fix(phase1): close auth and frontend acceptance gaps

This commit is contained in:
vsxd 2026-03-12 00:37:51 +08:00
parent 46bda26f9e
commit 0b1607630f
18 changed files with 737 additions and 207 deletions

View file

@ -1,11 +1,15 @@
.PHONY: dev dev-down build test clean
.PHONY: dev dev-down build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api prod-up prod-down
# 启动本地开发环境(仅依赖服务)
dev:
docker compose up -d
@echo "Waiting for services to be healthy..."
@sleep 5
@echo "Services ready. Start backend with: cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local"
@echo "Services ready. Start backend with: make dev-server"
@echo "Start frontend with: make dev-web"
dev-server:
cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local
# 停止本地开发环境
dev-down:
@ -29,6 +33,9 @@ generate-api:
@echo "Generating OpenAPI types..."
cd web && pnpm run generate-api
web-install:
cd web && pnpm install
# 前端开发服务器
dev-web:
cd web && pnpm run dev
@ -48,3 +55,9 @@ typecheck-web:
# 前端代码检查
lint-web:
cd web && pnpm run lint
prod-up:
docker compose -f docker-compose.prod.yml up -d --build
prod-down:
docker compose -f docker-compose.prod.yml down

86
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,86 @@
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: skillhub
POSTGRES_USER: skillhub
POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U skillhub"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
server:
build:
context: ./server
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub
SPRING_DATASOURCE_USERNAME: skillhub
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID}
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
web:
build:
context: ./web
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
server:
condition: service_healthy
volumes:
postgres_data:
minio_data:

14
server/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/skillhub-app/target/*.jar app.jar
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]

View file

@ -0,0 +1,33 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/cli")
public class CliController {
@GetMapping("/whoami")
public ResponseEntity<Map<String, Object>> whoami(@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
return ResponseEntity.status(401).build();
}
return ResponseEntity.ok(Map.of(
"data", Map.of(
"userId", principal.userId(),
"displayName", principal.displayName(),
"email", principal.email() != null ? principal.email() : "",
"avatarUrl", principal.avatarUrl() != null ? principal.avatarUrl() : "",
"authType", principal.oauthProvider(),
"platformRoles", principal.platformRoles()
)
));
}
}

View file

@ -29,10 +29,14 @@ public class TokenController {
var result = apiTokenService.createToken(principal.userId(), name, scopeJson);
return ResponseEntity.ok(Map.of(
"token", result.rawToken(),
"id", result.entity().getId(),
"name", result.entity().getName(),
"tokenPrefix", result.entity().getTokenPrefix()
"data", Map.of(
"token", result.rawToken(),
"id", result.entity().getId(),
"name", result.entity().getName(),
"tokenPrefix", result.entity().getTokenPrefix(),
"createdAt", result.entity().getCreatedAt().toString(),
"expiresAt", result.entity().getExpiresAt() != null ? result.entity().getExpiresAt().toString() : ""
)
));
}

View file

@ -0,0 +1,59 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/cli/whoami"))
.andExpect(status().isUnauthorized());
}
@Test
void whoamiShouldReturnCurrentPrincipal() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
7L,
"cli-user",
"cli@example.com",
"",
"api_token",
Set.of("SKILL_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))
);
mockMvc.perform(get("/api/v1/cli/whoami").with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.userId").value(7))
.andExpect(jsonPath("$.data.displayName").value("cli-user"))
.andExpect(jsonPath("$.data.authType").value("api_token"))
.andExpect(jsonPath("$.data.platformRoles[0]").value("SKILL_ADMIN"));
}
}

View file

@ -16,7 +16,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@ -64,4 +63,12 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return !(path.startsWith("/api/v1/cli/")
|| path.startsWith("/api/v1/tokens")
|| path.startsWith("/api/compat/"));
}
}

14
web/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s \
CMD wget -qO- http://localhost/nginx-health || exit 1

48
web/nginx.conf Normal file
View file

@ -0,0 +1,48 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /oauth2/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /login/oauth2/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /.well-known/ {
proxy_pass http://server:8080;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /nginx-health {
return 200 'ok';
add_header Content-Type text/plain;
}
}

View file

@ -4,17 +4,20 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"install:ci": "pnpm install --frozen-lockfile",
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@tanstack/react-router": "^1.95.0",
"@tanstack/react-query": "^5.64.0",
"openapi-fetch": "^0.13.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.344.0",
@ -33,6 +36,7 @@
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5"
"eslint-plugin-react-refresh": "^0.4.5",
"openapi-typescript": "^7.6.1"
}
}

195
web/pnpm-lock.yaml generated
View file

@ -23,6 +23,9 @@ importers:
lucide-react:
specifier: ^0.344.0
version: 0.344.0(react@19.2.4)
openapi-fetch:
specifier: ^0.13.8
version: 0.13.8
react:
specifier: ^19.0.0
version: 19.2.4
@ -60,6 +63,9 @@ importers:
eslint-plugin-react-refresh:
specifier: ^0.4.5
version: 0.4.26(eslint@8.57.1)
openapi-typescript:
specifier: ^7.6.1
version: 7.13.0(typescript@5.9.3)
postcss:
specifier: ^8.4.0
version: 8.5.8
@ -377,6 +383,16 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.10':
resolution: {integrity: sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@ -650,9 +666,17 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -727,6 +751,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -745,6 +772,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@ -950,6 +980,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -962,6 +996,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
@ -1004,6 +1042,10 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -1022,6 +1064,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@ -1070,6 +1115,10 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@ -1106,6 +1155,18 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openapi-fetch@0.13.8:
resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==}
openapi-typescript-helpers@0.0.15:
resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==}
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -1122,6 +1183,10 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -1160,6 +1225,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@ -1238,6 +1307,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -1315,6 +1388,10 @@ packages:
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@ -1372,6 +1449,10 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@ -1383,6 +1464,9 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1449,6 +1533,13 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -1478,7 +1569,7 @@ snapshots:
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -1560,7 +1651,7 @@ snapshots:
'@babel/parser': 7.29.0
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@ -1657,7 +1748,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.14.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@ -1673,7 +1764,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@ -1713,6 +1804,29 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.10(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.1.1
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.59.0':
@ -1884,7 +1998,7 @@ snapshots:
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 7.18.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 8.57.1
optionalDependencies:
typescript: 5.9.3
@ -1900,7 +2014,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3)
'@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 8.57.1
ts-api-utils: 1.4.3(typescript@5.9.3)
optionalDependencies:
@ -1914,7 +2028,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/visitor-keys': 7.18.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.9
@ -1961,6 +2075,8 @@ snapshots:
acorn@8.16.0: {}
agent-base@7.1.4: {}
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
@ -1968,6 +2084,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
@ -2034,6 +2152,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
change-case@5.4.4: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@ -2058,6 +2178,8 @@ snapshots:
color-name@1.1.4: {}
colorette@1.4.0: {}
commander@4.1.1: {}
concat-map@0.0.1: {}
@ -2076,9 +2198,11 @@ snapshots:
csstype@3.2.3: {}
debug@4.4.3:
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
deep-is@0.1.4: {}
@ -2157,7 +2281,7 @@ snapshots:
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@ -2297,6 +2421,13 @@ snapshots:
dependencies:
function-bind: 1.1.2
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
ignore@5.3.2: {}
import-fresh@3.3.1:
@ -2306,6 +2437,8 @@ snapshots:
imurmurhash@0.1.4: {}
index-to-position@1.2.0: {}
inflight@1.0.6:
dependencies:
once: 1.4.0
@ -2337,6 +2470,8 @@ snapshots:
jiti@1.21.7: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@ -2349,6 +2484,8 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {}
@ -2391,6 +2528,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
minimatch@5.1.9:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
@ -2419,6 +2560,22 @@ snapshots:
dependencies:
wrappy: 1.0.2
openapi-fetch@0.13.8:
dependencies:
openapi-typescript-helpers: 0.0.15
openapi-typescript-helpers@0.0.15: {}
openapi-typescript@7.13.0(typescript@5.9.3):
dependencies:
'@redocly/openapi-core': 1.34.10(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 5.9.3
yargs-parser: 21.1.1
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@ -2440,6 +2597,12 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.0
index-to-position: 1.2.0
type-fest: 4.41.0
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
@ -2460,6 +2623,8 @@ snapshots:
pirates@4.0.7: {}
pluralize@8.0.0: {}
postcss-import@15.1.0(postcss@8.5.8):
dependencies:
postcss: 8.5.8
@ -2520,6 +2685,8 @@ snapshots:
dependencies:
picomatch: 2.3.1
require-from-string@2.0.2: {}
resolve-from@4.0.0: {}
resolve@1.22.11:
@ -2607,6 +2774,8 @@ snapshots:
tinyglobby: 0.2.15
ts-interface-checker: 0.1.13
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@ -2678,6 +2847,8 @@ snapshots:
type-fest@0.20.2: {}
type-fest@4.41.0: {}
typescript@5.9.3: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
@ -2686,6 +2857,8 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uri-js-replace@1.0.1: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@ -2718,4 +2891,8 @@ snapshots:
yallist@3.1.1: {}
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {}
yocto-queue@0.1.0: {}

View file

@ -1,78 +1,96 @@
import type {
User,
OAuthProvider,
ApiToken,
CreateTokenRequest,
CreateTokenResponse,
ApiResponse,
} from './types'
import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import type { CreateTokenRequest, CreateTokenResponse, User } from './types'
// 基础 fetch 封装
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
const client = createClient<paths>({ baseUrl: '' })
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Request failed' }))
throw new Error(error.message || `HTTP ${res.status}`)
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : null
}
function withCsrf(headers?: HeadersInit): HeadersInit {
const csrfToken = getCsrfToken()
if (!csrfToken) {
return headers ?? {}
}
return res.json()
return {
...headers,
'X-XSRF-TOKEN': csrfToken,
}
}
async function unwrap<T>(promise: Promise<{ data?: T; error?: unknown; response: Response }>): Promise<T> {
const { data, error, response } = await promise
if (response.status === 401) {
throw new Error('HTTP 401')
}
if (error) {
throw new Error(`HTTP ${response.status}`)
}
if (data === undefined) {
throw new Error(`HTTP ${response.status}`)
}
return data
}
export async function getCurrentUser(): Promise<User | null> {
try {
return await unwrap(client.GET('/api/v1/auth/me'))
} catch (error) {
if (error instanceof Error && error.message === 'HTTP 401') {
return null
}
throw error
}
}
// Auth API
export const authApi = {
// 获取当前用户信息
async getMe(): Promise<User | null> {
try {
return await fetchJson<User>('/api/v1/auth/me')
} catch (error) {
// 401 表示未登录,返回 null
if (error instanceof Error && error.message.includes('401')) {
return null
}
throw error
getMe: getCurrentUser,
async getProviders() {
const response = await unwrap(client.GET('/api/v1/auth/providers'))
return response.data
},
async logout(): Promise<void> {
const { response, error } = await client.POST('/api/v1/auth/logout', {
headers: withCsrf(),
})
if (error || (response.status !== 200 && response.status !== 204)) {
throw new Error(`HTTP ${response.status}`)
}
},
// 获取可用的 OAuth 提供商
async getProviders(): Promise<OAuthProvider[]> {
const response = await fetchJson<ApiResponse<OAuthProvider[]>>('/api/v1/auth/providers')
return response.data
},
// 登出
async logout(): Promise<void> {
await fetch('/api/v1/auth/logout', { method: 'POST' })
},
}
// Token API
export const tokenApi = {
// 获取所有 Token
async getTokens(): Promise<ApiToken[]> {
const response = await fetchJson<ApiResponse<ApiToken[]>>('/api/v1/tokens')
async getTokens() {
const response = await unwrap(client.GET('/api/v1/tokens'))
return response.data
},
// 创建新 Token
async createToken(request: CreateTokenRequest): Promise<CreateTokenResponse> {
const response = await fetchJson<ApiResponse<CreateTokenResponse>>('/api/v1/tokens', {
method: 'POST',
body: JSON.stringify(request),
})
const response = await unwrap(client.POST('/api/v1/tokens', {
headers: withCsrf({
'Content-Type': 'application/json',
}),
body: request,
}))
return response.data
},
// 删除 Token
async deleteToken(tokenId: number): Promise<void> {
await fetch(`/api/v1/tokens/${tokenId}`, {
method: 'DELETE',
const { response, error } = await client.DELETE('/api/v1/tokens/{id}', {
params: {
path: {
id: tokenId,
},
},
headers: withCsrf(),
})
if (error || response.status !== 204) {
throw new Error(`HTTP ${response.status}`)
}
},
}

125
web/src/api/generated/schema.d.ts vendored Normal file
View file

@ -0,0 +1,125 @@
export interface paths {
'/api/v1/auth/me': {
get: {
responses: {
200: {
content: {
'application/json': components['schemas']['User']
}
}
401: {
content?: never
}
}
}
}
'/api/v1/auth/providers': {
get: {
responses: {
200: {
content: {
'application/json': components['schemas']['ApiResponse_OAuthProviderList']
}
}
}
}
}
'/api/v1/auth/logout': {
post: {
responses: {
200: {
content?: never
}
204: {
content?: never
}
}
}
}
'/api/v1/tokens': {
get: {
responses: {
200: {
content: {
'application/json': components['schemas']['ApiResponse_ApiTokenList']
}
}
}
}
post: {
requestBody: {
content: {
'application/json': components['schemas']['CreateTokenRequest']
}
}
responses: {
200: {
content: {
'application/json': components['schemas']['ApiResponse_CreateTokenResponse']
}
}
}
}
}
'/api/v1/tokens/{id}': {
delete: {
parameters: {
path: {
id: number
}
}
responses: {
204: {
content?: never
}
}
}
}
}
export interface components {
schemas: {
User: {
userId: number
displayName: string
email: string
avatarUrl: string
oauthProvider: string
platformRoles: string[]
}
OAuthProvider: {
id: string
name: string
authorizationUrl: string
}
ApiToken: {
id: number
name: string
tokenPrefix: string
createdAt: string
expiresAt: string
lastUsedAt: string
}
CreateTokenRequest: {
name: string
scopes?: string[]
}
CreateTokenResponse: {
token: string
id: number
name: string
tokenPrefix: string
createdAt: string
expiresAt: string
}
ApiResponse_OAuthProviderList: {
data: components['schemas']['OAuthProvider'][]
}
ApiResponse_ApiTokenList: {
data: components['schemas']['ApiToken'][]
}
ApiResponse_CreateTokenResponse: {
data: components['schemas']['CreateTokenResponse']
}
}
}

View file

@ -1,52 +1,7 @@
// API 类型定义
import type { components } from './generated/schema'
export interface User {
userId: number
displayName: string
email: string
avatarUrl: string
oauthProvider: string
platformRoles: string[]
}
export interface OAuthProvider {
id: string
name: string
authorizationUrl: string
}
export interface ApiToken {
tokenId: number
name: string
tokenPrefix: string
createdAt: string
lastUsedAt: string | null
expiresAt: string | null
}
export interface CreateTokenRequest {
name: string
expiresInDays?: number
}
export interface CreateTokenResponse {
token: string
tokenId: number
name: string
tokenPrefix: string
createdAt: string
expiresAt: string | null
}
export interface ApiResponse<T> {
data: T
message?: string
}
export interface ApiError {
error: string
message: string
path: string
timestamp: string
requestId: string
}
export type User = components['schemas']['User']
export type OAuthProvider = components['schemas']['OAuthProvider']
export type ApiToken = components['schemas']['ApiToken']
export type CreateTokenRequest = components['schemas']['CreateTokenRequest']
export type CreateTokenResponse = components['schemas']['CreateTokenResponse']

View file

@ -1,8 +1,9 @@
import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router'
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
import { Layout } from './layout'
import { HomePage } from '@/pages/home'
import { LoginPage } from '@/pages/login'
import { DashboardPage } from '@/pages/dashboard'
import { getCurrentUser } from '@/api/client'
const rootRoute = createRootRoute({
component: Layout,
@ -23,6 +24,13 @@ const loginRoute = createRoute({
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
beforeLoad: async () => {
const user = await getCurrentUser()
if (!user) {
throw redirect({ to: '/login' })
}
return { user }
},
component: DashboardPage,
})

View file

@ -1,32 +0,0 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useAuth } from './use-auth'
interface AuthGuardProps {
children: React.ReactNode
}
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, isLoading } = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (!isLoading && !isAuthenticated) {
navigate({ to: '/login' })
}
}, [isLoading, isAuthenticated, navigate])
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="text-muted-foreground">...</div>
</div>
)
}
if (!isAuthenticated) {
return null
}
return <>{children}</>
}

View file

@ -71,7 +71,7 @@ export function TokenList() {
</TableHeader>
<TableBody>
{tokens.map((token) => (
<TableRow key={token.tokenId}>
<TableRow key={token.id}>
<TableCell className="font-medium">{token.name}</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
@ -85,7 +85,7 @@ export function TokenList() {
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(token.tokenId, token.name)}
onClick={() => handleDelete(token.id, token.name)}
disabled={deleteMutation.isPending}
>

View file

@ -1,4 +1,3 @@
import { AuthGuard } from '@/features/auth/auth-guard'
import { useAuth } from '@/features/auth/use-auth'
import { TokenList } from '@/features/token/token-list'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
@ -7,57 +6,55 @@ export function DashboardPage() {
const { user } = useAuth()
return (
<AuthGuard>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-1">
API Tokens
</p>
</div>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-1">
API Tokens
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
{user?.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.displayName}
className="h-16 w-16 rounded-full"
/>
)}
<div className="space-y-1">
<div className="text-lg font-semibold">{user?.displayName}</div>
<div className="text-sm text-muted-foreground">{user?.email}</div>
<div className="text-xs text-muted-foreground">
{user?.oauthProvider}
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
{user?.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.displayName}
className="h-16 w-16 rounded-full"
/>
)}
<div className="space-y-1">
<div className="text-lg font-semibold">{user?.displayName}</div>
<div className="text-sm text-muted-foreground">{user?.email}</div>
<div className="text-xs text-muted-foreground">
{user?.oauthProvider}
</div>
</div>
{user?.platformRoles && user.platformRoles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="flex flex-wrap gap-2">
{user.platformRoles.map((role) => (
<span
key={role}
className="inline-flex items-center rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
{role}
</span>
))}
</div>
</div>
{user?.platformRoles && user.platformRoles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="flex flex-wrap gap-2">
{user.platformRoles.map((role) => (
<span
key={role}
className="inline-flex items-center rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
{role}
</span>
))}
</div>
)}
</CardContent>
</Card>
</div>
)}
</CardContent>
</Card>
<TokenList />
</div>
</AuthGuard>
<TokenList />
</div>
)
}