Compare commits

...

10 Commits

Author SHA1 Message Date
charilezhou
595d59ab5b chore: 将 next-env.d.ts 加入 .gitignore
该文件由 Next.js 自动生成,不应纳入版本控制。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 18:24:38 +08:00
charilezhou
5c1a998192 feat(web): 实现自定义主题功能
支持 6 套预设主题(默认、海洋、森林、日落、玫瑰、紫罗兰)和自定义主色色相滑块,
通过动态注入 CSS 变量实现主题切换,使用 localStorage 持久化存储,
添加 SSR 初始化脚本避免首次加载颜色闪烁。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 18:22:22 +08:00
charilezhou
ad847f1e4c refactor(web): 将刷新权限缓存功能移至系统设置
- 新增 PermissionSettings 组件
- 从个人中心页面移除刷新权限缓存功能
- 在系统设置页面添加权限缓存设置卡片

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:32:24 +08:00
charilezhou
3943bd112f docs: 更新 OIDC Provider 文档并完善环境配置
文档更新:
- 从"实施方案"改为"实施文档",标记为已完成状态
- 添加快速开始章节,提供完整的使用示例
- 补充第一方应用自动授权的两种场景实现细节
- 补充 Grant Scope 存储的 payload 结构说明
- 新增客户端服务章节(cuid2 ID + 随机密钥)
- 更新关键文件清单(后端/前端/共享类型)

环境配置:
- 添加 FRONTEND_URL 配置
- 添加 OIDC Provider 开发环境配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:29:26 +08:00
charilezhou
90513e8278 feat: 实现完整的 OIDC Provider 功能
- 后端:基于 node-oidc-provider 实现 OIDC Provider
  - 支持 authorization_code、refresh_token、client_credentials 授权类型
  - Redis adapter 存储会话数据,Prisma adapter 存储持久化数据
  - 客户端管理 CRUD API(创建、更新、删除、重新生成密钥)
  - 交互 API(登录、授权确认、中止)
  - 第一方应用自动跳过授权确认页面
  - 使用 cuid2 生成客户端 ID

- 前端:OIDC 客户端管理界面
  - 客户端列表表格(支持分页、排序)
  - 创建/编辑弹窗(支持所有 OIDC 配置字段)
  - OIDC 交互页面(登录表单、授权确认表单)

- 共享类型:添加 OIDC 相关 TypeScript 类型定义

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:22:32 +08:00
charilezhou
8db25538d4 docs: 添加 OIDC Provider 实施方案文档
- 基于 panva/node-oidc-provider 的完整实施方案
- 混合存储策略:PostgreSQL(Client、Grant、RefreshToken)+ Redis(短期令牌)
- 前端采用 SSR + Server Actions,独立路由组 (oidc)
- 包含 Prisma 模型、适配器、控制器、前端页面等完整设计

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 11:02:48 +08:00
charilezhou
66cce5a765 feat(api): health 接口添加 storage 服务监控
- StorageService 添加 healthCheck 方法
- 健康检查响应增加 storage 状态
- 更新共享类型定义

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:37:33 +08:00
charilezhou
140c268412 fix(web): 切换账号后权限缓存不刷新问题
- 登录/注册成功时先清除旧的权限缓存
- 确保切换账号后重新加载新账号的权限数据

feat(web): 系统状态接入真实 health API

- 新增 health.service.ts 调用后端健康检查接口
- 新增 useHealth hook,每 30 秒自动刷新
- DashboardStats 显示真实健康状态(正常/部分异常/异常)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:35:15 +08:00
charilezhou
e7496ed41b docs: 更新 CLAUDE.md 补充权限系统和代码生成器说明
新增内容:
- pnpm generate 代码生成命令
- 前端权限控制(PermissionGuard 组件、usePermissionStore)
- 后端权限控制(@RequirePermission 装饰器、PermissionModule)
- 代码生成器生成内容说明
- DataTable 通用组件说明

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:50:23 +08:00
charilezhou
2aa992c88d docs(plop): 更新 README 文档
新增内容:
- 服务类型说明(CrudService/RelationCrudService/ManyToManyCrudService)
- 关系配置 DSL 语法(一对多/多对一/多对多)
- 种子脚本生成和执行说明
- 权限控制说明(@RequirePermission + PermissionGuard)
- 新增模板文件(page.hbs、module-seed.hbs)
- Helpers 完整列表(含 openBrace/closeBrace)
- FAQ 扩展(QueryDto 生成、JSX 花括号、种子脚本)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:46:50 +08:00
67 changed files with 6508 additions and 84 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ dist
.next
.turbo
out
next-env.d.ts
# Environment
.env.local

View File

@@ -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`

View File

@@ -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

View File

@@ -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 私钥RS256PEM 格式Base64 编码)
# 生成方式: openssl genrsa 2048 | base64 -w 0
# 注意: 生产环境必须配置,开发环境可留空使用临时密钥
OIDC_JWKS_PRIVATE_KEY=

View File

@@ -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",

View File

@@ -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")
}

View File

@@ -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

View File

View 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,

View File

@@ -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',
},
},
};
}

View File

@@ -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;
}
}
}

View File

@@ -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 {

View File

@@ -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();

View 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);
};
}

View 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;
}
}
}

View 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);
}
}
}

View 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);
}
}

View 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>`;
},
};
}

View 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;
}

View 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;
}

View 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 : '中止授权失败',
});
}
}
}

View 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 {}

View 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);
}
}

View 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;
},
};
}
}

View 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 };
}
}

View 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;
}
}

View File

@@ -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.

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);

View 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">
&copy; {new Date().getFullYear()} {siteConfig.name}. All rights reserved.
</div>
</footer>
</div>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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`);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,

View 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&#10;每行一个地址"
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&#10;每行一个地址"
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>
);
}

View 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&#10;每行一个地址"
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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export { OidcClientsTable } from './OidcClientsTable';
export { OidcClientCreateDialog } from './OidcClientCreateDialog';
export { OidcClientEditDialog } from './OidcClientEditDialog';

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);

View File

@@ -1,2 +1,3 @@
export { ThemeSettings } from './ThemeSettings';
export { SidebarSettings } from './SidebarSettings';
export { PermissionSettings } from './PermissionSettings';

View File

@@ -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;
// 分页默认值

View 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;
}

View 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 秒自动刷新
});
}

View 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] });
},
});
}

View 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 };
}

View 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}%)`;
}

View 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();
}

View 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;
}

View 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);
},
};

View File

@@ -1,2 +0,0 @@
export { authService } from './auth.service';
export { userService, type GetUsersParams } from './user.service';

View 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`),
};

View 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' }
);
},
};

View File

@@ -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;

View File

@@ -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);

File diff suppressed because it is too large Load Diff

View File

@@ -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 模块 |

View File

@@ -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';

View 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[];
}

View File

@@ -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
View File

@@ -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