Compare commits
10 Commits
695f34bc3d
...
595d59ab5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595d59ab5b | ||
|
|
5c1a998192 | ||
|
|
ad847f1e4c | ||
|
|
3943bd112f | ||
|
|
90513e8278 | ||
|
|
8db25538d4 | ||
|
|
66cce5a765 | ||
|
|
140c268412 | ||
|
|
e7496ed41b | ||
|
|
2aa992c88d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ dist
|
||||
.next
|
||||
.turbo
|
||||
out
|
||||
next-env.d.ts
|
||||
|
||||
# Environment
|
||||
.env.local
|
||||
|
||||
52
CLAUDE.md
52
CLAUDE.md
@@ -38,6 +38,9 @@ pnpm db:generate # 生成 Prisma Client
|
||||
pnpm db:push # 推送 schema 到数据库
|
||||
pnpm db:migrate # 运行迁移
|
||||
cd apps/api && pnpm db:studio # 打开 Prisma Studio
|
||||
|
||||
# 代码生成
|
||||
pnpm generate # CRUD 模块生成器(交互式,生成后端+前端+类型+种子脚本)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -61,6 +64,25 @@ cd apps/api && pnpm db:studio # 打开 Prisma Studio
|
||||
| 组件本地状态 | useState | 表单输入、弹窗开关 |
|
||||
| URL 状态 | Next.js Router | 分页参数、筛选条件 |
|
||||
|
||||
**前端权限控制**:使用 `<PermissionGuard>` 组件包裹需要权限的 UI 元素:
|
||||
```tsx
|
||||
<PermissionGuard permission="user:create">
|
||||
<CreateButton />
|
||||
</PermissionGuard>
|
||||
|
||||
// 多个权限(OR 关系)
|
||||
<PermissionGuard permission={['user:update', 'user:delete']}>
|
||||
<ActionMenu />
|
||||
</PermissionGuard>
|
||||
|
||||
// 多个权限(AND 关系)
|
||||
<PermissionGuard permission={['user:update', 'user:delete']} mode="all">
|
||||
<AdminPanel />
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
权限数据通过 `usePermissionStore` 管理,登录后自动缓存,退出时清除。
|
||||
|
||||
**数据请求分层**
|
||||
|
||||
```
|
||||
@@ -80,9 +102,20 @@ NestJS 采用模块化架构:
|
||||
- **AuthModule** - JWT 认证(注册、登录、token 验证)
|
||||
- **UserModule** - 用户 CRUD,继承 CrudService 基类
|
||||
- **CaptchaModule** - 验证码服务,支持多场景验证
|
||||
- **PermissionModule** - 权限管理(角色、权限、菜单)
|
||||
|
||||
认证流程:使用 `@Public()` 装饰器标记公开接口,其他接口需要 JWT Bearer Token。使用 `@CurrentUser()` 装饰器获取当前登录用户信息(类型为 `AuthUser`)。
|
||||
|
||||
**权限控制**:使用 `@RequirePermission()` 装饰器控制接口权限,配合 `PermissionGuard` 使用:
|
||||
```typescript
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@RequirePermission('user:create')
|
||||
@Post()
|
||||
create(@Body() dto: CreateUserDto) { ... }
|
||||
```
|
||||
|
||||
权限码格式:`{resource}:{action}`(如 `user:create`、`user:read`、`user:update`、`user:delete`)
|
||||
|
||||
**后端导入规范:禁止使用 Barrel Imports**
|
||||
|
||||
后端代码禁止使用 `index.ts` 统一导出(barrel exports),必须直接从具体文件导入:
|
||||
@@ -289,3 +322,22 @@ docs/
|
||||
- `packages/shared/src/types/` - 共享类型定义
|
||||
- `packages/shared/src/crypto/` - 跨平台加密实现
|
||||
- `apps/web/src/types/` - 前端特有类型定义
|
||||
|
||||
### 代码生成器
|
||||
|
||||
`pnpm generate` 启动交互式代码生成器,可一键生成完整 CRUD 模块:
|
||||
|
||||
- **后端**: Controller、Service、DTO、Module
|
||||
- **前端**: Service、Hooks、Table、CreateDialog、EditDialog、Page
|
||||
- **共享类型**: TypeScript 接口定义
|
||||
- **Prisma Model**: 数据库模型
|
||||
- **种子脚本**: 菜单和权限初始化数据
|
||||
|
||||
生成的代码自动集成权限控制(`@RequirePermission` + `PermissionGuard`)。详见 `plop/README.md`。
|
||||
|
||||
### 通用组件
|
||||
|
||||
**DataTable** (`components/shared/DataTable/`): 封装 TanStack Table 的通用数据表格组件
|
||||
- 支持服务端分页、排序、搜索
|
||||
- 内置加载状态、错误状态、空状态渲染
|
||||
- 使用示例见 `components/*/XxxTable.tsx`
|
||||
|
||||
@@ -8,6 +8,7 @@ JWT_EXPIRES_IN="7d"
|
||||
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ----- 加密配置 -----
|
||||
# 是否启用通信加密
|
||||
@@ -38,3 +39,8 @@ MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=seclusion
|
||||
MINIO_PUBLIC_URL=
|
||||
|
||||
# ----- OIDC Provider 配置 -----
|
||||
OIDC_ISSUER=http://localhost:4000/oidc
|
||||
OIDC_COOKIE_SECRET=oidc-cookie-secret-change-in-production
|
||||
OIDC_JWKS_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRRFhGYkJ5cDVKc2Y1UWsKS213UnhObkxOMHE2Rzk1djZjQ1ZENW5EZmpzeDE0cDZpS2NRaG5jZU9JMmF2bDdGUUJGZzcyN1QvMlFqRExYegpHd05rcUhpcmo0ZlNHeERzN2x3c1RqSkRNUE51akF0NnlIMVVMdDBrWUxJL2pwRlhUUWNKdTFKQlc5OWZwTnU1CitoQkZpMHd4cnljQmRpWjlEeVg2K0I2YjhzRDRlRzdZbXJOSzlhd3NuQjVmQTUxUG4rWHRrNUM1YktvRjI4N0sKb1owRGxPZU1jY003S0I4elc4L1ZyZnE4Mk9pNDd3T3ZScmIwQ2I5aG5Ia0dUQWhyT0xzNjE5R1oyM1FnZzg1cQpVL2FXMGZwSG56WHpETWV5ak5scXJoRlgvb1hQak5EaHMvUGE2MHozZWxtS1dOZ1ByQ0dtb08ySnd6NDlmZkVFCjBDU2xNTzZiQWdNQkFBRUNnZ0VBRmQzL3BYaUIrNFB4Qk1oSFduc2dCWGdtb2N0Smp5azl5aW5lNFRCSlJtVDYKa0VDcWM1U29NYXRnUWpaT25sRklNd25FdzhyNFhGUGpmOGJrVG15T2NDclVqVGp4UEpWelM1SGJyRmNpdUwrRwpQMEo0ODRFY1BLR1VIY0FaNkwxTkZPRTFtSzJGaFV6V2hnNzFib3lkLzRNbVBSRE5FdlBpVWFTK1ArNnJUZGVJCi9UbVdwTnVic25ockJ0RE1pSDZ2RDBSMEJBK1NzTkI2Nkw5elI2UUVqSXhCOW12aGpJaXJyR3hkREprcVRldFEKRnVZZUNMVVpSMXVYU1dYdXFzSWZQSmNpbjJtblVpdUhFa3Z0bUVlaG9mL1g1ZVJNYWsvTmc0UmtUWHF5Y2FudwpDN3NOdUFmai9rNU51QTlWazJVVi9qanVmUWowSktKb2drQUhWVGRtMlFLQmdRRHVZZGpkeXNkSjlkTXJJN3BFCnRZNTJPaU0vaWkyQ2dsanZvTXZFejljTEFiOEVNbVpiRlUrTmhWRUVVbDBkK3dJYTNmUFFBKzRVZ2t6U09oUHcKT1E1NVk0SUp5Rm9yOFdLZ0hBcUI4ZHo3dnlTZWVSNmZUM1R5TFdSN3RJKzV6ZVZEQU1pNFEwUnRKRDFMeFVzMApBQnlzK0lIc2hmYUd2N1NxN1lUMytsMkZGd0tCZ1FEbSt3NnVhTVhsTUVtUW9remVFTWYwTGNwVHMxcTJPN3ZtCk9XSFB5TTNnTlY5UWVIUUF1dC9mNmU1VWVPdnliWHg5SjAxcEU3Z29RdWRhRWpiSkl4eXgyWXlaYkVzS28rV3kKR05mWk9RL1F5YWxHL29PYXFxQUpPdFVGa0dvUzlOaDV1R0Y1eXY5YmRQRU9TeTN0dVhpNzVxNE9sQ3RYVnM1WApBbWtMTVd6ZEhRS0JnQllsM3lsVU1zbnJYaEJQQkhwbngvR3lHeDVITDAxRjRROTZpQlFrSDEyMWJ0THIvOWlNCmxWU1h3MXc4YnN4ZlN1WEdJMlg3UjM1K1VMYmprSUNzUEcwSTBzY241MERYNzRyaXNCTThyb1J4VU95c1lpejUKQyt1SVRpSzBOdnBUWis2ZXZ3ZG5zSTdYWkI2TEdSNmV1QXRXRjNRclNpbGczRjlaTEJhQ0czaEhBb0dBR3VRegp5MTVyVzhtSlp3dGVRNlJVZ3pzcGlTRWllSUR2MlZmbzZWWUprZ2JrdCt1dUpiK2IvT2V4VmFoV1gvMGJOejd5CkpqK2pleHgrN3QrYi9VTFhQbVdEbHdFaW8zUjljNFNzN0o5V0ZnckVhSDJOT042UWowS0lOb09mdGVGSHFyUXEKdFJGTE5ZeWgyL1lvdkxxUk1kOGplSk1Ma0xtTWdGakpmZ0lkR0lrQ2dZRUFoL3BMRlVnNFNueGRxWm51bG13cQpVaEgvcVY0ck9zY1hRVkJidHArOTNSWVJ6THQ2c0VRT2NNWExLVGtEcU5UZzBLamdub2wyM2NJbnVSSWNRcE9rClcvTUlqZEsrZnEvaXJUZnJ4bzM3cUhRNXo4cFdtOWY3L29TeHIvS1RlaWFTYkFrZ0ptSVdQeCt5bnhvQmZHV00Kcnp5dmlvUjV4Uk10YTBCaS8rTkxtM0U9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
|
||||
|
||||
@@ -28,6 +28,8 @@ JWT_EXPIRES_IN="7d"
|
||||
PORT=4000
|
||||
# 运行环境: development | production | test
|
||||
NODE_ENV=development
|
||||
# 前端 URL(用于 OIDC 交互重定向)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ----- 加密配置 -----
|
||||
# 是否启用通信加密 (true/false)
|
||||
@@ -68,3 +70,13 @@ MINIO_BUCKET=seclusion
|
||||
# 如果设置,将使用此 URL 作为文件访问地址前缀
|
||||
# 示例: https://cdn.example.com 或 https://example.com/storage
|
||||
MINIO_PUBLIC_URL=
|
||||
|
||||
# ----- OIDC Provider 配置 -----
|
||||
# OIDC 签发者 URL(必须是可公开访问的 URL,包含 /oidc 路径)
|
||||
OIDC_ISSUER=http://localhost:4000/oidc
|
||||
# OIDC Cookie 签名密钥(生产环境必须修改)
|
||||
OIDC_COOKIE_SECRET=oidc-cookie-secret-change-in-production
|
||||
# OIDC JWKS 私钥(RS256,PEM 格式,Base64 编码)
|
||||
# 生成方式: openssl genrsa 2048 | base64 -w 0
|
||||
# 注意: 生产环境必须配置,开发环境可留空使用临时密钥
|
||||
OIDC_JWKS_PRIVATE_KEY=
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@paralleldrive/cuid2": "^3.0.6",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"@seclusion/shared": "workspace:*",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -36,6 +37,7 @@
|
||||
"minio": "^8.0.6",
|
||||
"nanoid": "^5.1.6",
|
||||
"nodemailer": "^7.0.12",
|
||||
"oidc-provider": "^9.6.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -55,6 +57,7 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/oidc-provider": "^9.5.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ model User {
|
||||
|
||||
roles UserRole[]
|
||||
uploadFiles File[] @relation("FileUploader")
|
||||
oidcGrants OidcGrant[]
|
||||
|
||||
// 复合唯一约束:未删除用户邮箱唯一,已删除用户邮箱可重复
|
||||
@@unique([email, deletedAt])
|
||||
@@ -229,3 +230,73 @@ model ClassTeacher {
|
||||
@@unique([classId, teacherId])
|
||||
@@map("class_teachers")
|
||||
}
|
||||
|
||||
// ============ OIDC Provider 模块 ============
|
||||
|
||||
// OIDC 客户端应用
|
||||
model OidcClient {
|
||||
id String @id @default(cuid(2))
|
||||
clientId String @unique // 客户端 ID
|
||||
clientSecret String? // 客户端密钥(公开客户端可为空)
|
||||
clientName String // 客户端名称
|
||||
clientUri String? // 客户端主页
|
||||
logoUri String? // Logo URL
|
||||
redirectUris String[] // 回调地址列表
|
||||
postLogoutRedirectUris String[] // 登出后回调地址
|
||||
grantTypes String[] // 授权类型: authorization_code, refresh_token, client_credentials
|
||||
responseTypes String[] // 响应类型: code, token, id_token
|
||||
scopes String[] // 允许的 scope: openid, profile, email, etc.
|
||||
tokenEndpointAuthMethod String @default("client_secret_basic") // 认证方式
|
||||
applicationType String @default("web") // web / native
|
||||
isEnabled Boolean @default(true)
|
||||
isFirstParty Boolean @default(false) // 是否为第一方应用(跳过授权确认)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
grants OidcGrant[]
|
||||
refreshTokens OidcRefreshToken[]
|
||||
|
||||
@@map("oidc_clients")
|
||||
}
|
||||
|
||||
// OIDC 刷新令牌(长期,需要持久化)
|
||||
model OidcRefreshToken {
|
||||
id String @id @default(cuid(2))
|
||||
jti String @unique // JWT ID
|
||||
grantId String // 关联的授权 ID
|
||||
clientId String // 客户端 ID
|
||||
userId String // 用户 ID
|
||||
scope String // 授权范围
|
||||
data Json // 完整令牌数据
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
client OidcClient @relation(fields: [clientId], references: [clientId])
|
||||
|
||||
@@index([grantId])
|
||||
@@index([clientId])
|
||||
@@index([userId])
|
||||
@@map("oidc_refresh_tokens")
|
||||
}
|
||||
|
||||
// OIDC 授权记录(用户对客户端的授权)
|
||||
model OidcGrant {
|
||||
id String @id @default(cuid(2))
|
||||
grantId String @unique // oidc-provider 生成的 grant ID
|
||||
clientId String // 客户端 ID
|
||||
userId String // 用户 ID
|
||||
scope String // 已授权的 scope
|
||||
data Json // 完整授权数据
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
client OidcClient @relation(fields: [clientId], references: [clientId])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([clientId, userId])
|
||||
@@index([userId])
|
||||
@@map("oidc_grants")
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ const permissions = [
|
||||
{ code: 'student:read', name: '查看学生', resource: 'student', action: 'read' },
|
||||
{ code: 'student:update', name: '更新学生', resource: 'student', action: 'update' },
|
||||
{ code: 'student:delete', name: '删除学生', resource: 'student', action: 'delete' },
|
||||
// OIDC 客户端管理权限
|
||||
{ code: 'oidc-client:create', name: '创建 OIDC 客户端', resource: 'oidc-client', action: 'create' },
|
||||
{ code: 'oidc-client:read', name: '查看 OIDC 客户端', resource: 'oidc-client', action: 'read' },
|
||||
{ code: 'oidc-client:update', name: '更新 OIDC 客户端', resource: 'oidc-client', action: 'update' },
|
||||
{ code: 'oidc-client:delete', name: '删除 OIDC 客户端', resource: 'oidc-client', action: 'delete' },
|
||||
];
|
||||
|
||||
// 初始角色数据
|
||||
@@ -171,6 +176,15 @@ const menus = [
|
||||
sort: 4,
|
||||
isStatic: true,
|
||||
},
|
||||
{
|
||||
code: 'oidc-client-management',
|
||||
name: 'OIDC 客户端',
|
||||
type: 'menu',
|
||||
path: '/oidc-clients',
|
||||
icon: 'KeyRound',
|
||||
sort: 5,
|
||||
isStatic: true,
|
||||
},
|
||||
{
|
||||
code: 'profile',
|
||||
name: '个人中心',
|
||||
@@ -198,6 +212,7 @@ const systemSubMenuCodes = [
|
||||
'role-management',
|
||||
'permission-management',
|
||||
'menu-management',
|
||||
'oidc-client-management',
|
||||
];
|
||||
|
||||
// 教学管理子菜单 codes
|
||||
|
||||
0
apps/api/prisma/seeds/.gitkeep
Normal file
0
apps/api/prisma/seeds/.gitkeep
Normal file
@@ -12,6 +12,7 @@ import { MailModule } from './common/mail/mail.module';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { FileModule } from './file/file.module';
|
||||
import { OidcModule } from './oidc/oidc.module';
|
||||
import { PermissionModule } from './permission/permission.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { StudentModule } from './student/student.module';
|
||||
@@ -36,6 +37,7 @@ import { UserModule } from './user/user.module';
|
||||
AuthModule,
|
||||
UserModule,
|
||||
PermissionModule,
|
||||
OidcModule,
|
||||
// 教学管理模块
|
||||
TeacherModule,
|
||||
ClassModule,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RedisService } from './common/redis/redis.service';
|
||||
import { StorageService } from './common/storage/storage.service';
|
||||
import { HealthCheckResponseDto } from './health/dto/health.dto';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
@@ -8,17 +9,19 @@ import { PrismaService } from './prisma/prisma.service';
|
||||
export class AppService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly redisService: RedisService
|
||||
private readonly redisService: RedisService,
|
||||
private readonly storageService: StorageService
|
||||
) {}
|
||||
|
||||
async healthCheck(): Promise<HealthCheckResponseDto> {
|
||||
const [dbHealthy, redisHealthy] = await Promise.all([
|
||||
const [dbHealthy, redisHealthy, storageHealthy] = await Promise.all([
|
||||
this.prismaService.healthCheck(),
|
||||
this.redisService.healthCheck(),
|
||||
this.storageService.healthCheck(),
|
||||
]);
|
||||
|
||||
const allHealthy = dbHealthy && redisHealthy;
|
||||
const allDown = !dbHealthy && !redisHealthy;
|
||||
const allHealthy = dbHealthy && redisHealthy && storageHealthy;
|
||||
const allDown = !dbHealthy && !redisHealthy && !storageHealthy;
|
||||
|
||||
return {
|
||||
status: allHealthy ? 'ok' : allDown ? 'error' : 'degraded',
|
||||
@@ -30,6 +33,9 @@ export class AppService {
|
||||
redis: {
|
||||
status: redisHealthy ? 'ok' : 'error',
|
||||
},
|
||||
storage: {
|
||||
status: storageHealthy ? 'ok' : 'error',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,4 +85,17 @@ export class StorageService implements OnModuleInit {
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.client.bucketExists(this.bucket);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('Storage 健康检查失败', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export class ServicesHealthDto implements ServicesHealth {
|
||||
|
||||
@ApiProperty({ type: ServiceHealthDto, description: 'Redis 健康状态' })
|
||||
redis: ServiceHealthDto;
|
||||
|
||||
@ApiProperty({ type: ServiceHealthDto, description: '存储服务健康状态' })
|
||||
storage: ServiceHealthDto;
|
||||
}
|
||||
|
||||
export class HealthCheckResponseDto implements HealthCheckResponse {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { EncryptionInterceptor } from './common/crypto/encryption.interceptor';
|
||||
import { OidcService } from './oidc/oidc.service';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
// 日志级别: 'log' | 'error' | 'warn' | 'debug' | 'verbose'
|
||||
// 开发环境显示所有日志,生产环境只显示 error/warn/log
|
||||
logger:
|
||||
@@ -23,6 +27,19 @@ async function bootstrap() {
|
||||
const port = configService.get<number>('PORT', 4000);
|
||||
const enableEncryption = configService.get<string>('ENABLE_ENCRYPTION') === 'true';
|
||||
|
||||
// 挂载 OIDC Provider(必须在 NestJS 路由注册之前)
|
||||
const oidcService = app.get(OidcService);
|
||||
const provider = oidcService.getProvider();
|
||||
if (provider) {
|
||||
// 获取底层 Express 实例
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
// 使用 Express 原生方式挂载 oidc-provider 到 /oidc 路径
|
||||
expressApp.use('/oidc', provider.callback());
|
||||
logger.log('OIDC Provider middleware mounted');
|
||||
} else {
|
||||
logger.warn('OIDC Provider not initialized');
|
||||
}
|
||||
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
@@ -39,8 +56,9 @@ async function bootstrap() {
|
||||
}
|
||||
|
||||
// CORS 配置
|
||||
const frontendUrl = configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
|
||||
app.enableCors({
|
||||
origin: ['http://localhost:3000'],
|
||||
origin: [frontendUrl],
|
||||
credentials: true,
|
||||
exposedHeaders: ['X-Encrypted'],
|
||||
});
|
||||
@@ -56,9 +74,10 @@ async function bootstrap() {
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
await app.listen(port);
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
||||
console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`);
|
||||
console.log(`🔐 Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`);
|
||||
logger.log(`Application is running on: http://localhost:${port}`);
|
||||
logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
logger.log(`Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`);
|
||||
logger.log(`OIDC Discovery: http://localhost:${port}/oidc/.well-known/openid-configuration`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
41
apps/api/src/oidc/adapters/index.ts
Normal file
41
apps/api/src/oidc/adapters/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Adapter } from 'oidc-provider';
|
||||
|
||||
import { PrismaAdapter } from './prisma.adapter';
|
||||
import { RedisAdapter } from './redis.adapter';
|
||||
|
||||
import { RedisService } from '@/common/redis/redis.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
|
||||
// Prisma 存储的模型
|
||||
const PRISMA_MODELS = ['Client', 'Grant', 'RefreshToken'];
|
||||
|
||||
// Redis 存储的模型
|
||||
const REDIS_MODELS = [
|
||||
'AuthorizationCode',
|
||||
'AccessToken',
|
||||
'Session',
|
||||
'Interaction',
|
||||
'DeviceCode',
|
||||
'BackchannelAuthenticationRequest',
|
||||
'RegistrationAccessToken',
|
||||
'ReplayDetection',
|
||||
'PushedAuthorizationRequest',
|
||||
];
|
||||
|
||||
/**
|
||||
* 创建混合适配器工厂
|
||||
* 根据模型类型选择 Prisma 或 Redis 适配器
|
||||
*/
|
||||
export function createAdapterFactory(prisma: PrismaService, redis: RedisService) {
|
||||
return (model: string): Adapter => {
|
||||
if (PRISMA_MODELS.includes(model)) {
|
||||
return new PrismaAdapter(prisma, model);
|
||||
}
|
||||
if (REDIS_MODELS.includes(model)) {
|
||||
return new RedisAdapter(redis, model);
|
||||
}
|
||||
// 默认使用 Redis 适配器
|
||||
return new RedisAdapter(redis, model);
|
||||
};
|
||||
}
|
||||
168
apps/api/src/oidc/adapters/prisma.adapter.ts
Normal file
168
apps/api/src/oidc/adapters/prisma.adapter.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Adapter, AdapterPayload, ResponseType } from 'oidc-provider';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Prisma 适配器
|
||||
* 用于存储持久化数据:Client、Grant、RefreshToken
|
||||
*/
|
||||
export class PrismaAdapter implements Adapter {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private model: string
|
||||
) {}
|
||||
|
||||
async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
||||
const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : null;
|
||||
|
||||
switch (this.model) {
|
||||
case 'Client':
|
||||
// Client 由管理接口创建,这里不处理
|
||||
break;
|
||||
|
||||
case 'Grant': {
|
||||
// oidc-provider 的 Grant payload 中,scope 存储在 openid.scope 属性中
|
||||
const openid = payload.openid as { scope?: string } | undefined;
|
||||
const scopeString = openid?.scope || '';
|
||||
|
||||
await this.prisma.oidcGrant.upsert({
|
||||
where: { grantId: id },
|
||||
create: {
|
||||
grantId: id,
|
||||
clientId: payload.clientId as string,
|
||||
userId: payload.accountId as string,
|
||||
scope: scopeString,
|
||||
data: payload as object,
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
scope: scopeString,
|
||||
data: payload as object,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.upsert({
|
||||
where: { jti: id },
|
||||
create: {
|
||||
jti: id,
|
||||
grantId: payload.grantId as string,
|
||||
clientId: payload.clientId as string,
|
||||
userId: payload.accountId as string,
|
||||
scope: (payload.scope as string) || '',
|
||||
data: payload as object,
|
||||
expiresAt: expiresAt!,
|
||||
},
|
||||
update: {
|
||||
data: payload as object,
|
||||
expiresAt: expiresAt!,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async find(id: string): Promise<AdapterPayload | undefined> {
|
||||
switch (this.model) {
|
||||
case 'Client': {
|
||||
const client = await this.prisma.oidcClient.findUnique({
|
||||
where: { clientId: id, isEnabled: true },
|
||||
});
|
||||
if (!client) return undefined;
|
||||
|
||||
// 转换为 oidc-provider 期望的格式
|
||||
return {
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret ?? undefined,
|
||||
client_name: client.clientName,
|
||||
client_uri: client.clientUri ?? undefined,
|
||||
logo_uri: client.logoUri ?? undefined,
|
||||
redirect_uris: client.redirectUris,
|
||||
post_logout_redirect_uris: client.postLogoutRedirectUris,
|
||||
grant_types: client.grantTypes,
|
||||
response_types: client.responseTypes as ResponseType[],
|
||||
scope: client.scopes.join(' '),
|
||||
token_endpoint_auth_method: client.tokenEndpointAuthMethod as 'client_secret_basic' | 'client_secret_post' | 'none',
|
||||
application_type: client.applicationType as 'web' | 'native',
|
||||
// 自定义属性
|
||||
'urn:custom:first_party': client.isFirstParty,
|
||||
};
|
||||
}
|
||||
|
||||
case 'Grant': {
|
||||
const grant = await this.prisma.oidcGrant.findUnique({
|
||||
where: { grantId: id },
|
||||
});
|
||||
if (!grant) return undefined;
|
||||
if (grant.expiresAt && grant.expiresAt < new Date()) return undefined;
|
||||
return grant.data as AdapterPayload;
|
||||
}
|
||||
|
||||
case 'RefreshToken': {
|
||||
const token = await this.prisma.oidcRefreshToken.findUnique({
|
||||
where: { jti: id },
|
||||
});
|
||||
if (!token) return undefined;
|
||||
if (token.expiresAt < new Date()) return undefined;
|
||||
return token.data as AdapterPayload;
|
||||
}
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async findByUserCode(_userCode: string): Promise<AdapterPayload | undefined> {
|
||||
// Prisma 适配器不支持 userCode 查找
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async findByUid(_uid: string): Promise<AdapterPayload | undefined> {
|
||||
// Prisma 适配器不支持 uid 查找
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async consume(id: string): Promise<void> {
|
||||
switch (this.model) {
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.update({
|
||||
where: { jti: id },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
switch (this.model) {
|
||||
case 'Grant':
|
||||
await this.prisma.oidcGrant.delete({
|
||||
where: { grantId: id },
|
||||
}).catch(() => {
|
||||
// 忽略不存在的记录
|
||||
});
|
||||
break;
|
||||
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.delete({
|
||||
where: { jti: id },
|
||||
}).catch(() => {
|
||||
// 忽略不存在的记录
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async revokeByGrantId(grantId: string): Promise<void> {
|
||||
switch (this.model) {
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.deleteMany({
|
||||
where: { grantId },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
128
apps/api/src/oidc/adapters/redis.adapter.ts
Normal file
128
apps/api/src/oidc/adapters/redis.adapter.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Adapter, AdapterPayload } from 'oidc-provider';
|
||||
|
||||
import { RedisService } from '@/common/redis/redis.service';
|
||||
|
||||
// Redis Key 前缀
|
||||
const KEY_PREFIX = 'oidc';
|
||||
|
||||
// 各模型的默认 TTL(秒)
|
||||
const MODEL_TTL: Record<string, number> = {
|
||||
AuthorizationCode: 600, // 10 分钟
|
||||
AccessToken: 3600, // 1 小时
|
||||
Session: 14 * 24 * 3600, // 14 天
|
||||
Interaction: 3600, // 1 小时
|
||||
DeviceCode: 600, // 10 分钟
|
||||
BackchannelAuthenticationRequest: 600, // 10 分钟
|
||||
};
|
||||
|
||||
/**
|
||||
* Redis 适配器
|
||||
* 用于存储短期/临时数据:AuthorizationCode、AccessToken、Session、Interaction
|
||||
*/
|
||||
export class RedisAdapter implements Adapter {
|
||||
private keyPrefix: string;
|
||||
|
||||
constructor(
|
||||
private redis: RedisService,
|
||||
private model: string
|
||||
) {
|
||||
this.keyPrefix = `${KEY_PREFIX}:${model.toLowerCase()}`;
|
||||
}
|
||||
|
||||
private key(id: string): string {
|
||||
return `${this.keyPrefix}:${id}`;
|
||||
}
|
||||
|
||||
private grantKey(grantId: string): string {
|
||||
return `${KEY_PREFIX}:grant:${grantId}`;
|
||||
}
|
||||
|
||||
private userCodeKey(userCode: string): string {
|
||||
return `${KEY_PREFIX}:usercode:${userCode}`;
|
||||
}
|
||||
|
||||
private uidKey(uid: string): string {
|
||||
return `${KEY_PREFIX}:uid:${uid}`;
|
||||
}
|
||||
|
||||
async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
||||
const key = this.key(id);
|
||||
const ttl = expiresIn || MODEL_TTL[this.model] || 3600;
|
||||
|
||||
// 存储主数据
|
||||
await this.redis.setJson(key, payload, ttl);
|
||||
|
||||
// 如果有 grantId,建立索引
|
||||
if (payload.grantId) {
|
||||
const grantKey = this.grantKey(payload.grantId);
|
||||
await this.redis.sadd(grantKey, key);
|
||||
await this.redis.expire(grantKey, ttl);
|
||||
}
|
||||
|
||||
// 如果有 userCode,建立索引
|
||||
if (payload.userCode) {
|
||||
const userCodeKey = this.userCodeKey(payload.userCode);
|
||||
await this.redis.set(userCodeKey, id, ttl);
|
||||
}
|
||||
|
||||
// 如果有 uid,建立索引
|
||||
if (payload.uid) {
|
||||
const uidKey = this.uidKey(payload.uid);
|
||||
await this.redis.set(uidKey, id, ttl);
|
||||
}
|
||||
}
|
||||
|
||||
async find(id: string): Promise<AdapterPayload | undefined> {
|
||||
const data = await this.redis.getJson<AdapterPayload>(this.key(id));
|
||||
return data ?? undefined;
|
||||
}
|
||||
|
||||
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
|
||||
const id = await this.redis.get(this.userCodeKey(userCode));
|
||||
if (!id) return undefined;
|
||||
return this.find(id);
|
||||
}
|
||||
|
||||
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
|
||||
const id = await this.redis.get(this.uidKey(uid));
|
||||
if (!id) return undefined;
|
||||
return this.find(id);
|
||||
}
|
||||
|
||||
async consume(id: string): Promise<void> {
|
||||
const data = await this.find(id);
|
||||
if (data) {
|
||||
data.consumed = Math.floor(Date.now() / 1000);
|
||||
const ttl = await this.redis.ttl(this.key(id));
|
||||
if (ttl > 0) {
|
||||
await this.redis.setJson(this.key(id), data, ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
const data = await this.find(id);
|
||||
if (data) {
|
||||
// 清理关联索引
|
||||
if (data.grantId) {
|
||||
const grantKey = this.grantKey(data.grantId);
|
||||
await this.redis.getClient().srem(grantKey, this.key(id));
|
||||
}
|
||||
if (data.userCode) {
|
||||
await this.redis.del(this.userCodeKey(data.userCode));
|
||||
}
|
||||
if (data.uid) {
|
||||
await this.redis.del(this.uidKey(data.uid));
|
||||
}
|
||||
}
|
||||
await this.redis.del(this.key(id));
|
||||
}
|
||||
|
||||
async revokeByGrantId(grantId: string): Promise<void> {
|
||||
const grantKey = this.grantKey(grantId);
|
||||
const keys = await this.redis.smembers(grantKey);
|
||||
if (keys.length > 0) {
|
||||
await this.redis.del(...keys, grantKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
97
apps/api/src/oidc/client.controller.ts
Normal file
97
apps/api/src/oidc/client.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
CreateOidcClientDto,
|
||||
CreateOidcClientResponseDto,
|
||||
OidcClientResponseDto,
|
||||
RegenerateSecretResponseDto,
|
||||
UpdateOidcClientDto,
|
||||
} from './dto/client.dto';
|
||||
import { OidcClientService } from './services/client.service';
|
||||
|
||||
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
|
||||
import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
|
||||
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
|
||||
import { RequirePermission } from '@/permission/decorators/require-permission.decorator';
|
||||
import { PermissionGuard } from '@/permission/guards/permission.guard';
|
||||
|
||||
|
||||
// 分页响应 DTO
|
||||
class PaginatedOidcClientResponseDto extends createPaginatedResponseDto(OidcClientResponseDto) {}
|
||||
|
||||
@ApiTags('OIDC 客户端管理')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller('oidc-clients')
|
||||
export class OidcClientController {
|
||||
constructor(private readonly clientService: OidcClientService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('oidc-client:read')
|
||||
@ApiOperation({ summary: '获取客户端列表' })
|
||||
@ApiOkResponse({ type: PaginatedOidcClientResponseDto, description: '客户端列表' })
|
||||
findAll(@Query() query: PaginationQueryDto) {
|
||||
return this.clientService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('oidc-client:read')
|
||||
@ApiOperation({ summary: '获取客户端详情' })
|
||||
@ApiOkResponse({ type: OidcClientResponseDto, description: '客户端详情' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.clientService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('oidc-client:create')
|
||||
@ApiOperation({ summary: '创建客户端' })
|
||||
@ApiCreatedResponse({ type: CreateOidcClientResponseDto, description: '创建成功,返回客户端信息和密钥' })
|
||||
create(@Body() dto: CreateOidcClientDto) {
|
||||
return this.clientService.createClient(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('oidc-client:update')
|
||||
@ApiOperation({ summary: '更新客户端' })
|
||||
@ApiOkResponse({ type: OidcClientResponseDto, description: '更新成功' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto) {
|
||||
return this.clientService.updateClient(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('oidc-client:delete')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '删除客户端' })
|
||||
@ApiNoContentResponse({ description: '删除成功' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.clientService.delete(id);
|
||||
}
|
||||
|
||||
@Post(':id/regenerate-secret')
|
||||
@RequirePermission('oidc-client:update')
|
||||
@ApiOperation({ summary: '重新生成客户端密钥' })
|
||||
@ApiOkResponse({ type: RegenerateSecretResponseDto, description: '新的客户端密钥' })
|
||||
regenerateSecret(@Param('id') id: string) {
|
||||
return this.clientService.regenerateSecret(id);
|
||||
}
|
||||
}
|
||||
124
apps/api/src/oidc/config/oidc.config.ts
Normal file
124
apps/api/src/oidc/config/oidc.config.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Adapter, Configuration, KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
import type { OidcAccountService } from '../services/account.service';
|
||||
|
||||
/**
|
||||
* 创建 OIDC Provider 配置
|
||||
*/
|
||||
export function createOidcConfiguration(
|
||||
_issuer: string,
|
||||
accountService: OidcAccountService,
|
||||
adapterFactory: (model: string) => Adapter,
|
||||
cookieKeys: string[],
|
||||
jwks: { keys: object[] },
|
||||
frontendUrl: string
|
||||
): Configuration {
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
return {
|
||||
// 适配器工厂
|
||||
adapter: adapterFactory,
|
||||
|
||||
// 账户查找
|
||||
findAccount: accountService.findAccount.bind(accountService),
|
||||
|
||||
// JWKS 配置
|
||||
jwks,
|
||||
|
||||
// 支持的功能
|
||||
features: {
|
||||
devInteractions: { enabled: false }, // 禁用开发模式交互
|
||||
rpInitiatedLogout: { enabled: true },
|
||||
revocation: { enabled: true },
|
||||
introspection: { enabled: true },
|
||||
},
|
||||
|
||||
// 支持的 claims
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
profile: ['name', 'picture'],
|
||||
email: ['email', 'email_verified'],
|
||||
},
|
||||
|
||||
// 令牌有效期
|
||||
ttl: {
|
||||
AccessToken: 3600, // 1 小时
|
||||
AuthorizationCode: 600, // 10 分钟
|
||||
RefreshToken: 30 * 24 * 3600, // 30 天
|
||||
IdToken: 3600, // 1 小时
|
||||
Interaction: 3600, // 1 小时
|
||||
Session: 14 * 24 * 3600, // 14 天
|
||||
Grant: 14 * 24 * 3600, // 14 天
|
||||
},
|
||||
|
||||
// Cookie 配置
|
||||
cookies: {
|
||||
keys: cookieKeys,
|
||||
long: {
|
||||
signed: true,
|
||||
path: '/', // 确保所有路径都能访问 Cookie
|
||||
sameSite: 'lax', // 允许同站点请求携带 Cookie
|
||||
secure: !isDev, // 开发环境允许 HTTP
|
||||
},
|
||||
short: {
|
||||
signed: true,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: !isDev,
|
||||
},
|
||||
},
|
||||
|
||||
// 交互 URL(重定向到前端页面)
|
||||
interactions: {
|
||||
url: (_ctx: KoaContextWithOIDC, interaction) =>
|
||||
`${frontendUrl}/oidc/interaction/${interaction.uid}`,
|
||||
},
|
||||
|
||||
// 路由前缀(相对于挂载点)
|
||||
routes: {
|
||||
authorization: '/authorize',
|
||||
token: '/token',
|
||||
userinfo: '/userinfo',
|
||||
jwks: '/jwks',
|
||||
revocation: '/revoke',
|
||||
introspection: '/introspect',
|
||||
end_session: '/logout',
|
||||
},
|
||||
|
||||
// PKCE 配置
|
||||
pkce: {
|
||||
required: () => false, // 可选,公开客户端建议启用
|
||||
},
|
||||
|
||||
// 响应类型
|
||||
responseTypes: ['code', 'code id_token', 'id_token', 'none'],
|
||||
|
||||
// 客户端默认配置
|
||||
clientDefaults: {
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
},
|
||||
|
||||
// 额外的客户端元数据
|
||||
extraClientMetadata: {
|
||||
properties: ['urn:custom:first_party'],
|
||||
},
|
||||
|
||||
// 渲染错误
|
||||
renderError: async (ctx, out, _error) => {
|
||||
ctx.type = 'html';
|
||||
ctx.body = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OIDC Error</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>OIDC Error</h1>
|
||||
<pre>${JSON.stringify(out, null, 2)}</pre>
|
||||
</body>
|
||||
</html>`;
|
||||
},
|
||||
};
|
||||
}
|
||||
186
apps/api/src/oidc/dto/client.dto.ts
Normal file
186
apps/api/src/oidc/dto/client.dto.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 创建 OIDC 客户端 DTO
|
||||
*/
|
||||
export class CreateOidcClientDto {
|
||||
@ApiProperty({ example: 'My Application', description: '客户端名称' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '客户端名称不能为空' })
|
||||
clientName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com', description: '客户端主页' })
|
||||
@IsOptional()
|
||||
@IsUrl({}, { message: '客户端主页必须是有效的 URL' })
|
||||
clientUri?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png', description: 'Logo URL' })
|
||||
@IsOptional()
|
||||
@IsUrl({}, { message: 'Logo URL 必须是有效的 URL' })
|
||||
logoUri?: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: ['https://example.com/callback'],
|
||||
description: '回调地址列表',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
redirectUris: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['https://example.com/logout'],
|
||||
description: '登出后回调地址',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
postLogoutRedirectUris?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['authorization_code', 'refresh_token'],
|
||||
description: '授权类型',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(['authorization_code', 'refresh_token', 'client_credentials'], { each: true })
|
||||
grantTypes?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['code'],
|
||||
description: '响应类型',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(['code', 'token', 'id_token', 'none'], { each: true })
|
||||
responseTypes?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['openid', 'profile', 'email'],
|
||||
description: '允许的 scope',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
scopes?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'client_secret_basic',
|
||||
description: '令牌端点认证方式',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['client_secret_basic', 'client_secret_post', 'none'])
|
||||
tokenEndpointAuthMethod?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'web',
|
||||
description: '应用类型',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['web', 'native'])
|
||||
applicationType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: false,
|
||||
description: '是否为第一方应用(跳过授权确认)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isFirstParty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 OIDC 客户端 DTO
|
||||
*/
|
||||
export class UpdateOidcClientDto extends PartialType(CreateOidcClientDto) {
|
||||
@ApiPropertyOptional({ example: true, description: '是否启用' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC 客户端响应 DTO
|
||||
*/
|
||||
export class OidcClientResponseDto {
|
||||
@ApiProperty({ example: 'clxxx', description: '记录 ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'my-app-client-id', description: '客户端 ID' })
|
||||
clientId: string;
|
||||
|
||||
@ApiProperty({ example: 'My Application', description: '客户端名称' })
|
||||
clientName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com', description: '客户端主页' })
|
||||
clientUri: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png', description: 'Logo URL' })
|
||||
logoUri: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
example: ['https://example.com/callback'],
|
||||
description: '回调地址列表',
|
||||
})
|
||||
redirectUris: string[];
|
||||
|
||||
@ApiProperty({
|
||||
example: ['https://example.com/logout'],
|
||||
description: '登出后回调地址',
|
||||
})
|
||||
postLogoutRedirectUris: string[];
|
||||
|
||||
@ApiProperty({
|
||||
example: ['authorization_code', 'refresh_token'],
|
||||
description: '授权类型',
|
||||
})
|
||||
grantTypes: string[];
|
||||
|
||||
@ApiProperty({ example: ['code'], description: '响应类型' })
|
||||
responseTypes: string[];
|
||||
|
||||
@ApiProperty({ example: ['openid', 'profile', 'email'], description: '允许的 scope' })
|
||||
scopes: string[];
|
||||
|
||||
@ApiProperty({ example: 'client_secret_basic', description: '令牌端点认证方式' })
|
||||
tokenEndpointAuthMethod: string;
|
||||
|
||||
@ApiProperty({ example: 'web', description: '应用类型' })
|
||||
applicationType: string;
|
||||
|
||||
@ApiProperty({ example: true, description: '是否启用' })
|
||||
isEnabled: boolean;
|
||||
|
||||
@ApiProperty({ example: false, description: '是否为第一方应用' })
|
||||
isFirstParty: boolean;
|
||||
|
||||
@ApiProperty({ example: '2026-01-20T10:00:00.000Z', description: '创建时间' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-20T10:00:00.000Z', description: '更新时间' })
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建客户端响应 DTO(包含密钥)
|
||||
*/
|
||||
export class CreateOidcClientResponseDto extends OidcClientResponseDto {
|
||||
@ApiProperty({ example: 'secret-xxx', description: '客户端密钥(仅创建时返回)' })
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成密钥响应 DTO
|
||||
*/
|
||||
export class RegenerateSecretResponseDto {
|
||||
@ApiProperty({ example: 'new-secret-xxx', description: '新的客户端密钥' })
|
||||
clientSecret: string;
|
||||
}
|
||||
99
apps/api/src/oidc/dto/interaction.dto.ts
Normal file
99
apps/api/src/oidc/dto/interaction.dto.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 登录请求 DTO
|
||||
*/
|
||||
export class OidcLoginDto {
|
||||
@ApiProperty({ example: 'user@example.com', description: '邮箱' })
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123', description: '密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权确认请求 DTO
|
||||
*/
|
||||
export class OidcConsentDto {
|
||||
@ApiProperty({
|
||||
example: ['openid', 'profile', 'email'],
|
||||
description: '授权的 scope 列表',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互详情响应 DTO
|
||||
*/
|
||||
export class OidcInteractionDetailsDto {
|
||||
@ApiProperty({ example: 'abc123', description: '交互 UID' })
|
||||
uid: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: { name: 'login', details: {} },
|
||||
description: '提示类型',
|
||||
})
|
||||
prompt: {
|
||||
name: 'login' | 'consent';
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@ApiProperty({
|
||||
example: {
|
||||
client_id: 'my-app',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid profile email',
|
||||
},
|
||||
description: '请求参数',
|
||||
})
|
||||
params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
response_type?: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
@ApiProperty({
|
||||
example: {
|
||||
clientId: 'my-app',
|
||||
clientName: 'My Application',
|
||||
logoUri: 'https://example.com/logo.png',
|
||||
},
|
||||
description: '客户端信息',
|
||||
})
|
||||
client: {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
};
|
||||
|
||||
@ApiProperty({
|
||||
example: { accountId: 'user123' },
|
||||
description: '会话信息(已登录时存在)',
|
||||
required: false,
|
||||
})
|
||||
session?: {
|
||||
accountId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互结果响应 DTO
|
||||
*/
|
||||
export class OidcInteractionResultDto {
|
||||
@ApiProperty({
|
||||
example: 'https://example.com/callback?code=xxx',
|
||||
description: '重定向 URL',
|
||||
})
|
||||
redirectTo: string;
|
||||
}
|
||||
202
apps/api/src/oidc/oidc.controller.ts
Normal file
202
apps/api/src/oidc/oidc.controller.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
|
||||
import { OidcConsentDto, OidcLoginDto } from './dto/interaction.dto';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { OidcInteractionService } from './services/interaction.service';
|
||||
|
||||
import { Public } from '@/auth/decorators/public.decorator';
|
||||
|
||||
@ApiTags('OIDC 交互')
|
||||
@Controller('oidc-interaction')
|
||||
export class OidcController {
|
||||
private readonly logger = new Logger(OidcController.name);
|
||||
|
||||
constructor(
|
||||
private readonly oidcService: OidcService,
|
||||
private readonly interactionService: OidcInteractionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取交互详情
|
||||
*/
|
||||
@Get(':uid')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '获取 OIDC 交互详情' })
|
||||
async getInteraction(@Req() req: Request, @Res() res: Response, @Param('uid') uid: string) {
|
||||
try {
|
||||
const details = await this.oidcService.interactionDetails(req, res);
|
||||
|
||||
// 获取客户端信息
|
||||
const provider = this.oidcService.getProvider();
|
||||
const client = await provider.Client.find(details.params.client_id as string);
|
||||
|
||||
// 检查是否是第一方应用 + 已登录用户 + consent prompt,自动完成授权
|
||||
if (
|
||||
details.prompt.name === 'consent' &&
|
||||
details.session?.accountId &&
|
||||
client?.['urn:custom:first_party']
|
||||
) {
|
||||
this.logger.debug('第一方应用自动授权', {
|
||||
clientId: details.params.client_id,
|
||||
accountId: details.session.accountId,
|
||||
});
|
||||
|
||||
const scopes = (details.params.scope as string).split(' ');
|
||||
const redirectTo = await this.interactionService.finishConsent(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res },
|
||||
details.grantId as string | undefined,
|
||||
scopes,
|
||||
details.params.client_id as string,
|
||||
details.session.accountId
|
||||
);
|
||||
|
||||
return res.json({ autoConsent: true, redirectTo });
|
||||
}
|
||||
|
||||
const response = {
|
||||
uid: details.uid,
|
||||
prompt: details.prompt,
|
||||
params: {
|
||||
client_id: details.params.client_id,
|
||||
redirect_uri: details.params.redirect_uri,
|
||||
scope: details.params.scope,
|
||||
response_type: details.params.response_type,
|
||||
state: details.params.state,
|
||||
nonce: details.params.nonce,
|
||||
},
|
||||
client: client
|
||||
? {
|
||||
clientId: client.clientId,
|
||||
clientName: client.clientName || client.clientId,
|
||||
logoUri: client.logoUri,
|
||||
clientUri: client.clientUri,
|
||||
}
|
||||
: null,
|
||||
session: details.session?.accountId
|
||||
? { accountId: details.session.accountId }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return res.json(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取交互详情失败: ${uid}`, error);
|
||||
return res.status(400).json({ error: 'invalid_request', message: '交互会话无效或已过期' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交登录
|
||||
*/
|
||||
@Post(':uid/login')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '提交 OIDC 登录' })
|
||||
async submitLogin(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Param('uid') _uid: string,
|
||||
@Body() dto: OidcLoginDto
|
||||
) {
|
||||
try {
|
||||
// 验证用户
|
||||
const accountId = await this.interactionService.validateLogin(dto.email, dto.password);
|
||||
|
||||
// 获取交互详情,用于第一方应用自动授权
|
||||
const details = await this.oidcService.interactionDetails(req, res);
|
||||
const clientId = details.params.client_id as string;
|
||||
const scopes = (details.params.scope as string).split(' ');
|
||||
|
||||
// 完成登录交互(第一方应用会自动完成授权)
|
||||
const provider = this.oidcService.getProvider();
|
||||
const redirectTo = await this.interactionService.finishLogin(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res },
|
||||
accountId,
|
||||
{ clientId, scopes }
|
||||
);
|
||||
|
||||
return res.json({ redirectTo });
|
||||
} catch (error) {
|
||||
this.logger.error('OIDC 登录失败', error);
|
||||
return res.status(401).json({
|
||||
error: 'authentication_failed',
|
||||
message: error instanceof Error ? error.message : '登录失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交授权确认
|
||||
*/
|
||||
@Post(':uid/confirm')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '提交 OIDC 授权确认' })
|
||||
async submitConsent(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Param('uid') _uid: string,
|
||||
@Body() dto: OidcConsentDto
|
||||
) {
|
||||
try {
|
||||
const details = await this.oidcService.interactionDetails(req, res);
|
||||
const provider = this.oidcService.getProvider();
|
||||
|
||||
const redirectTo = await this.interactionService.finishConsent(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res },
|
||||
details.grantId as string | undefined,
|
||||
dto.scopes,
|
||||
details.params.client_id as string,
|
||||
details.session!.accountId
|
||||
);
|
||||
|
||||
return res.json({ redirectTo });
|
||||
} catch (error) {
|
||||
this.logger.error('OIDC 授权确认失败', error);
|
||||
return res.status(400).json({
|
||||
error: 'consent_failed',
|
||||
message: error instanceof Error ? error.message : '授权确认失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中止授权
|
||||
*/
|
||||
@Post(':uid/abort')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '中止 OIDC 授权' })
|
||||
async abortInteraction(@Req() req: Request, @Res() res: Response, @Param('uid') _uid: string) {
|
||||
try {
|
||||
const provider = this.oidcService.getProvider();
|
||||
const redirectTo = await this.interactionService.abortInteraction(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res }
|
||||
);
|
||||
|
||||
return res.json({ redirectTo });
|
||||
} catch (error) {
|
||||
this.logger.error('OIDC 中止授权失败', error);
|
||||
return res.status(400).json({
|
||||
error: 'abort_failed',
|
||||
message: error instanceof Error ? error.message : '中止授权失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
15
apps/api/src/oidc/oidc.module.ts
Normal file
15
apps/api/src/oidc/oidc.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OidcClientController } from './client.controller';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { OidcAccountService } from './services/account.service';
|
||||
import { OidcClientService } from './services/client.service';
|
||||
import { OidcInteractionService } from './services/interaction.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OidcController, OidcClientController],
|
||||
providers: [OidcService, OidcAccountService, OidcInteractionService, OidcClientService],
|
||||
exports: [OidcService],
|
||||
})
|
||||
export class OidcModule {}
|
||||
152
apps/api/src/oidc/oidc.service.ts
Normal file
152
apps/api/src/oidc/oidc.service.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createPrivateKey, generateKeyPairSync, type JsonWebKey } from 'crypto';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { InteractionResults } from 'oidc-provider';
|
||||
import Provider from 'oidc-provider';
|
||||
|
||||
|
||||
import { createAdapterFactory } from './adapters';
|
||||
import { createOidcConfiguration } from './config/oidc.config';
|
||||
import { OidcAccountService } from './services/account.service';
|
||||
|
||||
import { RedisService } from '@/common/redis/redis.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* OIDC Provider 核心服务
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private readonly logger = new Logger(OidcService.name);
|
||||
private provider: Provider;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private redis: RedisService,
|
||||
private accountService: OidcAccountService
|
||||
) {
|
||||
// 在构造函数中初始化 provider,确保在模块注册时就可用
|
||||
this.initializeProvider();
|
||||
}
|
||||
|
||||
private initializeProvider() {
|
||||
const issuer = this.configService.get<string>('OIDC_ISSUER', 'http://localhost:4000/oidc');
|
||||
const cookieSecret = this.configService.get<string>(
|
||||
'OIDC_COOKIE_SECRET',
|
||||
'oidc-cookie-secret-change-in-production'
|
||||
);
|
||||
|
||||
// 解析 JWKS 私钥
|
||||
const jwksPrivateKeyBase64 = this.configService.get<string>('OIDC_JWKS_PRIVATE_KEY');
|
||||
let jwks: { keys: JsonWebKey[] };
|
||||
|
||||
if (jwksPrivateKeyBase64) {
|
||||
// 从 Base64 解码 PEM 格式私钥
|
||||
const privateKeyPem = Buffer.from(jwksPrivateKeyBase64, 'base64').toString('utf-8');
|
||||
const privateKey = createPrivateKey(privateKeyPem);
|
||||
const jwk = privateKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||
jwks = {
|
||||
keys: [
|
||||
{
|
||||
...jwk,
|
||||
use: 'sig',
|
||||
alg: 'RS256',
|
||||
kid: 'main',
|
||||
},
|
||||
],
|
||||
};
|
||||
this.logger.log('使用配置的 JWKS 私钥');
|
||||
} else {
|
||||
// 开发环境生成临时 RSA 密钥对
|
||||
this.logger.warn('OIDC_JWKS_PRIVATE_KEY 未配置,生成临时密钥(仅限开发环境)');
|
||||
const { privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
const jwk = privateKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||
jwks = {
|
||||
keys: [
|
||||
{
|
||||
...jwk,
|
||||
use: 'sig',
|
||||
alg: 'RS256',
|
||||
kid: 'dev-key',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 创建适配器工厂
|
||||
const adapterFactory = createAdapterFactory(this.prisma, this.redis);
|
||||
|
||||
// 获取前端 URL
|
||||
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
|
||||
|
||||
// 创建配置
|
||||
const configuration = createOidcConfiguration(
|
||||
issuer,
|
||||
this.accountService,
|
||||
adapterFactory,
|
||||
[cookieSecret],
|
||||
jwks,
|
||||
frontendUrl
|
||||
);
|
||||
|
||||
// 创建 Provider 实例
|
||||
this.provider = new Provider(issuer, configuration);
|
||||
|
||||
// 允许 HTTP(开发环境)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.provider.proxy = true;
|
||||
}
|
||||
|
||||
this.logger.log(`OIDC Provider 初始化完成,Issuer: ${issuer}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Provider 实例
|
||||
*/
|
||||
getProvider(): Provider {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 OIDC 请求的回调函数
|
||||
*/
|
||||
callback(req: Request, res: Response): void {
|
||||
this.provider.callback()(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交互详情
|
||||
*/
|
||||
async interactionDetails(req: Request, res: Response) {
|
||||
return this.provider.interactionDetails(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成交互
|
||||
*/
|
||||
async interactionFinished(
|
||||
req: Request,
|
||||
res: Response,
|
||||
result: InteractionResults,
|
||||
options?: { mergeWithLastSubmission?: boolean }
|
||||
) {
|
||||
return this.provider.interactionFinished(req, res, result, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交互结果 URL
|
||||
*/
|
||||
async interactionResult(
|
||||
req: Request,
|
||||
res: Response,
|
||||
result: InteractionResults,
|
||||
options?: { mergeWithLastSubmission?: boolean }
|
||||
) {
|
||||
return this.provider.interactionResult(req, res, result, options);
|
||||
}
|
||||
}
|
||||
44
apps/api/src/oidc/services/account.service.ts
Normal file
44
apps/api/src/oidc/services/account.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Account, AccountClaims, KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* OIDC 账户服务
|
||||
* 提供 oidc-provider 所需的账户查找功能
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcAccountService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 查找账户
|
||||
* oidc-provider 要求的 findAccount 方法
|
||||
*/
|
||||
async findAccount(_ctx: KoaContextWithOIDC, id: string): Promise<Account | undefined> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, email: true, name: true, avatarId: true },
|
||||
});
|
||||
|
||||
if (!user) return undefined;
|
||||
|
||||
return {
|
||||
accountId: user.id,
|
||||
claims: async (_use: string, scope: string): Promise<AccountClaims> => {
|
||||
const claims: AccountClaims = { sub: user.id };
|
||||
|
||||
if (scope.includes('profile')) {
|
||||
claims.name = user.name ?? undefined;
|
||||
claims.picture = user.avatarId ?? undefined;
|
||||
}
|
||||
if (scope.includes('email')) {
|
||||
claims.email = user.email;
|
||||
claims.email_verified = true;
|
||||
}
|
||||
|
||||
return claims;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
136
apps/api/src/oidc/services/client.service.ts
Normal file
136
apps/api/src/oidc/services/client.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import type { OidcClient, Prisma } from '@prisma/client';
|
||||
|
||||
import { CreateOidcClientDto, UpdateOidcClientDto } from '../dto/client.dto';
|
||||
|
||||
import { CrudOptions } from '@/common/crud/crud.decorator';
|
||||
import { CrudService } from '@/common/crud/crud.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
|
||||
/**
|
||||
* OIDC 客户端管理服务
|
||||
*/
|
||||
@Injectable()
|
||||
@CrudOptions({
|
||||
defaultSelect: {
|
||||
id: true,
|
||||
clientId: true,
|
||||
clientName: true,
|
||||
clientUri: true,
|
||||
logoUri: true,
|
||||
redirectUris: true,
|
||||
postLogoutRedirectUris: true,
|
||||
grantTypes: true,
|
||||
responseTypes: true,
|
||||
scopes: true,
|
||||
tokenEndpointAuthMethod: true,
|
||||
applicationType: true,
|
||||
isEnabled: true,
|
||||
isFirstParty: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
filterableFields: [
|
||||
{ field: 'clientId', operator: 'contains' },
|
||||
{ field: 'clientName', operator: 'contains' },
|
||||
],
|
||||
})
|
||||
export class OidcClientService extends CrudService<
|
||||
OidcClient,
|
||||
Prisma.OidcClientCreateInput,
|
||||
Prisma.OidcClientUpdateInput,
|
||||
Prisma.OidcClientWhereInput,
|
||||
Prisma.OidcClientWhereUniqueInput
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'oidcClient');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端 ID(使用 cuid2)
|
||||
*/
|
||||
private generateClientId(): string {
|
||||
return createId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端密钥(64 字符随机十六进制)
|
||||
*/
|
||||
private generateClientSecret(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建客户端(重写以生成 clientId 和 clientSecret)
|
||||
*/
|
||||
async createClient(dto: CreateOidcClientDto) {
|
||||
const clientId = this.generateClientId();
|
||||
const clientSecret = this.generateClientSecret();
|
||||
|
||||
const data: Prisma.OidcClientCreateInput = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
clientName: dto.clientName,
|
||||
clientUri: dto.clientUri,
|
||||
logoUri: dto.logoUri,
|
||||
redirectUris: dto.redirectUris,
|
||||
postLogoutRedirectUris: dto.postLogoutRedirectUris || [],
|
||||
grantTypes: dto.grantTypes || ['authorization_code', 'refresh_token'],
|
||||
responseTypes: dto.responseTypes || ['code'],
|
||||
scopes: dto.scopes || ['openid', 'profile', 'email'],
|
||||
tokenEndpointAuthMethod: dto.tokenEndpointAuthMethod || 'client_secret_basic',
|
||||
applicationType: dto.applicationType || 'web',
|
||||
isFirstParty: dto.isFirstParty || false,
|
||||
};
|
||||
|
||||
const client = await this.create(data);
|
||||
|
||||
return {
|
||||
...client,
|
||||
clientSecret, // 创建时返回密钥
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新客户端
|
||||
*/
|
||||
async updateClient(id: string, dto: UpdateOidcClientDto) {
|
||||
const data: Prisma.OidcClientUpdateInput = {};
|
||||
|
||||
if (dto.clientName !== undefined) data.clientName = dto.clientName;
|
||||
if (dto.clientUri !== undefined) data.clientUri = dto.clientUri;
|
||||
if (dto.logoUri !== undefined) data.logoUri = dto.logoUri;
|
||||
if (dto.redirectUris !== undefined) data.redirectUris = dto.redirectUris;
|
||||
if (dto.postLogoutRedirectUris !== undefined) data.postLogoutRedirectUris = dto.postLogoutRedirectUris;
|
||||
if (dto.grantTypes !== undefined) data.grantTypes = dto.grantTypes;
|
||||
if (dto.responseTypes !== undefined) data.responseTypes = dto.responseTypes;
|
||||
if (dto.scopes !== undefined) data.scopes = dto.scopes;
|
||||
if (dto.tokenEndpointAuthMethod !== undefined) data.tokenEndpointAuthMethod = dto.tokenEndpointAuthMethod;
|
||||
if (dto.applicationType !== undefined) data.applicationType = dto.applicationType;
|
||||
if (dto.isEnabled !== undefined) data.isEnabled = dto.isEnabled;
|
||||
if (dto.isFirstParty !== undefined) data.isFirstParty = dto.isFirstParty;
|
||||
|
||||
return this.update(id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成客户端密钥
|
||||
*/
|
||||
async regenerateSecret(id: string) {
|
||||
// 先检查记录是否存在
|
||||
await this.findById(id);
|
||||
|
||||
const clientSecret = this.generateClientSecret();
|
||||
|
||||
await this.prisma.oidcClient.update({
|
||||
where: { id },
|
||||
data: { clientSecret },
|
||||
});
|
||||
|
||||
return { clientSecret };
|
||||
}
|
||||
}
|
||||
159
apps/api/src/oidc/services/interaction.service.ts
Normal file
159
apps/api/src/oidc/services/interaction.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import type { InteractionResults, KoaContextWithOIDC, Provider } from 'oidc-provider';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* OIDC 交互服务
|
||||
* 处理登录、授权确认等交互流程
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcInteractionService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 验证用户登录
|
||||
*/
|
||||
async validateLogin(email: string, password: string): Promise<string> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email },
|
||||
select: { id: true, password: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成登录交互
|
||||
* @param autoConsent 如果为 true 且是第一方应用,自动完成授权
|
||||
*/
|
||||
async finishLogin(
|
||||
provider: Provider,
|
||||
ctx: KoaContextWithOIDC,
|
||||
accountId: string,
|
||||
autoConsent?: { clientId: string; scopes: string[] }
|
||||
): Promise<string> {
|
||||
// 检查是否需要自动完成授权(第一方应用)
|
||||
if (autoConsent) {
|
||||
const isFirstParty = await this.isFirstPartyClient(autoConsent.clientId);
|
||||
if (isFirstParty) {
|
||||
// 第一方应用:同时完成登录和授权
|
||||
const grant = new provider.Grant({
|
||||
accountId,
|
||||
clientId: autoConsent.clientId,
|
||||
});
|
||||
|
||||
for (const scope of autoConsent.scopes) {
|
||||
grant.addOIDCScope(scope);
|
||||
}
|
||||
|
||||
const grantId = await grant.save();
|
||||
|
||||
const result: InteractionResults = {
|
||||
login: { accountId, remember: true },
|
||||
consent: { grantId },
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 非第一方应用:仅完成登录,后续需要授权确认
|
||||
const result: InteractionResults = {
|
||||
login: {
|
||||
accountId,
|
||||
remember: true,
|
||||
},
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成授权确认交互
|
||||
*/
|
||||
async finishConsent(
|
||||
provider: Provider,
|
||||
ctx: KoaContextWithOIDC,
|
||||
grantId: string | undefined,
|
||||
scopes: string[],
|
||||
clientId: string,
|
||||
accountId: string
|
||||
): Promise<string> {
|
||||
let grant;
|
||||
|
||||
if (grantId) {
|
||||
// 更新现有授权
|
||||
grant = await provider.Grant.find(grantId);
|
||||
}
|
||||
|
||||
if (!grant) {
|
||||
// 创建新授权
|
||||
grant = new provider.Grant({
|
||||
accountId,
|
||||
clientId,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加授权的 scope
|
||||
for (const scope of scopes) {
|
||||
grant.addOIDCScope(scope);
|
||||
}
|
||||
|
||||
const newGrantId = await grant.save();
|
||||
|
||||
const result: InteractionResults = {
|
||||
consent: {
|
||||
grantId: newGrantId,
|
||||
},
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 中止交互
|
||||
*/
|
||||
async abortInteraction(
|
||||
provider: Provider,
|
||||
ctx: KoaContextWithOIDC,
|
||||
error: string = 'access_denied',
|
||||
errorDescription: string = '用户拒绝授权'
|
||||
): Promise<string> {
|
||||
const result: InteractionResults = {
|
||||
error,
|
||||
error_description: errorDescription,
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为第一方应用(跳过授权确认)
|
||||
*/
|
||||
async isFirstPartyClient(clientId: string): Promise<boolean> {
|
||||
const client = await this.prisma.oidcClient.findUnique({
|
||||
where: { clientId },
|
||||
select: { isFirstParty: true },
|
||||
});
|
||||
return client?.isFirstParty ?? false;
|
||||
}
|
||||
}
|
||||
6
apps/web/next-env.d.ts
vendored
6
apps/web/next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
37
apps/web/src/app/(dashboard)/oidc-clients/page.tsx
Normal file
37
apps/web/src/app/(dashboard)/oidc-clients/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { OidcClientsTable } from '@/components/oidc-clients';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
export default function OidcClientsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">OIDC 客户端管理</h1>
|
||||
<p className="text-muted-foreground">
|
||||
管理 OIDC 客户端应用,配置授权回调地址和权限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>客户端列表</CardTitle>
|
||||
<CardDescription>
|
||||
查看和管理所有 OIDC 客户端应用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OidcClientsTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { AvatarUpload } from '@/components/forms/AvatarUpload';
|
||||
import { PasswordForm } from '@/components/forms/PasswordForm';
|
||||
import { ProfileForm } from '@/components/forms/ProfileForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -15,26 +10,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { getUserMenusAndPermissions } from '@/services/permission.service';
|
||||
import { usePermissionStore } from '@/stores';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const setPermissionData = usePermissionStore((state) => state.setPermissionData);
|
||||
|
||||
const handleRefreshPermissions = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const data = await getUserMenusAndPermissions();
|
||||
setPermissionData(data);
|
||||
toast.success('权限缓存已刷新');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '刷新失败');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -72,21 +49,6 @@ export default function ProfilePage() {
|
||||
<PasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>刷新权限缓存</CardTitle>
|
||||
<CardDescription>
|
||||
如果您的权限或菜单发生变化,可以手动刷新以获取最新数据。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={handleRefreshPermissions} disabled={isRefreshing}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '刷新中...' : '刷新权限缓存'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeSettings, SidebarSettings } from '@/components/settings';
|
||||
import { ThemeSettings, SidebarSettings, PermissionSettings } from '@/components/settings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
@@ -13,6 +13,7 @@ export default function SettingsPage() {
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ThemeSettings />
|
||||
<SidebarSettings />
|
||||
<PermissionSettings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
41
apps/web/src/app/(oidc)/layout.tsx
Normal file
41
apps/web/src/app/(oidc)/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'OIDC 授权',
|
||||
template: `%s | ${siteConfig.name}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* OIDC 专用布局
|
||||
* 独立于主站布局,用于 OIDC 交互页面(登录、授权确认)
|
||||
*/
|
||||
export default function OidcLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-muted/30">
|
||||
{/* 顶部导航 */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-14 items-center justify-between">
|
||||
<span className="font-bold text-xl">{siteConfig.name}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="border-t py-4 bg-background">
|
||||
<div className="container text-center text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} {siteConfig.name}. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/app/(oidc)/oidc/error/page.tsx
Normal file
57
apps/web/src/app/(oidc)/oidc/error/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OIDC 错误',
|
||||
};
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
access_denied: '用户拒绝了授权请求',
|
||||
invalid_request: '无效的授权请求',
|
||||
unauthorized_client: '客户端未被授权',
|
||||
unsupported_response_type: '不支持的响应类型',
|
||||
invalid_scope: '无效的授权范围',
|
||||
server_error: '服务器内部错误',
|
||||
temporarily_unavailable: '服务暂时不可用',
|
||||
unknown_prompt: '未知的交互类型',
|
||||
interaction_not_found: '交互会话不存在或已过期',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ error?: string; error_description?: string }>;
|
||||
}
|
||||
|
||||
export default async function OidcErrorPage({ searchParams }: Props) {
|
||||
const { error, error_description } = await searchParams;
|
||||
const errorCode = error || 'server_error';
|
||||
const errorMessage =
|
||||
error_description || ERROR_MESSAGES[errorCode] || '发生未知错误';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-destructive">授权失败</CardTitle>
|
||||
<CardDescription>无法完成授权流程</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-muted-foreground mb-2">{errorMessage}</p>
|
||||
<p className="text-xs text-muted-foreground">错误代码: {errorCode}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">返回首页</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { OidcScopeDescriptions } from '@seclusion/shared';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { oidcInteractionService } from '@/services/oidc-interaction.service';
|
||||
|
||||
|
||||
interface OidcClient {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
uid: string;
|
||||
client: OidcClient;
|
||||
requestedScopes: string[];
|
||||
}
|
||||
|
||||
export function OidcConsentForm({ uid, client, requestedScopes }: Props) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 默认全部选中
|
||||
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(
|
||||
new Set(requestedScopes)
|
||||
);
|
||||
|
||||
const handleScopeChange = (scope: string, checked: boolean) => {
|
||||
setSelectedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(scope);
|
||||
} else {
|
||||
next.delete(scope);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await oidcInteractionService.submitConsent(uid, {
|
||||
scopes: Array.from(selectedScopes),
|
||||
});
|
||||
// 直接跳转,浏览器会自动携带 Cookie
|
||||
window.location.href = result.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '授权失败');
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async () => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await oidcInteractionService.abort(uid);
|
||||
window.location.href = result.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败');
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
{client.logoUri && (
|
||||
<img
|
||||
src={client.logoUri}
|
||||
alt={client.clientName}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<CardTitle className="text-xl">{client.clientName}</CardTitle>
|
||||
<CardDescription>请求访问您的账户</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
该应用请求以下权限:
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mb-4 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<ul className="space-y-3">
|
||||
{requestedScopes.map((scope) => (
|
||||
<li key={scope} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`scope_${scope}`}
|
||||
checked={selectedScopes.has(scope)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleScopeChange(scope, checked === true)
|
||||
}
|
||||
disabled={scope === 'openid' || isPending}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scope_${scope}`}
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{OidcScopeDescriptions[scope] || scope}
|
||||
</Label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDeny}
|
||||
disabled={isPending}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
授权
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
111
apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx
Normal file
111
apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { oidcInteractionService } from '@/services/oidc-interaction.service';
|
||||
|
||||
|
||||
interface OidcClient {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
uid: string;
|
||||
client: OidcClient;
|
||||
}
|
||||
|
||||
export function OidcLoginForm({ uid, client }: Props) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await oidcInteractionService.submitLogin(uid, { email, password });
|
||||
// 直接跳转,浏览器会自动携带 Cookie
|
||||
window.location.href = result.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
{client.logoUri && (
|
||||
<img
|
||||
src={client.logoUri}
|
||||
alt={client.clientName}
|
||||
className="w-10 h-10 rounded"
|
||||
/>
|
||||
)}
|
||||
<CardTitle className="text-xl">{client.clientName}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-center">
|
||||
登录以继续访问 {client.clientName}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
登录
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx
Normal file
76
apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { OidcInteractionDetails } from '@seclusion/shared';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { OidcConsentForm } from './OidcConsentForm';
|
||||
import { OidcLoginForm } from './OidcLoginForm';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:4000';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OIDC 授权',
|
||||
};
|
||||
|
||||
interface InteractionResponse extends OidcInteractionDetails {
|
||||
autoConsent?: boolean;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端获取交互详情
|
||||
*/
|
||||
async function getInteractionDetails(
|
||||
uid: string
|
||||
): Promise<InteractionResponse | null> {
|
||||
const cookieStore = await cookies();
|
||||
const res = await fetch(`${API_URL}/oidc-interaction/${uid}`, {
|
||||
headers: { Cookie: cookieStore.toString() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ uid: string }>;
|
||||
}
|
||||
|
||||
export default async function OidcInteractionPage({ params }: Props) {
|
||||
const { uid } = await params;
|
||||
const details = await getInteractionDetails(uid);
|
||||
|
||||
if (!details) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 第一方应用自动授权,直接重定向
|
||||
if (details.autoConsent && details.redirectTo) {
|
||||
redirect(details.redirectTo);
|
||||
}
|
||||
|
||||
// 客户端信息不存在时重定向到错误页
|
||||
if (!details.client) {
|
||||
redirect(`/oidc/error?error=invalid_client`);
|
||||
}
|
||||
|
||||
// 根据 prompt 类型渲染不同组件
|
||||
if (details.prompt.name === 'login') {
|
||||
return <OidcLoginForm uid={details.uid} client={details.client} />;
|
||||
}
|
||||
|
||||
if (details.prompt.name === 'consent') {
|
||||
const requestedScopes = details.params.scope.split(' ');
|
||||
return (
|
||||
<OidcConsentForm
|
||||
uid={details.uid}
|
||||
client={details.client}
|
||||
requestedScopes={requestedScopes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 未知 prompt 类型
|
||||
redirect(`/oidc/error?error=unknown_prompt`);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
|
||||
|
||||
import { Providers } from './providers';
|
||||
|
||||
import { getThemeInitScript } from '@/lib/theme-init-script';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -19,6 +21,12 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 主题初始化脚本 - 避免颜色闪烁 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: getThemeInitScript() }}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { OverallHealthStatus } from '@seclusion/shared';
|
||||
import { Users, UserCheck, UserPlus, Activity, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useHealthCheck } from '@/hooks/useHealth';
|
||||
import { useUsers, useDeletedUsers } from '@/hooks/useUsers';
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -52,6 +54,12 @@ function StatCard({
|
||||
);
|
||||
}
|
||||
|
||||
const healthStatusMap: Record<OverallHealthStatus, { label: string; description: string }> = {
|
||||
ok: { label: '正常', description: '所有服务运行正常' },
|
||||
degraded: { label: '部分异常', description: '部分服务运行异常' },
|
||||
error: { label: '异常', description: '服务运行异常' },
|
||||
};
|
||||
|
||||
export function DashboardStats() {
|
||||
const {
|
||||
data: usersData,
|
||||
@@ -65,13 +73,24 @@ export function DashboardStats() {
|
||||
refetch: refetchDeleted,
|
||||
} = useDeletedUsers({ pageSize: 1 });
|
||||
|
||||
const isLoading = isLoadingUsers || isLoadingDeleted;
|
||||
const {
|
||||
data: healthData,
|
||||
isLoading: isLoadingHealth,
|
||||
refetch: refetchHealth,
|
||||
} = useHealthCheck();
|
||||
|
||||
const isLoading = isLoadingUsers || isLoadingDeleted || isLoadingHealth;
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchUsers();
|
||||
refetchDeleted();
|
||||
refetchHealth();
|
||||
};
|
||||
|
||||
const healthStatus = healthData?.status
|
||||
? healthStatusMap[healthData.status]
|
||||
: { label: '-', description: '加载中...' };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -114,10 +133,10 @@ export function DashboardStats() {
|
||||
|
||||
<StatCard
|
||||
title="系统状态"
|
||||
value="正常"
|
||||
description="所有服务运行正常"
|
||||
value={healthStatus.label}
|
||||
description={healthStatus.description}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
isLoading={false}
|
||||
isLoading={isLoadingHealth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { loginSchema, type LoginFormValues } from '@/lib/validations';
|
||||
import { authService } from '@/services/auth.service';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useAuthStore, usePermissionStore } from '@/stores';
|
||||
|
||||
|
||||
export function LoginForm() {
|
||||
const { setAuth } = useAuthStore();
|
||||
const clearPermissionData = usePermissionStore((state) => state.clearPermissionData);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const captchaRef = useRef<CaptchaRef>(null);
|
||||
|
||||
@@ -48,6 +49,8 @@ export function LoginForm() {
|
||||
captchaCode: values.captchaCode,
|
||||
});
|
||||
|
||||
// 清除旧的权限缓存,确保切换账号后重新加载权限
|
||||
clearPermissionData();
|
||||
setAuth(
|
||||
response.accessToken,
|
||||
response.refreshToken,
|
||||
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { registerSchema, type RegisterFormValues } from '@/lib/validations';
|
||||
import { authService } from '@/services/auth.service';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useAuthStore, usePermissionStore } from '@/stores';
|
||||
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const { setAuth } = useAuthStore();
|
||||
const clearPermissionData = usePermissionStore((state) => state.clearPermissionData);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<RegisterFormValues>({
|
||||
@@ -52,6 +53,8 @@ export function RegisterForm() {
|
||||
captchaCode: values.captchaCode,
|
||||
});
|
||||
|
||||
// 清除旧的权限缓存,确保切换账号后重新加载权限
|
||||
clearPermissionData();
|
||||
setAuth(
|
||||
response.accessToken,
|
||||
response.refreshToken,
|
||||
|
||||
477
apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx
Normal file
477
apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCreateOidcClient } from '@/hooks/useOidcClients';
|
||||
|
||||
// 可选的授权类型
|
||||
const GRANT_TYPE_OPTIONS = [
|
||||
{ value: 'authorization_code', label: '授权码模式' },
|
||||
{ value: 'refresh_token', label: '刷新令牌' },
|
||||
{ value: 'client_credentials', label: '客户端凭证' },
|
||||
] as const;
|
||||
|
||||
// 可选的响应类型
|
||||
const RESPONSE_TYPE_OPTIONS = [
|
||||
{ value: 'code', label: 'code' },
|
||||
{ value: 'id_token', label: 'id_token' },
|
||||
{ value: 'token', label: 'token' },
|
||||
] as const;
|
||||
|
||||
// 可选的 Scope
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 'openid', label: 'openid(必需)' },
|
||||
{ value: 'profile', label: 'profile(姓名、头像)' },
|
||||
{ value: 'email', label: 'email(邮箱地址)' },
|
||||
{ value: 'offline_access', label: 'offline_access(刷新令牌)' },
|
||||
] as const;
|
||||
|
||||
// Token 端点认证方式
|
||||
const TOKEN_AUTH_METHOD_OPTIONS = [
|
||||
{ value: 'client_secret_basic', label: 'Basic 认证(推荐)' },
|
||||
{ value: 'client_secret_post', label: 'POST Body' },
|
||||
{ value: 'none', label: '无(公开客户端)' },
|
||||
] as const;
|
||||
|
||||
const createClientSchema = z.object({
|
||||
clientName: z.string().min(1, '请输入客户端名称'),
|
||||
clientUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
logoUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
redirectUris: z.string().min(1, '请输入至少一个回调地址'),
|
||||
postLogoutRedirectUris: z.string().optional(),
|
||||
grantTypes: z.array(z.string()).min(1, '请至少选择一种授权类型'),
|
||||
responseTypes: z.array(z.string()).min(1, '请至少选择一种响应类型'),
|
||||
scopes: z.array(z.string()).min(1, '请至少选择 openid'),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
applicationType: z.enum(['web', 'native']),
|
||||
isFirstParty: z.boolean(),
|
||||
});
|
||||
|
||||
type CreateClientFormValues = z.infer<typeof createClientSchema>;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function OidcClientCreateDialog({ open, onOpenChange }: Props) {
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
const createClient = useCreateOidcClient();
|
||||
|
||||
const form = useForm<CreateClientFormValues>({
|
||||
resolver: zodResolver(createClientSchema),
|
||||
defaultValues: {
|
||||
clientName: '',
|
||||
clientUri: '',
|
||||
logoUri: '',
|
||||
redirectUris: '',
|
||||
postLogoutRedirectUris: '',
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
responseTypes: ['code'],
|
||||
scopes: ['openid'],
|
||||
tokenEndpointAuthMethod: 'client_secret_basic',
|
||||
applicationType: 'web',
|
||||
isFirstParty: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: CreateClientFormValues) => {
|
||||
try {
|
||||
const result = await createClient.mutateAsync({
|
||||
clientName: values.clientName,
|
||||
clientUri: values.clientUri || undefined,
|
||||
logoUri: values.logoUri || undefined,
|
||||
redirectUris: values.redirectUris.split('\n').filter(Boolean),
|
||||
postLogoutRedirectUris: values.postLogoutRedirectUris
|
||||
? values.postLogoutRedirectUris.split('\n').filter(Boolean)
|
||||
: undefined,
|
||||
grantTypes: values.grantTypes,
|
||||
responseTypes: values.responseTypes,
|
||||
scopes: values.scopes,
|
||||
tokenEndpointAuthMethod: values.tokenEndpointAuthMethod,
|
||||
applicationType: values.applicationType,
|
||||
isFirstParty: values.isFirstParty,
|
||||
});
|
||||
setCreatedSecret(result.clientSecret);
|
||||
toast.success('客户端创建成功');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
setCreatedSecret(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const copySecret = () => {
|
||||
if (createdSecret) {
|
||||
navigator.clipboard.writeText(createdSecret);
|
||||
toast.success('密钥已复制');
|
||||
}
|
||||
};
|
||||
|
||||
// 显示创建成功后的密钥
|
||||
if (createdSecret) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>客户端创建成功</DialogTitle>
|
||||
<DialogDescription>
|
||||
请妥善保存以下客户端密钥,此密钥仅显示一次。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">客户端密钥</p>
|
||||
<p className="font-mono text-sm break-all">{createdSecret}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={copySecret}>
|
||||
复制密钥
|
||||
</Button>
|
||||
<Button onClick={handleClose}>完成</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建客户端</DialogTitle>
|
||||
<DialogDescription>创建一个新的 OIDC 客户端应用。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>客户端名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="我的应用" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用类型</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="web">Web 应用</SelectItem>
|
||||
<SelectItem value="native">原生应用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isFirstParty"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>第一方应用</FormLabel>
|
||||
<FormDescription className="text-xs">
|
||||
跳过授权确认
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>回调地址</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com/callback 每行一个地址"
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>每行输入一个回调地址</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用主页(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo 地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com/logo.png" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postLogoutRedirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>登出回调地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com/logout 每行一个地址"
|
||||
rows={2}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>用户登出后的跳转地址,每行一个</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>授权类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{GRANT_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>响应类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{RESPONSE_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>允许的 Scope</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SCOPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={option.value === 'openid'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenEndpointAuthMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token 端点认证方式</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TOKEN_AUTH_METHOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={createClient.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={createClient.isPending}>
|
||||
{createClient.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
478
apps/web/src/components/oidc-clients/OidcClientEditDialog.tsx
Normal file
478
apps/web/src/components/oidc-clients/OidcClientEditDialog.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { OidcClientResponse } from '@seclusion/shared';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useUpdateOidcClient } from '@/hooks/useOidcClients';
|
||||
|
||||
// 可选的授权类型
|
||||
const GRANT_TYPE_OPTIONS = [
|
||||
{ value: 'authorization_code', label: '授权码模式' },
|
||||
{ value: 'refresh_token', label: '刷新令牌' },
|
||||
{ value: 'client_credentials', label: '客户端凭证' },
|
||||
] as const;
|
||||
|
||||
// 可选的响应类型
|
||||
const RESPONSE_TYPE_OPTIONS = [
|
||||
{ value: 'code', label: 'code' },
|
||||
{ value: 'id_token', label: 'id_token' },
|
||||
{ value: 'token', label: 'token' },
|
||||
] as const;
|
||||
|
||||
// 可选的 Scope
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 'openid', label: 'openid(必需)' },
|
||||
{ value: 'profile', label: 'profile(姓名、头像)' },
|
||||
{ value: 'email', label: 'email(邮箱地址)' },
|
||||
{ value: 'offline_access', label: 'offline_access(刷新令牌)' },
|
||||
] as const;
|
||||
|
||||
// Token 端点认证方式
|
||||
const TOKEN_AUTH_METHOD_OPTIONS = [
|
||||
{ value: 'client_secret_basic', label: 'Basic 认证(推荐)' },
|
||||
{ value: 'client_secret_post', label: 'POST Body' },
|
||||
{ value: 'none', label: '无(公开客户端)' },
|
||||
] as const;
|
||||
|
||||
const editClientSchema = z.object({
|
||||
clientName: z.string().min(1, '请输入客户端名称'),
|
||||
clientUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
logoUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
redirectUris: z.string().min(1, '请输入至少一个回调地址'),
|
||||
postLogoutRedirectUris: z.string().optional(),
|
||||
grantTypes: z.array(z.string()).min(1, '请至少选择一种授权类型'),
|
||||
responseTypes: z.array(z.string()).min(1, '请至少选择一种响应类型'),
|
||||
scopes: z.array(z.string()).min(1, '请至少选择 openid'),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
applicationType: z.enum(['web', 'native']),
|
||||
isEnabled: z.boolean(),
|
||||
isFirstParty: z.boolean(),
|
||||
});
|
||||
|
||||
type EditClientFormValues = z.infer<typeof editClientSchema>;
|
||||
|
||||
interface Props {
|
||||
client: OidcClientResponse | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function OidcClientEditDialog({ client, open, onOpenChange }: Props) {
|
||||
const updateClient = useUpdateOidcClient();
|
||||
|
||||
const form = useForm<EditClientFormValues>({
|
||||
resolver: zodResolver(editClientSchema),
|
||||
defaultValues: {
|
||||
clientName: '',
|
||||
clientUri: '',
|
||||
logoUri: '',
|
||||
redirectUris: '',
|
||||
postLogoutRedirectUris: '',
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
responseTypes: ['code'],
|
||||
scopes: ['openid'],
|
||||
tokenEndpointAuthMethod: 'client_secret_basic',
|
||||
applicationType: 'web',
|
||||
isEnabled: true,
|
||||
isFirstParty: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 当 client 变化时重置表单
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
form.reset({
|
||||
clientName: client.clientName,
|
||||
clientUri: client.clientUri || '',
|
||||
logoUri: client.logoUri || '',
|
||||
redirectUris: client.redirectUris.join('\n'),
|
||||
postLogoutRedirectUris: client.postLogoutRedirectUris.join('\n'),
|
||||
grantTypes: client.grantTypes,
|
||||
responseTypes: client.responseTypes,
|
||||
scopes: client.scopes,
|
||||
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
||||
applicationType: client.applicationType as 'web' | 'native',
|
||||
isEnabled: client.isEnabled,
|
||||
isFirstParty: client.isFirstParty,
|
||||
});
|
||||
}
|
||||
}, [client, form]);
|
||||
|
||||
const onSubmit = async (values: EditClientFormValues) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await updateClient.mutateAsync({
|
||||
id: client.id,
|
||||
data: {
|
||||
clientName: values.clientName,
|
||||
clientUri: values.clientUri || undefined,
|
||||
logoUri: values.logoUri || undefined,
|
||||
redirectUris: values.redirectUris.split('\n').filter(Boolean),
|
||||
postLogoutRedirectUris: values.postLogoutRedirectUris
|
||||
? values.postLogoutRedirectUris.split('\n').filter(Boolean)
|
||||
: undefined,
|
||||
grantTypes: values.grantTypes,
|
||||
responseTypes: values.responseTypes,
|
||||
scopes: values.scopes,
|
||||
tokenEndpointAuthMethod: values.tokenEndpointAuthMethod,
|
||||
applicationType: values.applicationType,
|
||||
isEnabled: values.isEnabled,
|
||||
isFirstParty: values.isFirstParty,
|
||||
},
|
||||
});
|
||||
toast.success('客户端更新成功');
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑客户端</DialogTitle>
|
||||
<DialogDescription>
|
||||
客户端 ID: {client?.clientId}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>客户端名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用类型</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="web">Web 应用</SelectItem>
|
||||
<SelectItem value="native">原生应用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>启用状态</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isFirstParty"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>第一方应用</FormLabel>
|
||||
<FormDescription className="text-xs">
|
||||
跳过授权确认页面
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>回调地址</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>每行输入一个回调地址</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用主页(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo 地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com/logo.png" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postLogoutRedirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>登出回调地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com/logout 每行一个地址"
|
||||
rows={2}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>用户登出后的跳转地址,每行一个</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>授权类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{GRANT_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>响应类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{RESPONSE_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>允许的 Scope</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SCOPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={option.value === 'openid'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenEndpointAuthMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token 端点认证方式</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TOKEN_AUTH_METHOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={updateClient.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateClient.isPending}>
|
||||
{updateClient.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
331
apps/web/src/components/oidc-clients/OidcClientsTable.tsx
Normal file
331
apps/web/src/components/oidc-clients/OidcClientsTable.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import type { OidcClientResponse } from '@seclusion/shared';
|
||||
import { formatDate } from '@seclusion/shared';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Copy, Key, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { OidcClientCreateDialog } from './OidcClientCreateDialog';
|
||||
import { OidcClientEditDialog } from './OidcClientEditDialog';
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
type PaginationState,
|
||||
type SortingParams,
|
||||
} from '@/components/shared/DataTable';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { PAGINATION } from '@/config/constants';
|
||||
import {
|
||||
useDeleteOidcClient,
|
||||
useOidcClients,
|
||||
useRegenerateOidcClientSecret,
|
||||
} from '@/hooks/useOidcClients';
|
||||
|
||||
interface ClientActionsProps {
|
||||
client: OidcClientResponse;
|
||||
onEdit: (client: OidcClientResponse) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRegenerateSecret: (id: string) => void;
|
||||
}
|
||||
|
||||
function ClientActions({
|
||||
client,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerateSecret,
|
||||
}: ClientActionsProps) {
|
||||
const copyClientId = () => {
|
||||
navigator.clipboard.writeText(client.clientId);
|
||||
toast.success('客户端 ID 已复制');
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">打开菜单</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={copyClientId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制客户端 ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(client)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onRegenerateSecret(client.id)}>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
重新生成密钥
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(client.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function OidcClientsTable() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
page: PAGINATION.DEFAULT_PAGE,
|
||||
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [sorting, setSorting] = useState<SortingParams>({});
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [clientToEdit, setClientToEdit] = useState<OidcClientResponse | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [clientToDelete, setClientToDelete] = useState<string | null>(null);
|
||||
const [secretDialogOpen, setSecretDialogOpen] = useState(false);
|
||||
const [clientToRegenerate, setClientToRegenerate] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = useOidcClients({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...sorting,
|
||||
});
|
||||
|
||||
const deleteClient = useDeleteOidcClient();
|
||||
const regenerateSecret = useRegenerateOidcClientSecret();
|
||||
|
||||
const handleEdit = useCallback((client: OidcClientResponse) => {
|
||||
setClientToEdit(client);
|
||||
setEditDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
setClientToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!clientToDelete) return;
|
||||
try {
|
||||
await deleteClient.mutateAsync(clientToDelete);
|
||||
toast.success('客户端已删除');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setClientToDelete(null);
|
||||
}
|
||||
}, [clientToDelete, deleteClient]);
|
||||
|
||||
const handleRegenerateSecret = useCallback((id: string) => {
|
||||
setClientToRegenerate(id);
|
||||
setSecretDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmRegenerateSecret = useCallback(async () => {
|
||||
if (!clientToRegenerate) return;
|
||||
try {
|
||||
const result = await regenerateSecret.mutateAsync(clientToRegenerate);
|
||||
toast.success(
|
||||
<div>
|
||||
<p>密钥已重新生成</p>
|
||||
<p className="font-mono text-xs mt-1 break-all">{result.clientSecret}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">请妥善保存,此密钥仅显示一次</p>
|
||||
</div>,
|
||||
{ duration: 10000 }
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '重新生成失败');
|
||||
} finally {
|
||||
setSecretDialogOpen(false);
|
||||
setClientToRegenerate(null);
|
||||
}
|
||||
}, [clientToRegenerate, regenerateSecret]);
|
||||
|
||||
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
|
||||
setPagination(newPagination);
|
||||
}, []);
|
||||
|
||||
const handleSortingChange = useCallback((newSorting: SortingParams) => {
|
||||
setSorting(newSorting);
|
||||
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||
}, []);
|
||||
|
||||
const columns: ColumnDef<OidcClientResponse>[] = [
|
||||
{
|
||||
accessorKey: 'clientName',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
title="客户端名称"
|
||||
sortKey={column.id}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'clientId',
|
||||
header: '客户端 ID',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.clientId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'applicationType',
|
||||
header: '类型',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">
|
||||
{row.original.applicationType === 'web' ? 'Web' : 'Native'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isEnabled',
|
||||
header: '状态',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isEnabled ? 'default' : 'secondary'}>
|
||||
{row.original.isEnabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isFirstParty',
|
||||
header: '第一方',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isFirstParty ? 'default' : 'secondary'}>
|
||||
{row.original.isFirstParty ? '是' : '否'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
title="创建时间"
|
||||
sortKey={column.id}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<ClientActions
|
||||
client={row.original}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onRegenerateSecret={handleRegenerateSecret}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>创建客户端</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
pagination={pagination}
|
||||
paginationInfo={
|
||||
data ? { total: data.total, totalPages: data.totalPages } : undefined
|
||||
}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
manualPagination
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
manualSorting
|
||||
isLoading={isLoading}
|
||||
emptyMessage="暂无客户端"
|
||||
/>
|
||||
|
||||
{/* 创建客户端弹窗 */}
|
||||
<OidcClientCreateDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 编辑客户端弹窗 */}
|
||||
<OidcClientEditDialog
|
||||
client={clientToEdit}
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除该客户端吗?删除后使用该客户端的应用将无法正常工作。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 重新生成密钥确认弹窗 */}
|
||||
<AlertDialog open={secretDialogOpen} onOpenChange={setSecretDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>重新生成密钥</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要重新生成客户端密钥吗?旧密钥将立即失效,使用旧密钥的应用需要更新配置。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmRegenerateSecret}>
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/components/oidc-clients/index.ts
Normal file
3
apps/web/src/components/oidc-clients/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { OidcClientsTable } from './OidcClientsTable';
|
||||
export { OidcClientCreateDialog } from './OidcClientCreateDialog';
|
||||
export { OidcClientEditDialog } from './OidcClientEditDialog';
|
||||
71
apps/web/src/components/settings/HueSlider.tsx
Normal file
71
apps/web/src/components/settings/HueSlider.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HueSliderProps {
|
||||
value: number;
|
||||
onChange: (hue: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 色相滑块组件
|
||||
* 拖拽选择 0-360 色相值,背景显示彩虹渐变
|
||||
*/
|
||||
export function HueSlider({ value, onChange, className }: HueSliderProps) {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(parseInt(e.target.value, 10));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// 当前色相对应的颜色预览
|
||||
const previewColor = useMemo(() => {
|
||||
return `hsl(${value}, 70%, 50%)`;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">色相值</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-5 w-5 rounded-full border border-border"
|
||||
style={{ backgroundColor: previewColor }}
|
||||
/>
|
||||
<span className="text-sm font-medium tabular-nums">{value}°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={359}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
'h-3 w-full cursor-pointer appearance-none rounded-full',
|
||||
// 彩虹渐变背景
|
||||
'[background:linear-gradient(to_right,hsl(0,70%,50%),hsl(60,70%,50%),hsl(120,70%,50%),hsl(180,70%,50%),hsl(240,70%,50%),hsl(300,70%,50%),hsl(360,70%,50%))]',
|
||||
// 滑块样式 - Webkit (Chrome, Safari)
|
||||
'[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2',
|
||||
'[&::-webkit-slider-thumb]:border-gray-300 [&::-webkit-slider-thumb]:shadow-md',
|
||||
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing',
|
||||
'[&::-webkit-slider-thumb]:hover:scale-110 [&::-webkit-slider-thumb]:transition-transform',
|
||||
// 滑块样式 - Firefox
|
||||
'[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5',
|
||||
'[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full',
|
||||
'[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2',
|
||||
'[&::-moz-range-thumb]:border-gray-300 [&::-moz-range-thumb]:shadow-md',
|
||||
'[&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:active:cursor-grabbing'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/settings/PermissionSettings.tsx
Normal file
51
apps/web/src/components/settings/PermissionSettings.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { getUserMenusAndPermissions } from '@/services/permission.service';
|
||||
import { usePermissionStore } from '@/stores';
|
||||
|
||||
export function PermissionSettings() {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const setPermissionData = usePermissionStore((state) => state.setPermissionData);
|
||||
|
||||
const handleRefreshPermissions = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const data = await getUserMenusAndPermissions();
|
||||
setPermissionData(data);
|
||||
toast.success('权限缓存已刷新');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '刷新失败');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>权限缓存</CardTitle>
|
||||
<CardDescription>
|
||||
如果您的权限或菜单发生变化,可以手动刷新以获取最新数据。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={handleRefreshPermissions} disabled={isRefreshing}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '刷新中...' : '刷新权限缓存'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { Moon, Sun, Monitor, Check, Palette } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
|
||||
import { HueSlider } from './HueSlider';
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -11,9 +14,14 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { THEME_PRESETS, getHueFromConfig } from '@/config/theme-presets';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore, useThemeConfig } from '@/stores/uiStore';
|
||||
|
||||
const themes = [
|
||||
|
||||
// 主题模式选项
|
||||
const themeModes = [
|
||||
{
|
||||
value: 'light',
|
||||
label: '浅色',
|
||||
@@ -33,18 +41,46 @@ const themes = [
|
||||
|
||||
export function ThemeSettings() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const themeConfig = useThemeConfig();
|
||||
const applyPreset = useUIStore((state) => state.applyPreset);
|
||||
const applyCustomPrimaryHue = useUIStore(
|
||||
(state) => state.applyCustomPrimaryHue
|
||||
);
|
||||
|
||||
// 当前是否使用预设主题
|
||||
const isPreset = themeConfig.type === 'preset';
|
||||
|
||||
// 当前色相值(预设或自定义)
|
||||
const currentHue = useMemo(() => getHueFromConfig(themeConfig), [themeConfig]);
|
||||
|
||||
// 处理自定义色相变化
|
||||
const handleHueChange = useCallback(
|
||||
(hue: number) => {
|
||||
applyCustomPrimaryHue(hue);
|
||||
},
|
||||
[applyCustomPrimaryHue]
|
||||
);
|
||||
|
||||
// 处理预设选择
|
||||
const handlePresetSelect = useCallback(
|
||||
(presetId: string) => {
|
||||
applyPreset(presetId);
|
||||
},
|
||||
[applyPreset]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>外观</CardTitle>
|
||||
<CardDescription>自定义应用的外观主题。</CardDescription>
|
||||
<CardDescription>自定义应用的外观主题和配色。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 主题模式 */}
|
||||
<div className="space-y-4">
|
||||
<Label>主题模式</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{themes.map((item) => {
|
||||
{themeModes.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = theme === item.value;
|
||||
|
||||
@@ -67,6 +103,58 @@ export function ThemeSettings() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 预设主题 */}
|
||||
<div className="space-y-4">
|
||||
<Label>预设主题</Label>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||
{THEME_PRESETS.map((preset) => {
|
||||
const isActive = isPreset && themeConfig.presetId === preset.id;
|
||||
const previewColor = `hsl(${preset.hue}, 70%, 50%)`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
className={cn(
|
||||
'group relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all',
|
||||
'hover:bg-accent',
|
||||
isActive ? 'border-primary' : 'border-transparent'
|
||||
)}
|
||||
title={preset.description}
|
||||
>
|
||||
<div
|
||||
className="h-8 w-8 rounded-full border border-border shadow-sm transition-transform group-hover:scale-110"
|
||||
style={{ backgroundColor: previewColor }}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Check className="h-4 w-4 text-white drop-shadow-md" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium">{preset.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 自定义主色 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<Label>自定义主色</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
拖动滑块选择你喜欢的主色调,系统将自动生成配套的完整色板。
|
||||
</p>
|
||||
<HueSlider value={currentHue} onChange={handleHueChange} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ThemeSettings } from './ThemeSettings';
|
||||
export { SidebarSettings } from './SidebarSettings';
|
||||
export { PermissionSettings } from './PermissionSettings';
|
||||
|
||||
@@ -10,6 +10,7 @@ export const API_ENDPOINTS = {
|
||||
SEND_RESET_EMAIL: '/auth/send-reset-email',
|
||||
RESET_PASSWORD: '/auth/reset-password',
|
||||
},
|
||||
HEALTH: '/health',
|
||||
USERS: '/users',
|
||||
FILES: '/files',
|
||||
CAPTCHA: '/captcha',
|
||||
@@ -20,6 +21,8 @@ export const API_ENDPOINTS = {
|
||||
TEACHERS: '/teachers',
|
||||
CLASSES: '/classes',
|
||||
STUDENTS: '/students',
|
||||
// OIDC 交互(使用 Cookie 而非 JWT 认证)
|
||||
OIDC_INTERACTION: '/oidc-interaction',
|
||||
} as const;
|
||||
|
||||
// 分页默认值
|
||||
|
||||
115
apps/web/src/config/theme-presets.ts
Normal file
115
apps/web/src/config/theme-presets.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 主题预设配置
|
||||
* 定义预设主题和类型
|
||||
*/
|
||||
|
||||
// HSL 颜色类型
|
||||
export interface HSLColor {
|
||||
h: number; // 色相 0-360
|
||||
s: number; // 饱和度 0-100
|
||||
l: number; // 亮度 0-100
|
||||
}
|
||||
|
||||
// 主题色板类型
|
||||
export interface ThemePalette {
|
||||
background: HSLColor;
|
||||
foreground: HSLColor;
|
||||
card: HSLColor;
|
||||
cardForeground: HSLColor;
|
||||
popover: HSLColor;
|
||||
popoverForeground: HSLColor;
|
||||
primary: HSLColor;
|
||||
primaryForeground: HSLColor;
|
||||
secondary: HSLColor;
|
||||
secondaryForeground: HSLColor;
|
||||
muted: HSLColor;
|
||||
mutedForeground: HSLColor;
|
||||
accent: HSLColor;
|
||||
accentForeground: HSLColor;
|
||||
destructive: HSLColor;
|
||||
destructiveForeground: HSLColor;
|
||||
border: HSLColor;
|
||||
input: HSLColor;
|
||||
ring: HSLColor;
|
||||
sidebar: HSLColor;
|
||||
sidebarForeground: HSLColor;
|
||||
sidebarPrimary: HSLColor;
|
||||
sidebarPrimaryForeground: HSLColor;
|
||||
sidebarAccent: HSLColor;
|
||||
sidebarAccentForeground: HSLColor;
|
||||
sidebarBorder: HSLColor;
|
||||
sidebarRing: HSLColor;
|
||||
}
|
||||
|
||||
// 主题预设类型
|
||||
export interface ThemePreset {
|
||||
id: string;
|
||||
name: string;
|
||||
hue: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 主题配置类型 - 预设或自定义
|
||||
export type ThemeConfig =
|
||||
| { type: 'preset'; presetId: string }
|
||||
| { type: 'custom'; primaryHue: number };
|
||||
|
||||
// 默认主题配置
|
||||
export const DEFAULT_THEME_CONFIG: ThemeConfig = {
|
||||
type: 'preset',
|
||||
presetId: 'default',
|
||||
};
|
||||
|
||||
// 6 套预设主题
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{
|
||||
id: 'default',
|
||||
name: '默认',
|
||||
hue: 222,
|
||||
description: '经典灰黑色调',
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
name: '海洋',
|
||||
hue: 210,
|
||||
description: '清新蓝色',
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
name: '森林',
|
||||
hue: 142,
|
||||
description: '自然绿色',
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
name: '日落',
|
||||
hue: 24,
|
||||
description: '温暖橙色',
|
||||
},
|
||||
{
|
||||
id: 'rose',
|
||||
name: '玫瑰',
|
||||
hue: 346,
|
||||
description: '优雅粉色',
|
||||
},
|
||||
{
|
||||
id: 'violet',
|
||||
name: '紫罗兰',
|
||||
hue: 262,
|
||||
description: '高贵紫色',
|
||||
},
|
||||
];
|
||||
|
||||
// 根据 ID 获取预设主题
|
||||
export function getPresetById(id: string): ThemePreset | undefined {
|
||||
return THEME_PRESETS.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
// 获取主题配置对应的色相值
|
||||
export function getHueFromConfig(config: ThemeConfig): number {
|
||||
if (config.type === 'preset') {
|
||||
const preset = getPresetById(config.presetId);
|
||||
return preset?.hue ?? 222; // 回退到默认色相
|
||||
}
|
||||
return config.primaryHue;
|
||||
}
|
||||
20
apps/web/src/hooks/useHealth.ts
Normal file
20
apps/web/src/hooks/useHealth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { healthService } from '@/services/health.service';
|
||||
import { useIsAuthenticated } from '@/stores';
|
||||
|
||||
export const healthKeys = {
|
||||
all: ['health'] as const,
|
||||
check: () => [...healthKeys.all, 'check'] as const,
|
||||
};
|
||||
|
||||
export function useHealthCheck() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
return useQuery({
|
||||
queryKey: healthKeys.check(),
|
||||
queryFn: () => healthService.check(),
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // 每 30 秒自动刷新
|
||||
});
|
||||
}
|
||||
88
apps/web/src/hooks/useOidcClients.ts
Normal file
88
apps/web/src/hooks/useOidcClients.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
CreateOidcClientRequest,
|
||||
PaginationParams,
|
||||
UpdateOidcClientRequest,
|
||||
} from '@seclusion/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { oidcClientService } from '@/services/oidc-client.service';
|
||||
|
||||
const QUERY_KEY = 'oidc-clients';
|
||||
|
||||
/**
|
||||
* 获取 OIDC 客户端列表
|
||||
*/
|
||||
export function useOidcClients(params?: PaginationParams) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, params],
|
||||
queryFn: () => oidcClientService.getList(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OIDC 客户端详情
|
||||
*/
|
||||
export function useOidcClient(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, id],
|
||||
queryFn: () => oidcClientService.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 OIDC 客户端
|
||||
*/
|
||||
export function useCreateOidcClient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateOidcClientRequest) => oidcClientService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 OIDC 客户端
|
||||
*/
|
||||
export function useUpdateOidcClient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateOidcClientRequest }) =>
|
||||
oidcClientService.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OIDC 客户端
|
||||
*/
|
||||
export function useDeleteOidcClient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => oidcClientService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成客户端密钥
|
||||
*/
|
||||
export function useRegenerateOidcClientSecret() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => oidcClientService.regenerateSecret(id),
|
||||
onSuccess: (_data, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
25
apps/web/src/hooks/useThemeInit.ts
Normal file
25
apps/web/src/hooks/useThemeInit.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getHueFromConfig } from '@/config/theme-presets';
|
||||
import { applyTheme } from '@/lib/theme-service';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
/**
|
||||
* 主题初始化 Hook
|
||||
* 确保客户端 hydration 后主题正确应用
|
||||
*/
|
||||
export function useThemeInit() {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const themeConfig = useUIStore((state) => state.themeConfig);
|
||||
|
||||
useEffect(() => {
|
||||
// 在客户端 hydration 后重新应用主题,确保状态一致
|
||||
const hue = getHueFromConfig(themeConfig);
|
||||
applyTheme(hue);
|
||||
setIsInitialized(true);
|
||||
}, [themeConfig]);
|
||||
|
||||
return { isInitialized };
|
||||
}
|
||||
139
apps/web/src/lib/theme-generator.ts
Normal file
139
apps/web/src/lib/theme-generator.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 主题色板生成器
|
||||
* 根据主色色相自动生成完整的浅色/深色主题色板
|
||||
*/
|
||||
|
||||
import type { HSLColor, ThemePalette } from '@/config/theme-presets';
|
||||
|
||||
// 创建 HSL 颜色
|
||||
function hsl(h: number, s: number, l: number): HSLColor {
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
// 确保色相在 0-360 范围内
|
||||
function normalizeHue(hue: number): number {
|
||||
return ((hue % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成浅色主题色板
|
||||
* @param primaryHue 主色色相 (0-360)
|
||||
*/
|
||||
export function generateLightPalette(primaryHue: number): ThemePalette {
|
||||
const hue = normalizeHue(primaryHue);
|
||||
|
||||
return {
|
||||
// 背景色系 - 纯白到微灰
|
||||
background: hsl(0, 0, 100),
|
||||
foreground: hsl(hue, 84, 4.9),
|
||||
|
||||
// 卡片
|
||||
card: hsl(0, 0, 100),
|
||||
cardForeground: hsl(hue, 84, 4.9),
|
||||
|
||||
// 弹出层
|
||||
popover: hsl(0, 0, 100),
|
||||
popoverForeground: hsl(hue, 84, 4.9),
|
||||
|
||||
// 主色 - 深色调
|
||||
primary: hsl(hue, 47.4, 11.2),
|
||||
primaryForeground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 次要色 - 浅灰
|
||||
secondary: hsl(hue - 12, 40, 96.1),
|
||||
secondaryForeground: hsl(hue, 47.4, 11.2),
|
||||
|
||||
// 静音色
|
||||
muted: hsl(hue - 12, 40, 96.1),
|
||||
mutedForeground: hsl(hue - 7, 16.3, 46.9),
|
||||
|
||||
// 强调色
|
||||
accent: hsl(hue - 12, 40, 96.1),
|
||||
accentForeground: hsl(hue, 47.4, 11.2),
|
||||
|
||||
// 危险色 - 红色系
|
||||
destructive: hsl(0, 84.2, 60.2),
|
||||
destructiveForeground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 边框和输入框
|
||||
border: hsl(hue - 8, 31.8, 91.4),
|
||||
input: hsl(hue - 8, 31.8, 91.4),
|
||||
|
||||
// 聚焦环
|
||||
ring: hsl(hue, 84, 4.9),
|
||||
|
||||
// 侧边栏 - 微带主色调的浅灰
|
||||
sidebar: hsl(0, 0, 98),
|
||||
sidebarForeground: hsl(hue + 18, 5.3, 26.1),
|
||||
sidebarPrimary: hsl(hue + 18, 5.9, 10),
|
||||
sidebarPrimaryForeground: hsl(0, 0, 98),
|
||||
sidebarAccent: hsl(hue + 18, 4.8, 95.9),
|
||||
sidebarAccentForeground: hsl(hue + 18, 5.9, 10),
|
||||
sidebarBorder: hsl(hue - 2, 13, 91),
|
||||
sidebarRing: hsl(hue - 5, 91.2, 59.8),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成深色主题色板
|
||||
* @param primaryHue 主色色相 (0-360)
|
||||
*/
|
||||
export function generateDarkPalette(primaryHue: number): ThemePalette {
|
||||
const hue = normalizeHue(primaryHue);
|
||||
|
||||
return {
|
||||
// 背景色系 - 深色
|
||||
background: hsl(hue, 84, 4.9),
|
||||
foreground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 卡片
|
||||
card: hsl(hue, 84, 4.9),
|
||||
cardForeground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 弹出层
|
||||
popover: hsl(hue, 84, 4.9),
|
||||
popoverForeground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 主色 - 浅色调(深色模式下主色要亮)
|
||||
primary: hsl(hue - 12, 40, 98),
|
||||
primaryForeground: hsl(hue, 47.4, 11.2),
|
||||
|
||||
// 次要色 - 深灰
|
||||
secondary: hsl(hue - 5, 32.6, 17.5),
|
||||
secondaryForeground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 静音色
|
||||
muted: hsl(hue - 5, 32.6, 17.5),
|
||||
mutedForeground: hsl(hue - 7, 20.2, 65.1),
|
||||
|
||||
// 强调色
|
||||
accent: hsl(hue - 5, 32.6, 17.5),
|
||||
accentForeground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 危险色 - 深色模式下更暗
|
||||
destructive: hsl(0, 62.8, 30.6),
|
||||
destructiveForeground: hsl(hue - 12, 40, 98),
|
||||
|
||||
// 边框和输入框
|
||||
border: hsl(hue - 5, 32.6, 17.5),
|
||||
input: hsl(hue - 5, 32.6, 17.5),
|
||||
|
||||
// 聚焦环
|
||||
ring: hsl(hue - 10, 26.8, 83.9),
|
||||
|
||||
// 侧边栏
|
||||
sidebar: hsl(hue + 18, 5.9, 10),
|
||||
sidebarForeground: hsl(hue + 18, 4.8, 95.9),
|
||||
sidebarPrimary: hsl(hue + 2, 76.3, 48),
|
||||
sidebarPrimaryForeground: hsl(0, 0, 100),
|
||||
sidebarAccent: hsl(hue + 18, 3.7, 15.9),
|
||||
sidebarAccentForeground: hsl(hue + 18, 4.8, 95.9),
|
||||
sidebarBorder: hsl(hue + 18, 3.7, 15.9),
|
||||
sidebarRing: hsl(hue - 5, 91.2, 59.8),
|
||||
};
|
||||
}
|
||||
|
||||
// HSL 颜色转换为 CSS 值字符串
|
||||
export function hslToString(color: HSLColor): string {
|
||||
return `hsl(${color.h} ${color.s}% ${color.l}%)`;
|
||||
}
|
||||
140
apps/web/src/lib/theme-init-script.ts
Normal file
140
apps/web/src/lib/theme-init-script.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 主题初始化脚本
|
||||
* 生成在 HTML head 中执行的内联脚本,避免主题颜色闪烁
|
||||
*/
|
||||
|
||||
import { STORAGE_KEYS } from '@/config/constants';
|
||||
|
||||
/**
|
||||
* 生成主题初始化脚本代码
|
||||
* 该脚本在 HTML 解析时立即执行,从 localStorage 读取主题配置并应用
|
||||
*/
|
||||
export function getThemeInitScript(): string {
|
||||
// 为了在 SSR 时能正确生成脚本,使用硬编码的 storage key
|
||||
const storageKey = STORAGE_KEYS.UI;
|
||||
|
||||
// 内联脚本 - 会在浏览器解析 HTML 时立即执行
|
||||
return `
|
||||
(function() {
|
||||
try {
|
||||
var stored = localStorage.getItem('${storageKey}');
|
||||
if (!stored) return;
|
||||
|
||||
var data = JSON.parse(stored);
|
||||
var state = data.state;
|
||||
if (!state || !state.themeConfig) return;
|
||||
|
||||
var config = state.themeConfig;
|
||||
var hue;
|
||||
|
||||
if (config.type === 'preset') {
|
||||
// 预设主题色相映射
|
||||
var presets = {
|
||||
'default': 222,
|
||||
'ocean': 210,
|
||||
'forest': 142,
|
||||
'sunset': 24,
|
||||
'rose': 346,
|
||||
'violet': 262
|
||||
};
|
||||
hue = presets[config.presetId] || 222;
|
||||
} else if (config.type === 'custom') {
|
||||
hue = config.primaryHue || 222;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成并应用主题 CSS
|
||||
var css = generateThemeCSS(hue);
|
||||
var style = document.createElement('style');
|
||||
style.id = 'seclusion-theme-style';
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
} catch (e) {
|
||||
// 静默失败,使用默认主题
|
||||
}
|
||||
|
||||
function hsl(h, s, l) {
|
||||
return 'hsl(' + h + ' ' + s + '% ' + l + '%)';
|
||||
}
|
||||
|
||||
function generateThemeCSS(hue) {
|
||||
// 浅色主题变量
|
||||
var light = {
|
||||
'--color-background': hsl(0, 0, 100),
|
||||
'--color-foreground': hsl(hue, 84, 4.9),
|
||||
'--color-card': hsl(0, 0, 100),
|
||||
'--color-card-foreground': hsl(hue, 84, 4.9),
|
||||
'--color-popover': hsl(0, 0, 100),
|
||||
'--color-popover-foreground': hsl(hue, 84, 4.9),
|
||||
'--color-primary': hsl(hue, 47.4, 11.2),
|
||||
'--color-primary-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-secondary': hsl(hue - 12, 40, 96.1),
|
||||
'--color-secondary-foreground': hsl(hue, 47.4, 11.2),
|
||||
'--color-muted': hsl(hue - 12, 40, 96.1),
|
||||
'--color-muted-foreground': hsl(hue - 7, 16.3, 46.9),
|
||||
'--color-accent': hsl(hue - 12, 40, 96.1),
|
||||
'--color-accent-foreground': hsl(hue, 47.4, 11.2),
|
||||
'--color-destructive': hsl(0, 84.2, 60.2),
|
||||
'--color-destructive-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-border': hsl(hue - 8, 31.8, 91.4),
|
||||
'--color-input': hsl(hue - 8, 31.8, 91.4),
|
||||
'--color-ring': hsl(hue, 84, 4.9),
|
||||
'--color-sidebar': hsl(0, 0, 98),
|
||||
'--color-sidebar-foreground': hsl(hue + 18, 5.3, 26.1),
|
||||
'--color-sidebar-primary': hsl(hue + 18, 5.9, 10),
|
||||
'--color-sidebar-primary-foreground': hsl(0, 0, 98),
|
||||
'--color-sidebar-accent': hsl(hue + 18, 4.8, 95.9),
|
||||
'--color-sidebar-accent-foreground': hsl(hue + 18, 5.9, 10),
|
||||
'--color-sidebar-border': hsl(hue - 2, 13, 91),
|
||||
'--color-sidebar-ring': hsl(hue - 5, 91.2, 59.8)
|
||||
};
|
||||
|
||||
// 深色主题变量
|
||||
var dark = {
|
||||
'--color-background': hsl(hue, 84, 4.9),
|
||||
'--color-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-card': hsl(hue, 84, 4.9),
|
||||
'--color-card-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-popover': hsl(hue, 84, 4.9),
|
||||
'--color-popover-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-primary': hsl(hue - 12, 40, 98),
|
||||
'--color-primary-foreground': hsl(hue, 47.4, 11.2),
|
||||
'--color-secondary': hsl(hue - 5, 32.6, 17.5),
|
||||
'--color-secondary-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-muted': hsl(hue - 5, 32.6, 17.5),
|
||||
'--color-muted-foreground': hsl(hue - 7, 20.2, 65.1),
|
||||
'--color-accent': hsl(hue - 5, 32.6, 17.5),
|
||||
'--color-accent-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-destructive': hsl(0, 62.8, 30.6),
|
||||
'--color-destructive-foreground': hsl(hue - 12, 40, 98),
|
||||
'--color-border': hsl(hue - 5, 32.6, 17.5),
|
||||
'--color-input': hsl(hue - 5, 32.6, 17.5),
|
||||
'--color-ring': hsl(hue - 10, 26.8, 83.9),
|
||||
'--color-sidebar': hsl(hue + 18, 5.9, 10),
|
||||
'--color-sidebar-foreground': hsl(hue + 18, 4.8, 95.9),
|
||||
'--color-sidebar-primary': hsl(hue + 2, 76.3, 48),
|
||||
'--color-sidebar-primary-foreground': hsl(0, 0, 100),
|
||||
'--color-sidebar-accent': hsl(hue + 18, 3.7, 15.9),
|
||||
'--color-sidebar-accent-foreground': hsl(hue + 18, 4.8, 95.9),
|
||||
'--color-sidebar-border': hsl(hue + 18, 3.7, 15.9),
|
||||
'--color-sidebar-ring': hsl(hue - 5, 91.2, 59.8)
|
||||
};
|
||||
|
||||
var lightCSS = ':root {\\n';
|
||||
for (var key in light) {
|
||||
lightCSS += ' ' + key + ': ' + light[key] + ';\\n';
|
||||
}
|
||||
lightCSS += '}';
|
||||
|
||||
var darkCSS = '.dark {\\n';
|
||||
for (var key in dark) {
|
||||
darkCSS += ' ' + key + ': ' + dark[key] + ';\\n';
|
||||
}
|
||||
darkCSS += '}';
|
||||
|
||||
return '/* Seclusion Theme - Primary Hue: ' + hue + ' */\\n' + lightCSS + '\\n\\n' + darkCSS;
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
}
|
||||
124
apps/web/src/lib/theme-service.ts
Normal file
124
apps/web/src/lib/theme-service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 主题应用服务
|
||||
* 负责将主题色板应用到 DOM(通过动态 style 标签注入 CSS 变量)
|
||||
*/
|
||||
|
||||
import { generateLightPalette, generateDarkPalette, hslToString } from './theme-generator';
|
||||
|
||||
import type { ThemePalette } from '@/config/theme-presets';
|
||||
|
||||
|
||||
// 动态 style 标签的 ID
|
||||
const THEME_STYLE_ID = 'seclusion-theme-style';
|
||||
|
||||
// CSS 变量名映射
|
||||
const CSS_VAR_MAPPING: Record<keyof ThemePalette, string> = {
|
||||
background: '--color-background',
|
||||
foreground: '--color-foreground',
|
||||
card: '--color-card',
|
||||
cardForeground: '--color-card-foreground',
|
||||
popover: '--color-popover',
|
||||
popoverForeground: '--color-popover-foreground',
|
||||
primary: '--color-primary',
|
||||
primaryForeground: '--color-primary-foreground',
|
||||
secondary: '--color-secondary',
|
||||
secondaryForeground: '--color-secondary-foreground',
|
||||
muted: '--color-muted',
|
||||
mutedForeground: '--color-muted-foreground',
|
||||
accent: '--color-accent',
|
||||
accentForeground: '--color-accent-foreground',
|
||||
destructive: '--color-destructive',
|
||||
destructiveForeground: '--color-destructive-foreground',
|
||||
border: '--color-border',
|
||||
input: '--color-input',
|
||||
ring: '--color-ring',
|
||||
sidebar: '--color-sidebar',
|
||||
sidebarForeground: '--color-sidebar-foreground',
|
||||
sidebarPrimary: '--color-sidebar-primary',
|
||||
sidebarPrimaryForeground: '--color-sidebar-primary-foreground',
|
||||
sidebarAccent: '--color-sidebar-accent',
|
||||
sidebarAccentForeground: '--color-sidebar-accent-foreground',
|
||||
sidebarBorder: '--color-sidebar-border',
|
||||
sidebarRing: '--color-sidebar-ring',
|
||||
};
|
||||
|
||||
// 将色板转换为 CSS 变量字符串
|
||||
function paletteToCSS(palette: ThemePalette, selector: string): string {
|
||||
const variables = Object.entries(CSS_VAR_MAPPING)
|
||||
.map(([key, cssVar]) => {
|
||||
const color = palette[key as keyof ThemePalette];
|
||||
return ` ${cssVar}: ${hslToString(color)};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `${selector} {\n${variables}\n}`;
|
||||
}
|
||||
|
||||
// 生成完整的主题 CSS
|
||||
function generateThemeCSS(primaryHue: number): string {
|
||||
const lightPalette = generateLightPalette(primaryHue);
|
||||
const darkPalette = generateDarkPalette(primaryHue);
|
||||
|
||||
// 浅色模式::root 和 html:not(.dark)
|
||||
const lightCSS = paletteToCSS(lightPalette, ':root');
|
||||
// 深色模式:.dark 类
|
||||
const darkCSS = paletteToCSS(darkPalette, '.dark');
|
||||
|
||||
return `/* Seclusion Theme - Primary Hue: ${primaryHue} */\n${lightCSS}\n\n${darkCSS}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用主题色相到页面
|
||||
* @param primaryHue 主色色相 (0-360)
|
||||
*/
|
||||
export function applyTheme(primaryHue: number): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return; // SSR 环境下不执行
|
||||
}
|
||||
|
||||
// 查找或创建 style 标签
|
||||
let styleElement = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = THEME_STYLE_ID;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
// 生成并应用 CSS
|
||||
const css = generateThemeCSS(primaryHue);
|
||||
styleElement.textContent = css;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除自定义主题(恢复默认)
|
||||
*/
|
||||
export function removeTheme(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = document.getElementById(THEME_STYLE_ID);
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前应用的主题色相
|
||||
* 如果没有自定义主题则返回 null
|
||||
*/
|
||||
export function getCurrentThemeHue(): number | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styleElement = document.getElementById(THEME_STYLE_ID);
|
||||
if (!styleElement?.textContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从注释中提取色相值
|
||||
const match = styleElement.textContent.match(/Primary Hue: (\d+)/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
11
apps/web/src/services/health.service.ts
Normal file
11
apps/web/src/services/health.service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { HealthCheckResponse } from '@seclusion/shared';
|
||||
|
||||
import { API_ENDPOINTS } from '@/config/constants';
|
||||
import { http } from '@/lib/http';
|
||||
|
||||
export const healthService = {
|
||||
// 健康检查
|
||||
check: (): Promise<HealthCheckResponse> => {
|
||||
return http.get<HealthCheckResponse>(API_ENDPOINTS.HEALTH);
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { authService } from './auth.service';
|
||||
export { userService, type GetUsersParams } from './user.service';
|
||||
46
apps/web/src/services/oidc-client.service.ts
Normal file
46
apps/web/src/services/oidc-client.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
CreateOidcClientRequest,
|
||||
OidcClientResponse,
|
||||
UpdateOidcClientRequest,
|
||||
} from '@seclusion/shared';
|
||||
import type { PaginatedResponse, PaginationParams } from '@seclusion/shared';
|
||||
|
||||
import { http } from '@/lib/http';
|
||||
|
||||
const BASE_URL = '/oidc-clients';
|
||||
|
||||
export const oidcClientService = {
|
||||
/**
|
||||
* 获取客户端列表
|
||||
*/
|
||||
getList: (params?: PaginationParams) =>
|
||||
http.get<PaginatedResponse<OidcClientResponse>>(BASE_URL, { params }),
|
||||
|
||||
/**
|
||||
* 获取客户端详情
|
||||
*/
|
||||
getById: (id: string) => http.get<OidcClientResponse>(`${BASE_URL}/${id}`),
|
||||
|
||||
/**
|
||||
* 创建客户端
|
||||
*/
|
||||
create: (data: CreateOidcClientRequest) =>
|
||||
http.post<OidcClientResponse & { clientSecret: string }>(BASE_URL, data),
|
||||
|
||||
/**
|
||||
* 更新客户端
|
||||
*/
|
||||
update: (id: string, data: UpdateOidcClientRequest) =>
|
||||
http.patch<OidcClientResponse>(`${BASE_URL}/${id}`, data),
|
||||
|
||||
/**
|
||||
* 删除客户端
|
||||
*/
|
||||
delete: (id: string) => http.delete(`${BASE_URL}/${id}`),
|
||||
|
||||
/**
|
||||
* 重新生成客户端密钥
|
||||
*/
|
||||
regenerateSecret: (id: string) =>
|
||||
http.post<{ clientSecret: string }>(`${BASE_URL}/${id}/regenerate-secret`),
|
||||
};
|
||||
71
apps/web/src/services/oidc-interaction.service.ts
Normal file
71
apps/web/src/services/oidc-interaction.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* OIDC 交互服务
|
||||
*
|
||||
* 注意:此服务使用原生 fetch 而非标准 http 客户端,因为:
|
||||
* - OIDC 交互使用 Cookie 而非 JWT 认证
|
||||
* - 需要 `credentials: 'include'` 让浏览器自动管理 Cookie
|
||||
*/
|
||||
import type {
|
||||
OidcConsentRequest,
|
||||
OidcInteractionRedirectResponse,
|
||||
OidcLoginRequest,
|
||||
} from '@seclusion/shared';
|
||||
|
||||
import { API_ENDPOINTS } from '@/config/constants';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
const BASE_URL = `${API_BASE_URL}${API_ENDPOINTS.OIDC_INTERACTION}`;
|
||||
|
||||
/** 发送 OIDC 交互请求(使用 Cookie 认证) */
|
||||
async function fetchWithCredentials<T>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: '请求失败' }));
|
||||
throw new Error(error.message || '请求失败');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const oidcInteractionService = {
|
||||
// 提交 OIDC 登录
|
||||
submitLogin: (
|
||||
uid: string,
|
||||
data: OidcLoginRequest
|
||||
): Promise<OidcInteractionRedirectResponse> => {
|
||||
return fetchWithCredentials<OidcInteractionRedirectResponse>(
|
||||
`${BASE_URL}/${uid}/login`,
|
||||
{ method: 'POST', body: JSON.stringify(data) }
|
||||
);
|
||||
},
|
||||
|
||||
// 提交授权确认
|
||||
submitConsent: (
|
||||
uid: string,
|
||||
data: OidcConsentRequest
|
||||
): Promise<OidcInteractionRedirectResponse> => {
|
||||
return fetchWithCredentials<OidcInteractionRedirectResponse>(
|
||||
`${BASE_URL}/${uid}/confirm`,
|
||||
{ method: 'POST', body: JSON.stringify(data) }
|
||||
);
|
||||
},
|
||||
|
||||
// 中止授权
|
||||
abort: (uid: string): Promise<OidcInteractionRedirectResponse> => {
|
||||
return fetchWithCredentials<OidcInteractionRedirectResponse>(
|
||||
`${BASE_URL}/${uid}/abort`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AuthUser } from '@seclusion/shared';
|
||||
|
||||
import type { ThemeConfig } from '@/config/theme-presets';
|
||||
|
||||
// Auth Store 状态类型
|
||||
export interface AuthState {
|
||||
token: string | null;
|
||||
@@ -39,6 +41,7 @@ export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface UIState {
|
||||
theme: Theme;
|
||||
themeConfig: ThemeConfig;
|
||||
sidebarOpen: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
}
|
||||
@@ -46,6 +49,9 @@ export interface UIState {
|
||||
// UI Store Actions 类型
|
||||
export interface UIActions {
|
||||
setTheme: (theme: Theme) => void;
|
||||
setThemeConfig: (config: ThemeConfig) => void;
|
||||
applyPreset: (presetId: string) => void;
|
||||
applyCustomPrimaryHue: (hue: number) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
|
||||
@@ -4,16 +4,46 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { UIStore } from './types';
|
||||
|
||||
import { STORAGE_KEYS } from '@/config/constants';
|
||||
import {
|
||||
DEFAULT_THEME_CONFIG,
|
||||
getHueFromConfig,
|
||||
getPresetById,
|
||||
} from '@/config/theme-presets';
|
||||
import { applyTheme } from '@/lib/theme-service';
|
||||
|
||||
export const useUIStore = create<UIStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
themeConfig: DEFAULT_THEME_CONFIG,
|
||||
sidebarOpen: false, // 移动端侧边栏默认关闭
|
||||
sidebarCollapsed: false,
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
|
||||
setThemeConfig: (config) => {
|
||||
set({ themeConfig: config });
|
||||
// 应用主题
|
||||
const hue = getHueFromConfig(config);
|
||||
applyTheme(hue);
|
||||
},
|
||||
|
||||
applyPreset: (presetId) => {
|
||||
// 验证预设是否存在
|
||||
const preset = getPresetById(presetId);
|
||||
if (!preset) {
|
||||
console.warn(`Theme preset "${presetId}" not found, using default`);
|
||||
presetId = 'default';
|
||||
}
|
||||
get().setThemeConfig({ type: 'preset', presetId });
|
||||
},
|
||||
|
||||
applyCustomPrimaryHue: (hue) => {
|
||||
// 确保色相在有效范围内
|
||||
const normalizedHue = ((hue % 360) + 360) % 360;
|
||||
get().setThemeConfig({ type: 'custom', primaryHue: normalizedHue });
|
||||
},
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
@@ -27,14 +57,23 @@ export const useUIStore = create<UIStore>()(
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
themeConfig: state.themeConfig,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
}),
|
||||
// hydration 完成后应用主题
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state?.themeConfig) {
|
||||
const hue = getHueFromConfig(state.themeConfig);
|
||||
applyTheme(hue);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Selector hooks
|
||||
export const useTheme = () => useUIStore((state) => state.theme);
|
||||
export const useThemeConfig = () => useUIStore((state) => state.themeConfig);
|
||||
export const useSidebarOpen = () => useUIStore((state) => state.sidebarOpen);
|
||||
export const useSidebarCollapsed = () =>
|
||||
useUIStore((state) => state.sidebarCollapsed);
|
||||
|
||||
1293
docs/backend/oidc-provider.md
Normal file
1293
docs/backend/oidc-provider.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
| ---------------------------------------------------- | -------------------- | -------------------------------- |
|
||||
| [design.md](./design.md) | 项目设计文档 | 了解整体架构、技术选型、模块设计 |
|
||||
| [backend/soft-delete.md](./backend/soft-delete.md) | 软删除设计文档 | 了解软删除机制、扩展新模型 |
|
||||
| [backend/oidc-provider.md](./backend/oidc-provider.md) | OIDC Provider 实施方案 | 实现中央鉴权、OAuth2.0/OIDC 集成 |
|
||||
| [api/crud-service.md](./api/crud-service.md) | CrudService 分层架构 | CRUD 服务开发、关联查询、多对多关系 |
|
||||
| [../plop/README.md](../plop/README.md) | CRUD 代码生成器 | 一键生成全栈 CRUD 模块 |
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ export interface ServiceHealth {
|
||||
export interface ServicesHealth {
|
||||
database: ServiceHealth;
|
||||
redis: ServiceHealth;
|
||||
storage: ServiceHealth;
|
||||
}
|
||||
|
||||
/** 健康检查响应 */
|
||||
@@ -256,3 +257,24 @@ export {
|
||||
type UserRoleResponse,
|
||||
type UserWithRolesResponse,
|
||||
} from './permission';
|
||||
|
||||
// ==================== OIDC Provider 相关类型 ====================
|
||||
|
||||
export {
|
||||
// Scope 描述
|
||||
OidcScopeDescriptions,
|
||||
// 客户端类型
|
||||
type OidcClientResponse,
|
||||
type CreateOidcClientRequest,
|
||||
type UpdateOidcClientRequest,
|
||||
/** @deprecated 使用 CreateOidcClientRequest */
|
||||
type CreateOidcClientDto,
|
||||
/** @deprecated 使用 UpdateOidcClientRequest */
|
||||
type UpdateOidcClientDto,
|
||||
// 交互类型
|
||||
type OidcInteractionDetails,
|
||||
type OidcInteractionRedirectResponse,
|
||||
type OidcLoginRequest,
|
||||
type OidcConsentRequest,
|
||||
} from './oidc';
|
||||
|
||||
|
||||
114
packages/shared/src/types/oidc.ts
Normal file
114
packages/shared/src/types/oidc.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// ==================== OIDC 客户端类型 ====================
|
||||
|
||||
/** OIDC 客户端响应 */
|
||||
export interface OidcClientResponse {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientUri: string | null;
|
||||
logoUri: string | null;
|
||||
redirectUris: string[];
|
||||
postLogoutRedirectUris: string[];
|
||||
grantTypes: string[];
|
||||
responseTypes: string[];
|
||||
scopes: string[];
|
||||
tokenEndpointAuthMethod: string;
|
||||
applicationType: string;
|
||||
isEnabled: boolean;
|
||||
isFirstParty: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 创建 OIDC 客户端请求 */
|
||||
export interface CreateOidcClientRequest {
|
||||
clientName: string;
|
||||
clientUri?: string;
|
||||
logoUri?: string;
|
||||
redirectUris: string[];
|
||||
postLogoutRedirectUris?: string[];
|
||||
grantTypes?: string[];
|
||||
responseTypes?: string[];
|
||||
scopes?: string[];
|
||||
tokenEndpointAuthMethod?: string;
|
||||
applicationType?: string;
|
||||
isFirstParty?: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 CreateOidcClientRequest */
|
||||
export type CreateOidcClientDto = CreateOidcClientRequest;
|
||||
|
||||
/** 更新 OIDC 客户端请求 */
|
||||
export interface UpdateOidcClientRequest {
|
||||
clientName?: string;
|
||||
clientUri?: string;
|
||||
logoUri?: string;
|
||||
redirectUris?: string[];
|
||||
postLogoutRedirectUris?: string[];
|
||||
grantTypes?: string[];
|
||||
responseTypes?: string[];
|
||||
scopes?: string[];
|
||||
tokenEndpointAuthMethod?: string;
|
||||
applicationType?: string;
|
||||
isEnabled?: boolean;
|
||||
isFirstParty?: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 UpdateOidcClientRequest */
|
||||
export type UpdateOidcClientDto = UpdateOidcClientRequest;
|
||||
|
||||
// ==================== OIDC 交互类型 ====================
|
||||
|
||||
/** OIDC 交互详情 */
|
||||
export interface OidcInteractionDetails {
|
||||
uid: string;
|
||||
prompt: {
|
||||
name: 'login' | 'consent';
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
response_type?: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
};
|
||||
client: {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
} | null;
|
||||
session?: {
|
||||
accountId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== OIDC Scope 描述 ====================
|
||||
|
||||
/** Scope 描述映射 */
|
||||
export const OidcScopeDescriptions: Record<string, string> = {
|
||||
openid: '基本身份信息',
|
||||
profile: '个人资料(姓名、头像)',
|
||||
email: '邮箱地址',
|
||||
offline_access: '离线访问(刷新令牌)',
|
||||
};
|
||||
|
||||
// ==================== OIDC 交互 API 类型 ====================
|
||||
|
||||
/** OIDC 交互成功响应 */
|
||||
export interface OidcInteractionRedirectResponse {
|
||||
redirectTo: string;
|
||||
}
|
||||
|
||||
/** OIDC 登录请求 */
|
||||
export interface OidcLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** OIDC 授权确认请求 */
|
||||
export interface OidcConsentRequest {
|
||||
scopes: string[];
|
||||
}
|
||||
172
plop/README.md
172
plop/README.md
@@ -1,6 +1,6 @@
|
||||
# CRUD 代码生成器
|
||||
|
||||
基于 Plop.js 的全栈 CRUD 代码生成器,支持一键生成后端模块、前端模块、共享类型和 Prisma Model。
|
||||
基于 Plop.js 的全栈 CRUD 代码生成器,支持一键生成后端模块、前端模块、共享类型、Prisma Model 和菜单/权限种子脚本。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -16,12 +16,24 @@ pnpm generate
|
||||
|------|------|------|------|
|
||||
| 1 | 模块名称 | 英文,小写开头 | `product` |
|
||||
| 2 | 模块中文名 | 用于注释和 API 标签 | `产品` |
|
||||
| 3 | 复数名称 | API 路径和表名 | `products` |
|
||||
| 4 | 生成目标 | 多选:后端/前端/共享类型/Prisma | 全选 |
|
||||
| 3 | 生成目标 | 多选:后端/前端/共享类型/Prisma/种子脚本 | 全选 |
|
||||
| 4 | 服务类型 | 单表/带关联/多对多 | `CrudService` |
|
||||
| 5 | 软删除 | 是否启用软删除 | `Yes` |
|
||||
| 6 | 字段定义 | DSL 语法定义字段 | 见下方 |
|
||||
| 7 | 搜索字段 | 选择可搜索的字段 | `name, status` |
|
||||
| 8 | 分页配置 | 默认/最大分页、排序 | `20/100/createdAt/desc` |
|
||||
| 7 | 关系配置 | 一对多/多对一/多对多关系 | 交互式配置 |
|
||||
| 8 | 搜索字段 | 选择可搜索的字段 | `name, status` |
|
||||
| 9 | 分页配置 | 默认/最大分页、排序 | `20/100/createdAt/desc` |
|
||||
| 10 | 菜单配置 | 图标、排序(生成种子脚本时) | `Users / 50` |
|
||||
|
||||
## 服务类型
|
||||
|
||||
生成器支持三种服务类型,根据模块复杂度选择:
|
||||
|
||||
| 类型 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `CrudService` | 单表 CRUD | 简单实体,无关联关系 |
|
||||
| `RelationCrudService` | 带关联查询 | 有外键关联,需要返回关联数据 |
|
||||
| `ManyToManyCrudService` | 多对多关系 | 需要管理多对多关系(如班级-教师) |
|
||||
|
||||
## 字段 DSL 语法
|
||||
|
||||
@@ -91,6 +103,47 @@ isActive:boolean 是否激活 "true"
|
||||
publishedAt:datetime? 发布时间 "2026-01-16T10:00:00Z"
|
||||
```
|
||||
|
||||
## 关系配置
|
||||
|
||||
### 一对多关系
|
||||
|
||||
新模型包含多个目标模型(如:宿舍包含多个学生)
|
||||
|
||||
```
|
||||
# 格式: 关系名:目标模型 [optional]
|
||||
students:Student optional
|
||||
```
|
||||
|
||||
### 多对一关系
|
||||
|
||||
新模型属于一个目标模型(如:成绩属于学生)
|
||||
|
||||
```
|
||||
# 格式: 关联名:目标模型 [optional]
|
||||
student:Student
|
||||
class:Class optional
|
||||
```
|
||||
|
||||
### 多对多关系
|
||||
|
||||
多对多关联(如:班级-教师)
|
||||
|
||||
```
|
||||
# 格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
|
||||
teachers:ClassTeacher:classId:teacherId:Teacher id,name,teacherNo
|
||||
```
|
||||
|
||||
### 查询关联配置
|
||||
|
||||
配置 API 响应返回哪些关联字段:
|
||||
|
||||
```
|
||||
# 格式: 关联名:目标模型 字段1,字段2,... [noList]
|
||||
# noList: 不在列表中显示(仅详情显示)
|
||||
class:Class id,code,name
|
||||
students:Student id,name,studentNo noList
|
||||
```
|
||||
|
||||
## 生成的文件
|
||||
|
||||
### 后端 (apps/api)
|
||||
@@ -98,19 +151,28 @@ publishedAt:datetime? 发布时间 "2026-01-16T10:00:00Z"
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/{module}/dto/{module}.dto.ts` | CreateDto、UpdateDto、ResponseDto、QueryDto |
|
||||
| `src/{module}/{module}.service.ts` | CRUD 服务,继承 CrudService |
|
||||
| `src/{module}/{module}.controller.ts` | RESTful 控制器,含 Swagger 文档 |
|
||||
| `src/{module}/{module}.service.ts` | CRUD 服务,继承对应基类 |
|
||||
| `src/{module}/{module}.controller.ts` | RESTful 控制器,含 Swagger 文档和权限控制 |
|
||||
| `src/{module}/{module}.module.ts` | NestJS 模块 |
|
||||
|
||||
**权限控制**:生成的 Controller 使用 `@RequirePermission()` 装饰器控制接口权限:
|
||||
- `{module}:create` - 创建权限
|
||||
- `{module}:read` - 查看权限
|
||||
- `{module}:update` - 更新权限
|
||||
- `{module}:delete` - 删除权限
|
||||
|
||||
### 前端 (apps/web)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/services/{module}.service.ts` | API 调用封装 |
|
||||
| `src/hooks/use{Module}s.ts` | TanStack Query hooks |
|
||||
| `src/components/{module}s/{Module}sTable.tsx` | 数据表格组件 |
|
||||
| `src/components/{module}s/{Module}sTable.tsx` | 数据表格组件(含错误状态处理) |
|
||||
| `src/components/{module}s/{Module}CreateDialog.tsx` | 创建对话框 |
|
||||
| `src/components/{module}s/{Module}EditDialog.tsx` | 编辑对话框 |
|
||||
| `src/app/(dashboard)/{modules}/page.tsx` | 页面组件 |
|
||||
|
||||
**权限控制**:生成的前端组件使用 `<PermissionGuard>` 包裹需要权限的操作按钮。
|
||||
|
||||
### 共享类型 (packages/shared)
|
||||
|
||||
@@ -124,6 +186,21 @@ publishedAt:datetime? 发布时间 "2026-01-16T10:00:00Z"
|
||||
|------|------|
|
||||
| `prisma/schema.prisma` | 追加模型定义 |
|
||||
|
||||
### 种子脚本 (apps/api/prisma/seeds)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `{module}.seed.ts` | 菜单和权限种子数据 |
|
||||
|
||||
生成的种子脚本包含:
|
||||
- 4 个 CRUD 权限(create/read/update/delete)
|
||||
- 1 个菜单项(可配置图标和排序)
|
||||
|
||||
可独立运行或被主 seed.ts 导入:
|
||||
```bash
|
||||
cd apps/api && npx ts-node prisma/seeds/{module}.seed.ts
|
||||
```
|
||||
|
||||
## 自动集成
|
||||
|
||||
生成器会自动修改以下文件完成集成:
|
||||
@@ -141,7 +218,10 @@ publishedAt:datetime? 发布时间 "2026-01-16T10:00:00Z"
|
||||
# 1. 同步数据库
|
||||
pnpm db:generate && pnpm db:push
|
||||
|
||||
# 2. 重启开发服务器
|
||||
# 2. 执行种子脚本(如果生成了)
|
||||
cd apps/api && npx ts-node prisma/seeds/{module}.seed.ts
|
||||
|
||||
# 3. 重启开发服务器
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -154,8 +234,8 @@ $ pnpm generate
|
||||
|
||||
? 模块名称(英文,如 product): product
|
||||
? 模块中文名(如 产品): 产品
|
||||
? 复数名称(如 products): products
|
||||
? 选择要生成的模块: 后端 (NestJS), 前端 (Next.js), 共享类型, Prisma Model
|
||||
? 选择要生成的模块: 后端 (NestJS), 前端 (Next.js), 共享类型, Prisma Model, 菜单/权限种子脚本
|
||||
? 选择服务类型: CrudService(单表 CRUD)
|
||||
? 是否启用软删除? Yes
|
||||
? 定义字段:
|
||||
name:string 名称 "示例产品" min:2 max:100
|
||||
@@ -168,6 +248,8 @@ $ pnpm generate
|
||||
? 最大分页大小: 100
|
||||
? 默认排序字段: createdAt
|
||||
? 默认排序方向: desc
|
||||
? 菜单图标名称: Package
|
||||
? 菜单排序值: 50
|
||||
|
||||
✔ 生成 apps/api/src/product/dto/product.dto.ts
|
||||
✔ 生成 apps/api/src/product/product.service.ts
|
||||
@@ -178,6 +260,8 @@ $ pnpm generate
|
||||
✔ 生成 apps/web/src/components/products/ProductsTable.tsx
|
||||
✔ 生成 apps/web/src/components/products/ProductCreateDialog.tsx
|
||||
✔ 生成 apps/web/src/components/products/ProductEditDialog.tsx
|
||||
✔ 生成 apps/web/src/app/(dashboard)/products/page.tsx
|
||||
✔ 生成 apps/api/prisma/seeds/product.seed.ts
|
||||
✔ 生成 packages/shared/src/types/product.ts
|
||||
✔ 修改 apps/api/prisma/schema.prisma
|
||||
✔ 修改 apps/api/src/app.module.ts
|
||||
@@ -194,12 +278,15 @@ $ pnpm generate
|
||||
plop/
|
||||
├── plopfile.ts # 主配置入口
|
||||
├── package.json # ESM 模块配置
|
||||
├── README.md # 本文档
|
||||
├── generators/
|
||||
│ └── crud.ts # CRUD 生成器逻辑
|
||||
├── helpers/
|
||||
│ └── index.ts # Handlebars helpers
|
||||
├── utils/
|
||||
│ └── field-parser.ts # 字段 DSL 解析器
|
||||
│ ├── field-parser.ts # 字段 DSL 解析器
|
||||
│ ├── relation-parser.ts # 关系配置解析器
|
||||
│ └── schema-parser.ts # Prisma schema 解析器
|
||||
└── templates/
|
||||
├── api/ # 后端模板
|
||||
│ ├── dto.hbs
|
||||
@@ -211,11 +298,14 @@ plop/
|
||||
│ ├── hooks.hbs
|
||||
│ ├── table.hbs
|
||||
│ ├── create-dialog.hbs
|
||||
│ └── edit-dialog.hbs
|
||||
│ ├── edit-dialog.hbs
|
||||
│ └── page.hbs
|
||||
├── shared/ # 共享类型模板
|
||||
│ └── types.hbs
|
||||
└── prisma/ # Prisma 模板
|
||||
└── model.hbs
|
||||
├── prisma/ # Prisma 模板
|
||||
│ └── model.hbs
|
||||
└── seed/ # 种子脚本模板
|
||||
└── module-seed.hbs
|
||||
```
|
||||
|
||||
## 扩展模板
|
||||
@@ -232,9 +322,36 @@ plop/
|
||||
| `snakeCase` | 转 snake_case | `productItem` → `product_item` |
|
||||
| `constantCase` | 转 CONSTANT_CASE | `product` → `PRODUCT` |
|
||||
| `tsType` | 获取 TS 类型 | `string`, `number` 等 |
|
||||
| `tsResponseType` | 获取响应 TS 类型 | `string`, `number` 等 |
|
||||
| `prismaType` | 获取 Prisma 类型 | `String`, `Float` 等 |
|
||||
| `zodValidation` | 生成 Zod 验证 | `z.string().min(2)` |
|
||||
| `formControl` | 生成表单控件 | `<Input .../>` |
|
||||
| `cellRenderer` | 生成表格单元格渲染 | `<Badge .../>` |
|
||||
| `validationDecorators` | 生成 class-validator 装饰器 | `@IsString()` |
|
||||
| `formattedExample` | 格式化示例值 | `"text"` / `123` |
|
||||
| `openBrace` | 输出 `{` | 用于 JSX 模板 |
|
||||
| `closeBrace` | 输出 `}` | 用于 JSX 模板 |
|
||||
|
||||
### 逻辑 Helpers
|
||||
|
||||
| Helper | 说明 |
|
||||
|--------|------|
|
||||
| `eq a b` | 相等判断 |
|
||||
| `ne a b` | 不等判断 |
|
||||
| `and a b` | 逻辑与 |
|
||||
| `or a b` | 逻辑或 |
|
||||
| `not a` | 逻辑非 |
|
||||
| `includes arr value` | 数组包含 |
|
||||
|
||||
### 条件 Helpers
|
||||
|
||||
| Helper | 说明 |
|
||||
|--------|------|
|
||||
| `hasValidation fields` | 字段是否有验证规则 |
|
||||
| `hasTransform fields` | 字段是否需要转换(date/datetime) |
|
||||
| `hasTextarea fields` | 字段是否有长文本 |
|
||||
| `hasSelect fields` | 字段是否有枚举 |
|
||||
| `hasSwitch fields` | 字段是否有布尔值 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
@@ -249,3 +366,28 @@ plop/
|
||||
### Q: 生成后 TypeScript 报错?
|
||||
|
||||
确保运行 `pnpm db:generate` 更新 Prisma Client 类型。
|
||||
|
||||
### Q: 如何在 JSX 模板中输出花括号?
|
||||
|
||||
使用 `{{openBrace}}` 和 `{{closeBrace}}` helpers:
|
||||
```handlebars
|
||||
<span>{{openBrace}}item.id{{closeBrace}}</span>
|
||||
```
|
||||
输出:`<span>{item.id}</span>`
|
||||
|
||||
### Q: QueryDto 什么时候会被生成?
|
||||
|
||||
无论是否选择搜索字段,QueryDto 都会被生成。这便于后续扩展查询参数。`hasQueryDto` 标志仅控制 Controller 中是否解构查询字段。
|
||||
|
||||
### Q: 如何执行种子脚本?
|
||||
|
||||
生成的种子脚本支持独立运行:
|
||||
```bash
|
||||
cd apps/api && npx ts-node prisma/seeds/{module}.seed.ts
|
||||
```
|
||||
|
||||
或在主 `seed.ts` 中导入调用:
|
||||
```typescript
|
||||
import { seedProductModule } from './seeds/product.seed';
|
||||
await seedProductModule(prisma);
|
||||
```
|
||||
|
||||
312
pnpm-lock.yaml
generated
312
pnpm-lock.yaml
generated
@@ -56,6 +56,9 @@ importers:
|
||||
'@nestjs/swagger':
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.1(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@prisma/client':
|
||||
specifier: ^6.1.0
|
||||
version: 6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3)
|
||||
@@ -83,6 +86,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^7.0.12
|
||||
version: 7.0.12
|
||||
oidc-provider:
|
||||
specifier: ^9.6.0
|
||||
version: 9.6.0
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -135,6 +141,9 @@ importers:
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.5
|
||||
version: 7.0.5
|
||||
'@types/oidc-provider':
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0
|
||||
'@types/passport-jwt':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -1184,6 +1193,16 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@koa/cors@5.0.0':
|
||||
resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@koa/router@15.2.0':
|
||||
resolution: {integrity: sha512-7YUhq4W83cybfNa4E7JqJpWzoCTSvbnFltkvRaUaUX1ybFzlUoLNY1SqT8XmIAO6nGbFrev+FvJHw4mL+4WhuQ==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
koa: ^2.0.0 || ^3.0.0
|
||||
|
||||
'@ljharb/through@2.3.14':
|
||||
resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1370,6 +1389,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1391,6 +1414,10 @@ packages:
|
||||
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@paralleldrive/cuid2@3.0.6':
|
||||
resolution: {integrity: sha512-ujtxTTvr4fwPrzuQT7o6VLKs5BzdWetR9+/zRQ0SyK9hVIwZQllEccxgcHYXN6I3Z429y1yg3F6+uiVxMDPrLQ==}
|
||||
hasBin: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2373,6 +2400,9 @@ packages:
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
'@types/accepts@1.3.7':
|
||||
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -2394,6 +2424,12 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/content-disposition@0.5.9':
|
||||
resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==}
|
||||
|
||||
'@types/cookies@0.9.2':
|
||||
resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||
|
||||
@@ -2415,6 +2451,9 @@ packages:
|
||||
'@types/graceful-fs@4.1.9':
|
||||
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
||||
|
||||
'@types/http-assert@1.5.6':
|
||||
resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
@@ -2445,6 +2484,15 @@ packages:
|
||||
'@types/jsonwebtoken@9.0.5':
|
||||
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
|
||||
|
||||
'@types/keygrip@1.0.6':
|
||||
resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==}
|
||||
|
||||
'@types/koa-compose@3.2.9':
|
||||
resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==}
|
||||
|
||||
'@types/koa@3.0.1':
|
||||
resolution: {integrity: sha512-VkB6WJUQSe0zBpR+Q7/YIUESGp5wPHcaXr0xueU5W0EOUWtlSbblsl+Kl31lyRQ63nIILh0e/7gXjQ09JXJIHw==}
|
||||
|
||||
'@types/liftoff@4.0.3':
|
||||
resolution: {integrity: sha512-UgbL2kR5pLrWICvr8+fuSg0u43LY250q7ZMkC+XKC3E+rs/YBDEnQIzsnhU5dYsLlwMi3R75UvCL87pObP1sxw==}
|
||||
|
||||
@@ -2466,6 +2514,9 @@ packages:
|
||||
'@types/nodemailer@7.0.5':
|
||||
resolution: {integrity: sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==}
|
||||
|
||||
'@types/oidc-provider@9.5.0':
|
||||
resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==}
|
||||
|
||||
'@types/passport-jwt@4.0.1':
|
||||
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
|
||||
|
||||
@@ -2963,6 +3014,9 @@ packages:
|
||||
resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3240,6 +3294,10 @@ packages:
|
||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookies@0.9.1:
|
||||
resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -3323,6 +3381,9 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
deep-equal@1.0.1:
|
||||
resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -3359,6 +3420,10 @@ packages:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
depd@1.1.2:
|
||||
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3469,6 +3534,9 @@ packages:
|
||||
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
error-causes@3.0.2:
|
||||
resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@@ -3666,6 +3734,10 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eta@4.5.0:
|
||||
resolution: {integrity: sha512-qifAYjuW5AM1eEEIsFnOwB+TGqu6ynU3OKj9WbUTOtUBHFPZqL03XUW34kbp3zm19Ald+U8dEyRXaVsUck+Y1g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4032,10 +4104,22 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
http-assert@1.5.0:
|
||||
resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-errors@1.8.1:
|
||||
resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
http-errors@2.0.0:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -4458,6 +4542,9 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4539,6 +4626,11 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
keygrip@1.1.0:
|
||||
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -4546,6 +4638,13 @@ packages:
|
||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
koa-compose@4.1.0:
|
||||
resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==}
|
||||
|
||||
koa@3.1.1:
|
||||
resolution: {integrity: sha512-KDDuvpfqSK0ZKEO2gCPedNjl5wYpfj+HNiuVRlbhd1A88S3M0ySkdf2V/EJ4NWt5dwh5PXCdcenrKK2IQJAxsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
language-subtag-registry@0.3.23:
|
||||
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
|
||||
|
||||
@@ -4752,6 +4851,10 @@ packages:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
memfs@3.5.3:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -4778,10 +4881,18 @@ packages:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5008,6 +5119,9 @@ packages:
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
oidc-provider@9.6.0:
|
||||
resolution: {integrity: sha512-CCRUYPOumEy/DT+L86H40WgXjXfDHlsJYZdyd4ZKGFxJh/kAd7DxMX3dwpbX0g+WjB+NWU+kla1b/yZmHNcR0Q==}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -5126,6 +5240,9 @@ packages:
|
||||
path-to-regexp@3.3.0:
|
||||
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
|
||||
|
||||
path-to-regexp@8.3.0:
|
||||
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
||||
|
||||
path-type@4.0.0:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5265,6 +5382,10 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
quick-lru@7.3.0:
|
||||
resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@@ -5276,6 +5397,10 @@ packages:
|
||||
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
@@ -5602,10 +5727,18 @@ packages:
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
statuses@1.5.0:
|
||||
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5903,6 +6036,10 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsscmp@1.0.6:
|
||||
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
|
||||
engines: {node: '>=0.6.x'}
|
||||
|
||||
tsup@8.5.1:
|
||||
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5981,6 +6118,10 @@ packages:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
type-is@2.0.1:
|
||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7405,6 +7546,20 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@koa/cors@5.0.0':
|
||||
dependencies:
|
||||
vary: 1.1.2
|
||||
|
||||
'@koa/router@15.2.0(koa@3.1.1)':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
koa: 3.1.1
|
||||
koa-compose: 4.1.0
|
||||
path-to-regexp: 8.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ljharb/through@2.3.14':
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -7605,6 +7760,8 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -7627,6 +7784,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@paralleldrive/cuid2@3.0.6':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
bignumber.js: 9.3.1
|
||||
error-causes: 3.0.2
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -8649,6 +8812,10 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/accepts@1.3.7':
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
@@ -8683,6 +8850,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/content-disposition@0.5.9': {}
|
||||
|
||||
'@types/cookies@0.9.2':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/express': 5.0.6
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
dependencies:
|
||||
'@types/eslint': 9.6.1
|
||||
@@ -8714,6 +8890,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/http-assert@1.5.6': {}
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/inquirer@9.0.9':
|
||||
@@ -8749,6 +8927,23 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/keygrip@1.0.6': {}
|
||||
|
||||
'@types/koa-compose@3.2.9':
|
||||
dependencies:
|
||||
'@types/koa': 3.0.1
|
||||
|
||||
'@types/koa@3.0.1':
|
||||
dependencies:
|
||||
'@types/accepts': 1.3.7
|
||||
'@types/content-disposition': 0.5.9
|
||||
'@types/cookies': 0.9.2
|
||||
'@types/http-assert': 1.5.6
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/koa-compose': 3.2.9
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/liftoff@4.0.3':
|
||||
dependencies:
|
||||
'@types/fined': 1.1.5
|
||||
@@ -8777,6 +8972,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@types/oidc-provider@9.5.0':
|
||||
dependencies:
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/koa': 3.0.1
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/passport-jwt@4.0.1':
|
||||
dependencies:
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
@@ -9353,6 +9554,8 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bl@4.1.0:
|
||||
@@ -9623,6 +9826,11 @@ snapshots:
|
||||
|
||||
cookie@0.7.1: {}
|
||||
|
||||
cookies@0.9.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
keygrip: 1.1.0
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.5:
|
||||
@@ -9700,6 +9908,8 @@ snapshots:
|
||||
|
||||
dedent@1.7.1: {}
|
||||
|
||||
deep-equal@1.0.1: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
@@ -9730,6 +9940,8 @@ snapshots:
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
depd@1.1.2: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
@@ -9811,6 +10023,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
error-causes@3.0.2: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@@ -10209,6 +10423,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eta@4.5.0: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
@@ -10647,6 +10863,19 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-assert@1.5.0:
|
||||
dependencies:
|
||||
deep-equal: 1.0.1
|
||||
http-errors: 1.8.1
|
||||
|
||||
http-errors@1.8.1:
|
||||
dependencies:
|
||||
depd: 1.1.2
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 1.5.0
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-errors@2.0.0:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -10655,6 +10884,14 @@ snapshots:
|
||||
statuses: 2.0.1
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
@@ -11311,6 +11548,8 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -11411,12 +11650,39 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
keygrip@1.1.0:
|
||||
dependencies:
|
||||
tsscmp: 1.0.6
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
koa-compose@4.1.0: {}
|
||||
|
||||
koa@3.1.1:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
content-disposition: 0.5.4
|
||||
content-type: 1.0.5
|
||||
cookies: 0.9.1
|
||||
delegates: 1.0.0
|
||||
destroy: 1.2.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
fresh: 0.5.2
|
||||
http-assert: 1.5.0
|
||||
http-errors: 2.0.0
|
||||
koa-compose: 4.1.0
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.1
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
|
||||
language-tags@1.0.9:
|
||||
@@ -11580,6 +11846,8 @@ snapshots:
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
memfs@3.5.3:
|
||||
dependencies:
|
||||
fs-monkey: 1.1.0
|
||||
@@ -11599,10 +11867,16 @@ snapshots:
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
@@ -11844,6 +12118,21 @@ snapshots:
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
oidc-provider@9.6.0:
|
||||
dependencies:
|
||||
'@koa/cors': 5.0.0
|
||||
'@koa/router': 15.2.0(koa@3.1.1)
|
||||
debug: 4.4.3
|
||||
eta: 4.5.0
|
||||
jose: 6.1.3
|
||||
jsesc: 3.1.0
|
||||
koa: 3.1.1
|
||||
nanoid: 5.1.6
|
||||
quick-lru: 7.3.0
|
||||
raw-body: 3.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -11966,6 +12255,8 @@ snapshots:
|
||||
|
||||
path-to-regexp@3.3.0: {}
|
||||
|
||||
path-to-regexp@8.3.0: {}
|
||||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
@@ -12093,6 +12384,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-lru@7.3.0: {}
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -12106,6 +12399,13 @@ snapshots:
|
||||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
rc9@2.1.2:
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
@@ -12497,8 +12797,12 @@ snapshots:
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@1.5.0: {}
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -12808,6 +13112,8 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsscmp@1.0.6: {}
|
||||
|
||||
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.2)
|
||||
@@ -12885,6 +13191,12 @@ snapshots:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
type-is@2.0.1:
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.2
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
Reference in New Issue
Block a user