first commit
This commit is contained in:
parent
51ab712a5d
commit
7da91991ff
316
CONSOLE_DEVELOPMENT.md
Normal file
316
CONSOLE_DEVELOPMENT.md
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
# 风铃认证中心 - 管理端开发完成
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
本项目为风铃认证中心提供完整的 Web 管理界面和后端 API。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- Vue 3 + TypeScript
|
||||||
|
- Vite
|
||||||
|
- Element Plus UI 框架
|
||||||
|
- Pinia 状态管理
|
||||||
|
- Vue Router 路由
|
||||||
|
- Axios HTTP 客户端
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- ASP.NET Core 10.0
|
||||||
|
- Entity Framework Core 10.0
|
||||||
|
- PostgreSQL 数据库
|
||||||
|
- OpenIddict OAuth2/OIDC 服务
|
||||||
|
- Serilog 日志
|
||||||
|
- OpenTelemetry 可观测性
|
||||||
|
|
||||||
|
## 完成的功能
|
||||||
|
|
||||||
|
### 前端页面
|
||||||
|
|
||||||
|
#### 1. 仪表盘(Dashboard)
|
||||||
|
- 统计卡片:用户数、租户数、OAuth应用数、今日访问
|
||||||
|
- 最近活动时间线
|
||||||
|
- 系统信息展示
|
||||||
|
- 位置:`src/views/Dashboard/Dashboard.vue`
|
||||||
|
|
||||||
|
#### 2. 用户管理
|
||||||
|
- 用户列表(分页、搜索)
|
||||||
|
- 添加/编辑用户
|
||||||
|
- 角色分配
|
||||||
|
- 重置密码
|
||||||
|
- 删除用户
|
||||||
|
- 位置:`src/views/Users/UserList.vue`
|
||||||
|
|
||||||
|
#### 3. 角色管理
|
||||||
|
- 角色列表(分页、搜索)
|
||||||
|
- 添加/编辑角色
|
||||||
|
- 权限配置
|
||||||
|
- 查看角色用户
|
||||||
|
- 移除用户角色
|
||||||
|
- 位置:`src/views/Users/RoleList.vue`
|
||||||
|
|
||||||
|
#### 4. 租户管理
|
||||||
|
- 租户列表(分页、搜索)
|
||||||
|
- 添加/编辑租户
|
||||||
|
- 租户设置(注册限制、密码策略、会话超时)
|
||||||
|
- 查看租户用户和角色
|
||||||
|
- 位置:`src/views/Users/TenantList.vue`
|
||||||
|
|
||||||
|
#### 5. OAuth 应用管理
|
||||||
|
- 应用列表(分页、搜索)
|
||||||
|
- 添加/编辑应用
|
||||||
|
- 完整配置(重定向URI、授权类型、权限范围等)
|
||||||
|
- 查看密钥
|
||||||
|
- 位置:`src/views/OAuth/ClientList.vue`
|
||||||
|
|
||||||
|
#### 6. 访问日志
|
||||||
|
- 日志列表(多条件筛选)
|
||||||
|
- 日志详情查看
|
||||||
|
- 导出 CSV
|
||||||
|
- 位置:`src/views/Audit/AccessLog.vue`
|
||||||
|
|
||||||
|
#### 7. 审计日志
|
||||||
|
- 日志列表(多条件筛选)
|
||||||
|
- 日志详情查看(包含变更前/后数据)
|
||||||
|
- 导出 CSV
|
||||||
|
- 位置:`src/views/Audit/AuditLog.vue`
|
||||||
|
|
||||||
|
### 后端 API
|
||||||
|
|
||||||
|
#### 控制器
|
||||||
|
|
||||||
|
1. **AuthController** - 认证端点
|
||||||
|
- POST /connect/token - 登录(密码模式)
|
||||||
|
- POST /connect/token/refresh - 刷新令牌
|
||||||
|
- POST /connect/revoke - 撤销令牌
|
||||||
|
|
||||||
|
2. **UsersController** - 用户管理
|
||||||
|
- GET /api/users - 获取用户列表(分页、搜索)
|
||||||
|
- GET /api/users/{id} - 获取单个用户
|
||||||
|
- POST /api/users - 创建用户
|
||||||
|
- PUT /api/users/{id} - 更新用户
|
||||||
|
- PUT /api/users/{id}/password - 重置密码
|
||||||
|
- DELETE /api/users/{id} - 删除用户
|
||||||
|
|
||||||
|
3. **RolesController** - 角色管理
|
||||||
|
- GET /api/roles - 获取角色列表(分页、搜索)
|
||||||
|
- GET /api/roles/{id} - 获取单个角色
|
||||||
|
- GET /api/roles/{id}/users - 获取角色用户
|
||||||
|
- POST /api/roles - 创建角色
|
||||||
|
- PUT /api/roles/{id} - 更新角色
|
||||||
|
- DELETE /api/roles/{id} - 删除角色
|
||||||
|
- DELETE /api/roles/{id}/users/{userId} - 移除用户角色
|
||||||
|
|
||||||
|
4. **TenantsController** - 租户管理
|
||||||
|
- GET /api/tenants - 获取租户列表(分页、搜索)
|
||||||
|
- GET /api/tenants/{id} - 获取单个租户
|
||||||
|
- GET /api/tenants/{tenantId}/users - 获取租户用户
|
||||||
|
- GET /api/tenants/{tenantId}/roles - 获取租户角色
|
||||||
|
- GET /api/tenants/{tenantId}/settings - 获取租户设置
|
||||||
|
- PUT /api/tenants/{tenantId}/settings - 更新租户设置
|
||||||
|
- POST /api/tenants - 创建租户
|
||||||
|
- PUT /api/tenants/{id} - 更新租户
|
||||||
|
- DELETE /api/tenants/{id} - 删除租户
|
||||||
|
|
||||||
|
5. **OAuthClientsController** - OAuth 应用管理
|
||||||
|
- GET /api/oauthclients - 获取应用列表(分页、搜索)
|
||||||
|
- GET /api/oauthclients/{id} - 获取单个应用
|
||||||
|
- GET /api/oauthclients/{id}/secret - 获取应用密钥
|
||||||
|
- POST /api/oauthclients - 创建应用
|
||||||
|
- PUT /api/oauthclients/{id} - 更新应用
|
||||||
|
- DELETE /api/oauthclients/{id} - 删除应用
|
||||||
|
|
||||||
|
6. **AccessLogsController** - 访问日志
|
||||||
|
- GET /api/access-logs - 获取日志列表(分页、筛选)
|
||||||
|
- GET /api/access-logs/export - 导出 CSV
|
||||||
|
|
||||||
|
7. **AuditLogsController** - 审计日志
|
||||||
|
- GET /api/audit-logs - 获取日志列表(分页、筛选)
|
||||||
|
- GET /api/audit-logs/export - 导出 CSV
|
||||||
|
|
||||||
|
8. **StatsController** - 统计数据
|
||||||
|
- GET /api/stats/dashboard - 仪表盘统计数据
|
||||||
|
- GET /api/stats/system - 系统统计信息
|
||||||
|
|
||||||
|
9. **HealthCheckController** - 健康检查
|
||||||
|
- GET /health - 健康检查端点
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
|
||||||
|
#### 核心模型
|
||||||
|
|
||||||
|
1. **ApplicationUser** - 用户
|
||||||
|
- 继承自 IdentityUser<long>
|
||||||
|
- RealName, Phone, TenantId, CreatedTime, UpdatedTime, IsDeleted
|
||||||
|
|
||||||
|
2. **ApplicationRole** - 角色
|
||||||
|
- 继承自 IdentityRole<long>
|
||||||
|
- Description, DisplayName, TenantId, IsSystem, Permissions, CreatedTime
|
||||||
|
|
||||||
|
3. **Tenant** - 租户
|
||||||
|
- Id, TenantId, Name, ContactName, ContactEmail, ContactPhone
|
||||||
|
- MaxUsers, Description, Status, ExpiresAt, CreatedAt, UpdatedAt, IsDeleted
|
||||||
|
|
||||||
|
4. **OAuthApplication** - OAuth 应用
|
||||||
|
- Id, ClientId, ClientSecret, DisplayName
|
||||||
|
- RedirectUris, PostLogoutRedirectUris, Scopes, GrantTypes
|
||||||
|
- ClientType, ConsentType, Status, Description, CreatedAt, UpdatedAt
|
||||||
|
|
||||||
|
5. **AccessLog** - 访问日志
|
||||||
|
- UserName, TenantId, Action, Resource, Method, IpAddress
|
||||||
|
- UserAgent, Status, Duration, RequestData, ResponseData, ErrorMessage
|
||||||
|
- CreatedAt
|
||||||
|
|
||||||
|
6. **AuditLog** - 审计日志
|
||||||
|
- Operator, TenantId, Operation, Action
|
||||||
|
- TargetType, TargetId, TargetName, IpAddress, Description
|
||||||
|
- OldValue, NewValue, ErrorMessage, Status, CreatedAt
|
||||||
|
|
||||||
|
### 数据库配置
|
||||||
|
|
||||||
|
- **数据库类型**: PostgreSQL
|
||||||
|
- **连接字符串**: `Host=192.168.100.10;Port=5432;Database=fengling_auth;Username=movingsam;Password=sl52788542`
|
||||||
|
- **迁移**:
|
||||||
|
- InitialCreate - 初始化表结构
|
||||||
|
- AddOAuthApplications - 添加 OAuth 应用表
|
||||||
|
- AddTenantAndLogs - 添加租户和日志表
|
||||||
|
- AddOAuthDescription - 添加 OAuth 描述字段
|
||||||
|
|
||||||
|
### 初始数据
|
||||||
|
|
||||||
|
SeedData 初始化以下数据:
|
||||||
|
|
||||||
|
#### 默认租户
|
||||||
|
- TenantId: default
|
||||||
|
- Name: 默认租户
|
||||||
|
- MaxUsers: 1000
|
||||||
|
|
||||||
|
#### 系统角色
|
||||||
|
1. **Admin** - 管理员
|
||||||
|
- 所有权限
|
||||||
|
- IsSystem: true
|
||||||
|
|
||||||
|
2. **User** - 普通用户
|
||||||
|
- user.view 权限
|
||||||
|
- IsSystem: true
|
||||||
|
|
||||||
|
#### 默认用户
|
||||||
|
1. **admin**
|
||||||
|
- 密码: Admin@123
|
||||||
|
- 角色: Admin
|
||||||
|
|
||||||
|
2. **testuser**
|
||||||
|
- 密码: Test@123
|
||||||
|
- 角色: User
|
||||||
|
|
||||||
|
#### OAuth 应用
|
||||||
|
- **fengling-console** - 风铃运管中心
|
||||||
|
- ClientSecret: console-secret-change-in-production
|
||||||
|
- 授权类型: authorization_code, refresh_token
|
||||||
|
|
||||||
|
## 运行说明
|
||||||
|
|
||||||
|
### 后端(AuthService)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Fengling.AuthService
|
||||||
|
dotnet build
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
服务地址: http://localhost:5000
|
||||||
|
API 文档: http://localhost:5000/swagger
|
||||||
|
健康检查: http://localhost:5000/health
|
||||||
|
|
||||||
|
### 前端(Console.Web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Fengling.Console.Web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发地址: http://localhost:5173
|
||||||
|
|
||||||
|
生产构建:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 代理配置
|
||||||
|
|
||||||
|
Vite 开发服务器配置了 API 代理:
|
||||||
|
|
||||||
|
- `/api/auth/*` → `http://localhost:5000` (AuthService)
|
||||||
|
- `/api/gateway/*` → `http://localhost:5001` (YarpGateway)
|
||||||
|
|
||||||
|
## 默认登录
|
||||||
|
|
||||||
|
- 用户名: admin
|
||||||
|
- 密码: Admin@123
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
Fengling.Refactory.Buiding/
|
||||||
|
├── src/
|
||||||
|
│ ├── Fengling.AuthService/ # 后端认证服务
|
||||||
|
│ │ ├── Controllers/ # API 控制器
|
||||||
|
│ │ ├── Data/ # 数据库上下文和种子数据
|
||||||
|
│ │ ├── Models/ # 数据模型
|
||||||
|
│ │ ├── Configuration/ # 配置类
|
||||||
|
│ │ └── Migrations/ # 数据库迁移
|
||||||
|
│ │
|
||||||
|
│ └── Fengling.Console.Web/ # 前端管理界面
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API 调用封装
|
||||||
|
│ │ ├── stores/ # Pinia 状态管理
|
||||||
|
│ │ ├── router/ # Vue Router 路由
|
||||||
|
│ │ └── views/ # 页面组件
|
||||||
|
│ │ ├── Auth/ # 认证页面
|
||||||
|
│ │ ├── Dashboard/ # 仪表盘
|
||||||
|
│ │ ├── Users/ # 用户/角色/租户管理
|
||||||
|
│ │ ├── OAuth/ # OAuth 应用管理
|
||||||
|
│ │ └── Audit/ # 日志管理
|
||||||
|
│ └── dist/ # 生产构建输出
|
||||||
|
│
|
||||||
|
└── docs/ # 项目文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **生产环境修改**:
|
||||||
|
- 更改默认管理员密码
|
||||||
|
- 修改 OAuth 应用密钥
|
||||||
|
- 配置 HTTPS
|
||||||
|
- 限制数据库访问
|
||||||
|
|
||||||
|
2. **密码策略**:
|
||||||
|
- 至少 8 位
|
||||||
|
- 包含字母和数字
|
||||||
|
- 可根据租户设置自定义策略
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. **前端**:
|
||||||
|
- 添加更多图表可视化
|
||||||
|
- 实现前端国际化
|
||||||
|
- 添加单元测试
|
||||||
|
|
||||||
|
2. **后端**:
|
||||||
|
- 添加 API 限流
|
||||||
|
- 实现缓存策略
|
||||||
|
- 添加更多日志级别
|
||||||
|
- 完善错误处理
|
||||||
|
|
||||||
|
3. **功能**:
|
||||||
|
- OAuth 授权码流程完善
|
||||||
|
- 双因素认证(2FA)
|
||||||
|
- 用户自助服务(找回密码、注册)
|
||||||
|
- API 权限细粒度控制
|
||||||
|
|
||||||
|
## 版本信息
|
||||||
|
|
||||||
|
- 前端版本: v1.0.0
|
||||||
|
- 后端版本: v1.0.0
|
||||||
|
- .NET 版本: 10.0.2
|
||||||
|
- Node.js 版本: 推荐 18+
|
||||||
@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YarpGateway", "src\YarpGateway\YarpGateway.csproj", "{8DDFE39A-06AE-4C02-BA80-27F0C809E959}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YarpGateway", "src\YarpGateway\YarpGateway.csproj", "{8DDFE39A-06AE-4C02-BA80-27F0C809E959}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.AuthService", "src\Fengling.AuthService\Fengling.AuthService.csproj", "{469FA168-1656-483D-A40D-072FFE8C5E33}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -29,11 +31,24 @@ Global
|
|||||||
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x64.Build.0 = Release|Any CPU
|
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.ActiveCfg = Release|Any CPU
|
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.Build.0 = Release|Any CPU
|
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{8DDFE39A-06AE-4C02-BA80-27F0C809E959} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{8DDFE39A-06AE-4C02-BA80-27F0C809E959} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{469FA168-1656-483D-A40D-072FFE8C5E33} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
72
LAYOUT_FIX.md
Normal file
72
LAYOUT_FIX.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# 风铃认证中心 - 布局和路由修复
|
||||||
|
|
||||||
|
## 修复的问题
|
||||||
|
|
||||||
|
1. **菜单点击不跳转** - 添加了 `router.push({ name: index })` 到 `handleMenuSelect`
|
||||||
|
2. **App.vue 缺少 router-view** - 更新为包含 `<router-view />` 和基础样式
|
||||||
|
3. **面包屑显示** - 优化为只在非 Dashboard 页面显示
|
||||||
|
4. **内容区域样式** - 添加 `content-wrapper` 包裹,优化布局
|
||||||
|
|
||||||
|
## 现在的布局结构
|
||||||
|
|
||||||
|
```
|
||||||
|
App.vue
|
||||||
|
└── <router-view />
|
||||||
|
├── Login.vue (路径: /login)
|
||||||
|
├── Callback.vue (路径: /auth/callback)
|
||||||
|
└── Dashboard.vue (路径: / 及其子路由)
|
||||||
|
├── 侧边栏 (el-aside)
|
||||||
|
└── 主内容区 (el-main)
|
||||||
|
├── 面包屑 (可选)
|
||||||
|
└── <router-view /> (嵌套子路由)
|
||||||
|
├── Dashboard/Dashboard.vue
|
||||||
|
├── Users/UserList.vue
|
||||||
|
├── Users/RoleList.vue
|
||||||
|
├── Users/TenantList.vue
|
||||||
|
├── OAuth/ClientList.vue
|
||||||
|
├── Audit/AccessLog.vue
|
||||||
|
└── Audit/AuditLog.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行项目
|
||||||
|
|
||||||
|
### 启动后端
|
||||||
|
```bash
|
||||||
|
cd src/Fengling.AuthService
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动前端
|
||||||
|
```bash
|
||||||
|
cd src/Fengling.Console.Web
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问: http://localhost:5173
|
||||||
|
|
||||||
|
默认登录:
|
||||||
|
- 用户名: admin
|
||||||
|
- 密码: Admin@123
|
||||||
|
|
||||||
|
## 功能测试清单
|
||||||
|
|
||||||
|
- [ ] 登录页面正常显示
|
||||||
|
- [ ] 登录成功后跳转到仪表盘
|
||||||
|
- [ ] 侧边栏菜单正常显示
|
||||||
|
- [ ] 点击菜单项正确跳转
|
||||||
|
- [ ] 当前菜单项高亮显示
|
||||||
|
- [ ] 面包屑导航正确显示
|
||||||
|
- [ ] 各个管理页面正常加载
|
||||||
|
- [ ] 退出登录功能正常
|
||||||
|
|
||||||
|
## 页面路由
|
||||||
|
|
||||||
|
| 路径 | 名称 | 页面 |
|
||||||
|
|------|------|------|
|
||||||
|
| / | Dashboard | 仪表盘 |
|
||||||
|
| /users | UserList | 用户列表 |
|
||||||
|
| /roles | RoleList | 角色管理 |
|
||||||
|
| /tenants | TenantList | 租户管理 |
|
||||||
|
| /oauth/clients | OAuthClients | OAuth 应用 |
|
||||||
|
| /logs/access | AccessLog | 访问日志 |
|
||||||
|
| /logs/audit | AuditLog | 审计日志 |
|
||||||
@ -1,6 +1,6 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using OpenIddict.Validation.AspNetCore;
|
|
||||||
|
|
||||||
namespace Fengling.AuthService.Configuration;
|
namespace Fengling.AuthService.Configuration;
|
||||||
|
|
||||||
@ -11,42 +11,41 @@ public static class OpenIddictSetup
|
|||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
services
|
var isTesting = configuration.GetValue<bool>("Testing", false);
|
||||||
.AddOpenIddict()
|
|
||||||
.AddCore(options =>
|
|
||||||
{
|
|
||||||
options.UseEntityFrameworkCore().UseDbContext<Data.ApplicationDbContext>();
|
|
||||||
})
|
|
||||||
.AddServer(options =>
|
|
||||||
{
|
|
||||||
options.SetIssuer(
|
|
||||||
configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local"
|
|
||||||
);
|
|
||||||
|
|
||||||
options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate();
|
var builder = services.AddOpenIddict();
|
||||||
|
|
||||||
options
|
builder.AddCore(options =>
|
||||||
.AllowAuthorizationCodeFlow()
|
{
|
||||||
.AllowPasswordFlow()
|
options.UseEntityFrameworkCore().UseDbContext<Data.ApplicationDbContext>();
|
||||||
.AllowRefreshTokenFlow()
|
});
|
||||||
.RequireProofKeyForCodeExchange();
|
|
||||||
|
if (!isTesting)
|
||||||
|
{
|
||||||
|
builder.AddServer(options =>
|
||||||
|
{
|
||||||
|
options.SetIssuer(configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local");
|
||||||
|
|
||||||
|
options.AddDevelopmentEncryptionCertificate()
|
||||||
|
.AddDevelopmentSigningCertificate();
|
||||||
|
|
||||||
|
options.AllowAuthorizationCodeFlow()
|
||||||
|
.AllowPasswordFlow()
|
||||||
|
.AllowRefreshTokenFlow()
|
||||||
|
.RequireProofKeyForCodeExchange();
|
||||||
|
|
||||||
options.RegisterScopes("api", "offline_access");
|
options.RegisterScopes("api", "offline_access");
|
||||||
|
|
||||||
options.UseAspNetCore();
|
|
||||||
})
|
|
||||||
.AddValidation(options =>
|
|
||||||
{
|
|
||||||
options.UseLocalServer();
|
|
||||||
options.UseAspNetCore();
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AddValidation(options =>
|
||||||
|
{
|
||||||
|
options.UseLocalServer();
|
||||||
|
});
|
||||||
|
|
||||||
services.AddAuthentication(options =>
|
services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
options.DefaultAuthenticateScheme =
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
|
||||||
options.DefaultChallengeScheme =
|
|
||||||
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
158
src/Fengling.AuthService/Controllers/AccessLogsController.cs
Normal file
158
src/Fengling.AuthService/Controllers/AccessLogsController.cs
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class AccessLogsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly ILogger<AccessLogsController> _logger;
|
||||||
|
|
||||||
|
public AccessLogsController(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
ILogger<AccessLogsController> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<object>> GetAccessLogs(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? userName = null,
|
||||||
|
[FromQuery] string? tenantId = null,
|
||||||
|
[FromQuery] string? action = null,
|
||||||
|
[FromQuery] string? status = null,
|
||||||
|
[FromQuery] DateTime? startTime = null,
|
||||||
|
[FromQuery] DateTime? endTime = null)
|
||||||
|
{
|
||||||
|
var query = _context.AccessLogs.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(userName))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.UserName != null && l.UserName.Contains(userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.TenantId == tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(action))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Action == action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(status))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt >= startTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt <= endTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var logs = await query
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = logs.Select(l => new
|
||||||
|
{
|
||||||
|
id = l.Id,
|
||||||
|
userName = l.UserName,
|
||||||
|
tenantId = l.TenantId,
|
||||||
|
action = l.Action,
|
||||||
|
resource = l.Resource,
|
||||||
|
method = l.Method,
|
||||||
|
ipAddress = l.IpAddress,
|
||||||
|
userAgent = l.UserAgent,
|
||||||
|
status = l.Status,
|
||||||
|
duration = l.Duration,
|
||||||
|
requestData = l.RequestData,
|
||||||
|
responseData = l.ResponseData,
|
||||||
|
errorMessage = l.ErrorMessage,
|
||||||
|
createdAt = l.CreatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
items = result,
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export")]
|
||||||
|
public async Task<IActionResult> ExportAccessLogs(
|
||||||
|
[FromQuery] string? userName = null,
|
||||||
|
[FromQuery] string? tenantId = null,
|
||||||
|
[FromQuery] string? action = null,
|
||||||
|
[FromQuery] string? status = null,
|
||||||
|
[FromQuery] DateTime? startTime = null,
|
||||||
|
[FromQuery] DateTime? endTime = null)
|
||||||
|
{
|
||||||
|
var query = _context.AccessLogs.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(userName))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.UserName != null && l.UserName.Contains(userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.TenantId == tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(action))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Action == action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(status))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt >= startTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt <= endTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs = await query
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Take(10000)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var csv = new System.Text.StringBuilder();
|
||||||
|
csv.AppendLine("ID,UserName,TenantId,Action,Resource,Method,IpAddress,UserAgent,Status,Duration,CreatedAt");
|
||||||
|
|
||||||
|
foreach (var log in logs)
|
||||||
|
{
|
||||||
|
csv.AppendLine($"{log.Id},{log.UserName},{log.TenantId},{log.Action},{log.Resource},{log.Method},{log.IpAddress},\"{log.UserAgent}\",{log.Status},{log.Duration},{log.CreatedAt:yyyy-MM-dd HH:mm:ss}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"access-logs-{DateTime.UtcNow:yyyyMMdd}.csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/Fengling.AuthService/Controllers/AccountController.cs
Normal file
119
src/Fengling.AuthService/Controllers/AccountController.cs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Fengling.AuthService.ViewModels;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[Route("account")]
|
||||||
|
public class AccountController : Controller
|
||||||
|
{
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
private readonly ILogger<AccountController> _logger;
|
||||||
|
|
||||||
|
public AccountController(
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
SignInManager<ApplicationUser> signInManager,
|
||||||
|
ILogger<AccountController> logger)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_signInManager = signInManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("login")]
|
||||||
|
public IActionResult Login(string returnUrl = "/")
|
||||||
|
{
|
||||||
|
return View(new LoginInputModel { ReturnUrl = returnUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Login(LoginInputModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByNameAsync(model.Username);
|
||||||
|
if (user == null || user.IsDeleted)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, "用户名或密码错误");
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, false);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
if (result.IsLockedOut)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, "账号已被锁定");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, "用户名或密码错误");
|
||||||
|
}
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocalRedirect(model.ReturnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("register")]
|
||||||
|
public IActionResult Register(string returnUrl = "/")
|
||||||
|
{
|
||||||
|
return View(new RegisterViewModel { ReturnUrl = returnUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Register(RegisterViewModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = model.Username,
|
||||||
|
Email = model.Email,
|
||||||
|
NormalizedUserName = model.Username.ToUpper(),
|
||||||
|
NormalizedEmail = model.Email.ToUpper()
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _userManager.CreateAsync(user, model.Password);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
foreach (var error in result.Errors)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, error.Description);
|
||||||
|
}
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||||
|
return LocalRedirect(model.ReturnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("profile")]
|
||||||
|
[HttpGet("settings")]
|
||||||
|
[HttpGet("logout")]
|
||||||
|
public IActionResult NotImplemented()
|
||||||
|
{
|
||||||
|
return RedirectToAction("Index", "Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> LogoutPost()
|
||||||
|
{
|
||||||
|
await _signInManager.SignOutAsync();
|
||||||
|
return Redirect("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/Fengling.AuthService/Controllers/AuditLogsController.cs
Normal file
159
src/Fengling.AuthService/Controllers/AuditLogsController.cs
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class AuditLogsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly ILogger<AuditLogsController> _logger;
|
||||||
|
|
||||||
|
public AuditLogsController(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
ILogger<AuditLogsController> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<object>> GetAuditLogs(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? operatorName = null,
|
||||||
|
[FromQuery] string? tenantId = null,
|
||||||
|
[FromQuery] string? operation = null,
|
||||||
|
[FromQuery] string? action = null,
|
||||||
|
[FromQuery] DateTime? startTime = null,
|
||||||
|
[FromQuery] DateTime? endTime = null)
|
||||||
|
{
|
||||||
|
var query = _context.AuditLogs.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(operatorName))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Operator.Contains(operatorName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.TenantId == tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(operation))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Operation == operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(action))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Action == action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt >= startTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt <= endTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var logs = await query
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = logs.Select(l => new
|
||||||
|
{
|
||||||
|
id = l.Id,
|
||||||
|
@operator = l.Operator,
|
||||||
|
tenantId = l.TenantId,
|
||||||
|
operation = l.Operation,
|
||||||
|
action = l.Action,
|
||||||
|
targetType = l.TargetType,
|
||||||
|
targetId = l.TargetId,
|
||||||
|
targetName = l.TargetName,
|
||||||
|
ipAddress = l.IpAddress,
|
||||||
|
description = l.Description,
|
||||||
|
oldValue = l.OldValue,
|
||||||
|
newValue = l.NewValue,
|
||||||
|
errorMessage = l.ErrorMessage,
|
||||||
|
status = l.Status,
|
||||||
|
createdAt = l.CreatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
items = result,
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export")]
|
||||||
|
public async Task<IActionResult> ExportAuditLogs(
|
||||||
|
[FromQuery] string? operatorName = null,
|
||||||
|
[FromQuery] string? tenantId = null,
|
||||||
|
[FromQuery] string? operation = null,
|
||||||
|
[FromQuery] string? action = null,
|
||||||
|
[FromQuery] DateTime? startTime = null,
|
||||||
|
[FromQuery] DateTime? endTime = null)
|
||||||
|
{
|
||||||
|
var query = _context.AuditLogs.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(operatorName))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Operator.Contains(operatorName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.TenantId == tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(operation))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Operation == operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(action))
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.Action == action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt >= startTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endTime.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.CreatedAt <= endTime.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs = await query
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Take(10000)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var csv = new System.Text.StringBuilder();
|
||||||
|
csv.AppendLine("ID,Operator,TenantId,Operation,Action,TargetType,TargetId,TargetName,IpAddress,Description,Status,CreatedAt");
|
||||||
|
|
||||||
|
foreach (var log in logs)
|
||||||
|
{
|
||||||
|
csv.AppendLine($"{log.Id},{log.Operator},{log.TenantId},{log.Operation},{log.Action},{log.TargetType},{log.TargetId},{log.TargetName},{log.IpAddress},\"{log.Description}\",{log.Status},{log.CreatedAt:yyyy-MM-dd HH:mm:ss}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"audit-logs-{DateTime.UtcNow:yyyyMMdd}.csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,90 +0,0 @@
|
|||||||
using Fengling.AuthService.DTOs;
|
|
||||||
using Fengling.AuthService.Models;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using OpenIddict.Abstractions;
|
|
||||||
using OpenIddict.Server.AspNetCore;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
|
||||||
|
|
||||||
namespace Fengling.AuthService.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class AuthController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
|
||||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
|
||||||
private readonly IOpenIddictAuthorizationManager _authorizationManager;
|
|
||||||
private readonly IOpenIddictScopeManager _scopeManager;
|
|
||||||
private readonly ILogger<AuthController> _logger;
|
|
||||||
|
|
||||||
public AuthController(
|
|
||||||
SignInManager<ApplicationUser> signInManager,
|
|
||||||
UserManager<ApplicationUser> userManager,
|
|
||||||
IOpenIddictApplicationManager applicationManager,
|
|
||||||
IOpenIddictAuthorizationManager authorizationManager,
|
|
||||||
IOpenIddictScopeManager scopeManager,
|
|
||||||
ILogger<AuthController> logger)
|
|
||||||
{
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_userManager = userManager;
|
|
||||||
_applicationManager = applicationManager;
|
|
||||||
_authorizationManager = authorizationManager;
|
|
||||||
_scopeManager = scopeManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("login")]
|
|
||||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
|
||||||
{
|
|
||||||
var user = await _userManager.FindByNameAsync(request.UserName);
|
|
||||||
if (user == null || user.IsDeleted)
|
|
||||||
{
|
|
||||||
return Unauthorized(new { error = "用户不存在" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.TenantId != request.TenantId)
|
|
||||||
{
|
|
||||||
return Unauthorized(new { error = "租户不匹配" });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false);
|
|
||||||
if (!result.Succeeded)
|
|
||||||
{
|
|
||||||
return Unauthorized(new { error = "用户名或密码错误" });
|
|
||||||
}
|
|
||||||
|
|
||||||
var token = await GenerateTokenAsync(user);
|
|
||||||
return Ok(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<LoginResponse> GenerateTokenAsync(ApplicationUser user)
|
|
||||||
{
|
|
||||||
var claims = new List<System.Security.Claims.Claim>
|
|
||||||
{
|
|
||||||
new(Claims.Subject, user.Id.ToString()),
|
|
||||||
new(Claims.Name, user.UserName ?? string.Empty),
|
|
||||||
new(Claims.Email, user.Email ?? string.Empty),
|
|
||||||
new("tenant_id", user.TenantId.ToString())
|
|
||||||
};
|
|
||||||
|
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
|
||||||
foreach (var role in roles)
|
|
||||||
{
|
|
||||||
claims.Add(new Claim(Claims.Role, role));
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = new System.Security.Claims.ClaimsIdentity(claims, "Server");
|
|
||||||
var principal = new System.Security.Claims.ClaimsPrincipal(identity);
|
|
||||||
|
|
||||||
return new LoginResponse
|
|
||||||
{
|
|
||||||
AccessToken = "token-placeholder",
|
|
||||||
RefreshToken = "refresh-placeholder",
|
|
||||||
ExpiresIn = 3600,
|
|
||||||
TokenType = "Bearer"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
217
src/Fengling.AuthService/Controllers/AuthorizationController.cs
Normal file
217
src/Fengling.AuthService/Controllers/AuthorizationController.cs
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using OpenIddict.Abstractions;
|
||||||
|
using OpenIddict.Server.AspNetCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Fengling.AuthService.ViewModels;
|
||||||
|
using Microsoft.AspNetCore;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("connect")]
|
||||||
|
public class AuthorizationController(
|
||||||
|
IOpenIddictApplicationManager applicationManager,
|
||||||
|
IOpenIddictAuthorizationManager authorizationManager,
|
||||||
|
IOpenIddictScopeManager scopeManager,
|
||||||
|
SignInManager<ApplicationUser> signInManager,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
ILogger<AuthorizationController> logger)
|
||||||
|
: Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
[HttpGet("authorize")]
|
||||||
|
[HttpPost("authorize")]
|
||||||
|
public async Task<IActionResult> Authorize()
|
||||||
|
{
|
||||||
|
var request = HttpContext.GetOpenIddictServerRequest() ??
|
||||||
|
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
|
||||||
|
|
||||||
|
// If prompt=login was specified by the client application,
|
||||||
|
// immediately return the user agent to the login page.
|
||||||
|
if (request.HasPromptValue(OpenIddictConstants.PromptValues.Login))
|
||||||
|
{
|
||||||
|
// To avoid endless login -> authorization redirects, the prompt=login flag
|
||||||
|
// is removed from the authorization request payload before redirecting the user.
|
||||||
|
var prompt = string.Join(" ", request.GetPromptValues().Remove(OpenIddictConstants.PromptValues.Login));
|
||||||
|
|
||||||
|
var parameters = Request.HasFormContentType
|
||||||
|
? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList()
|
||||||
|
: Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList();
|
||||||
|
|
||||||
|
parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt)));
|
||||||
|
|
||||||
|
return Challenge(
|
||||||
|
authenticationSchemes: IdentityConstants.ApplicationScheme,
|
||||||
|
properties: new AuthenticationProperties
|
||||||
|
{
|
||||||
|
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the user principal stored in the authentication cookie.
|
||||||
|
// If a max_age parameter was provided, ensure that the cookie is not too old.
|
||||||
|
// If the user principal can't be extracted or the cookie is too old, redirect the user to the login page.
|
||||||
|
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
|
||||||
|
if (result is not { Succeeded: true } || (request.MaxAge != null && result.Properties?.IssuedUtc != null &&
|
||||||
|
DateTimeOffset.UtcNow - result.Properties.IssuedUtc >
|
||||||
|
TimeSpan.FromSeconds(request.MaxAge.Value)))
|
||||||
|
{
|
||||||
|
// If the client application requested promptless authentication,
|
||||||
|
// return an error indicating that the user is not logged in.
|
||||||
|
if (request.HasPromptValue(OpenIddictConstants.PromptValues.None))
|
||||||
|
{
|
||||||
|
return Forbid(
|
||||||
|
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||||
|
properties: new AuthenticationProperties(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.Error] =
|
||||||
|
OpenIddictConstants.Errors.LoginRequired,
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in."
|
||||||
|
}!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Challenge(
|
||||||
|
authenticationSchemes: IdentityConstants.ApplicationScheme,
|
||||||
|
properties: new AuthenticationProperties
|
||||||
|
{
|
||||||
|
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
|
||||||
|
Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the profile of the logged in user.
|
||||||
|
var user = await userManager.GetUserAsync(result.Principal) ??
|
||||||
|
throw new InvalidOperationException("The user details cannot be retrieved.");
|
||||||
|
|
||||||
|
// Retrieve the application details from the database.
|
||||||
|
var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ??
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Details concerning the calling client application cannot be found.");
|
||||||
|
|
||||||
|
// Retrieve the permanent authorizations associated with the user and the calling client application.
|
||||||
|
var authorizations = await authorizationManager.FindAsync(
|
||||||
|
subject: await userManager.GetUserIdAsync(user),
|
||||||
|
client: (await applicationManager.GetIdAsync(application))!,
|
||||||
|
status: OpenIddictConstants.Statuses.Valid,
|
||||||
|
type: OpenIddictConstants.AuthorizationTypes.Permanent,
|
||||||
|
scopes: request.GetScopes()).ToListAsync();
|
||||||
|
|
||||||
|
switch (await applicationManager.GetConsentTypeAsync(application))
|
||||||
|
{
|
||||||
|
// If the consent is external (e.g when authorizations are granted by a sysadmin),
|
||||||
|
// immediately return an error if no authorization can be found in the database.
|
||||||
|
case OpenIddictConstants.ConsentTypes.External when !authorizations.Any():
|
||||||
|
return Forbid(
|
||||||
|
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||||
|
properties: new AuthenticationProperties(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.Error] =
|
||||||
|
OpenIddictConstants.Errors.ConsentRequired,
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
|
||||||
|
"The logged in user is not allowed to access this client application."
|
||||||
|
}!));
|
||||||
|
|
||||||
|
// If the consent is implicit or if an authorization was found,
|
||||||
|
// return an authorization response without displaying the consent form.
|
||||||
|
case OpenIddictConstants.ConsentTypes.Implicit:
|
||||||
|
case OpenIddictConstants.ConsentTypes.External when authorizations.Any():
|
||||||
|
case OpenIddictConstants.ConsentTypes.Explicit
|
||||||
|
when authorizations.Any() && !request.HasPromptValue(OpenIddictConstants.PromptValues.Consent):
|
||||||
|
var principal = await signInManager.CreateUserPrincipalAsync(user);
|
||||||
|
|
||||||
|
// Note: in this sample, the granted scopes match the requested scope
|
||||||
|
// but you may want to allow the user to uncheck specific scopes.
|
||||||
|
// For that, simply restrict the list of scopes before calling SetScopes.
|
||||||
|
principal.SetScopes(request.GetScopes());
|
||||||
|
principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());
|
||||||
|
|
||||||
|
// Automatically create a permanent authorization to avoid requiring explicit consent
|
||||||
|
// for future authorization or token requests containing the same scopes.
|
||||||
|
var authorization = authorizations.LastOrDefault();
|
||||||
|
if (authorization == null)
|
||||||
|
{
|
||||||
|
authorization = await authorizationManager.CreateAsync(
|
||||||
|
principal: principal,
|
||||||
|
subject: await userManager.GetUserIdAsync(user),
|
||||||
|
client: (await applicationManager.GetIdAsync(application))!,
|
||||||
|
type: OpenIddictConstants.AuthorizationTypes.Permanent,
|
||||||
|
scopes: principal.GetScopes());
|
||||||
|
}
|
||||||
|
|
||||||
|
principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization));
|
||||||
|
|
||||||
|
foreach (var claim in principal.Claims)
|
||||||
|
{
|
||||||
|
claim.SetDestinations(GetDestinations(claim, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
// At this point, no authorization was found in the database and an error must be returned
|
||||||
|
// if the client application specified prompt=none in the authorization request.
|
||||||
|
case OpenIddictConstants.ConsentTypes.Explicit when request.HasPromptValue(OpenIddictConstants.PromptValues.None):
|
||||||
|
case OpenIddictConstants.ConsentTypes.Systematic when request.HasPromptValue(OpenIddictConstants.PromptValues.None):
|
||||||
|
return Forbid(
|
||||||
|
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||||
|
properties: new AuthenticationProperties(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.Error] =
|
||||||
|
OpenIddictConstants.Errors.ConsentRequired,
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
|
||||||
|
"Interactive user consent is required."
|
||||||
|
}!));
|
||||||
|
|
||||||
|
// In every other case, render the consent form.
|
||||||
|
default:
|
||||||
|
return View(new AuthorizeViewModel(await applicationManager.GetDisplayNameAsync(application),request.Scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetDestinations(Claim claim, ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
// Note: by default, claims are NOT automatically included in the access and identity tokens.
|
||||||
|
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
|
||||||
|
// whether they should be included in access tokens, in identity tokens or in both.
|
||||||
|
|
||||||
|
switch (claim.Type)
|
||||||
|
{
|
||||||
|
case OpenIddictConstants.Claims.Name:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
|
||||||
|
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Profile))
|
||||||
|
yield return OpenIddictConstants.Destinations.IdentityToken;
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
case OpenIddictConstants.Claims.Email:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
|
||||||
|
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Email))
|
||||||
|
yield return OpenIddictConstants.Destinations.IdentityToken;
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
case OpenIddictConstants.Claims.Role:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
|
||||||
|
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Roles))
|
||||||
|
yield return OpenIddictConstants.Destinations.IdentityToken;
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// Never include the security stamp in the access and identity tokens, as it's a secret value.
|
||||||
|
case "AspNet.Identity.SecurityStamp": yield break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/Fengling.AuthService/Controllers/DashboardController.cs
Normal file
61
src/Fengling.AuthService/Controllers/DashboardController.cs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
using Fengling.AuthService.ViewModels;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[Route("dashboard")]
|
||||||
|
public class DashboardController : Controller
|
||||||
|
{
|
||||||
|
private readonly ILogger<DashboardController> _logger;
|
||||||
|
|
||||||
|
public DashboardController(ILogger<DashboardController> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("")]
|
||||||
|
[HttpGet("index")]
|
||||||
|
public IActionResult Index()
|
||||||
|
{
|
||||||
|
if (User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return View("Index", new DashboardViewModel
|
||||||
|
{
|
||||||
|
Username = User.Identity?.Name,
|
||||||
|
Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("profile")]
|
||||||
|
public IActionResult Profile()
|
||||||
|
{
|
||||||
|
if (User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard/profile" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(new DashboardViewModel
|
||||||
|
{
|
||||||
|
Username = User.Identity?.Name,
|
||||||
|
Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public IActionResult Settings()
|
||||||
|
{
|
||||||
|
if (User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard/settings" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(new DashboardViewModel
|
||||||
|
{
|
||||||
|
Username = User.Identity?.Name,
|
||||||
|
Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/Fengling.AuthService/Controllers/LogoutController.cs
Normal file
71
src/Fengling.AuthService/Controllers/LogoutController.cs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using OpenIddict.Abstractions;
|
||||||
|
using OpenIddict.Server.AspNetCore;
|
||||||
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("connect")]
|
||||||
|
public class LogoutController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||||
|
private readonly IOpenIddictAuthorizationManager _authorizationManager;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
private readonly ILogger<LogoutController> _logger;
|
||||||
|
|
||||||
|
public LogoutController(
|
||||||
|
IOpenIddictApplicationManager applicationManager,
|
||||||
|
IOpenIddictAuthorizationManager authorizationManager,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
SignInManager<ApplicationUser> signInManager,
|
||||||
|
ILogger<LogoutController> logger)
|
||||||
|
{
|
||||||
|
_applicationManager = applicationManager;
|
||||||
|
_authorizationManager = authorizationManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_signInManager = signInManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("endsession")]
|
||||||
|
[HttpPost("endsession")]
|
||||||
|
[IgnoreAntiforgeryToken]
|
||||||
|
public async Task<IActionResult> EndSession()
|
||||||
|
{
|
||||||
|
var request = HttpContext.GetOpenIddictServerRequest() ??
|
||||||
|
throw new InvalidOperationException("OpenIddict request is null");
|
||||||
|
|
||||||
|
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
await _signInManager.SignOutAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ClientId != null)
|
||||||
|
{
|
||||||
|
var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
|
||||||
|
if (application != null)
|
||||||
|
{
|
||||||
|
var postLogoutRedirectUri = await _applicationManager.GetPostLogoutRedirectUrisAsync(application);
|
||||||
|
if (!string.IsNullOrEmpty(request.PostLogoutRedirectUri))
|
||||||
|
{
|
||||||
|
if (postLogoutRedirectUri.Contains(request.PostLogoutRedirectUri))
|
||||||
|
{
|
||||||
|
return Redirect(request.PostLogoutRedirectUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return Redirect("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,15 @@
|
|||||||
using Fengling.AuthService.Data;
|
using Fengling.AuthService.Data;
|
||||||
using Fengling.AuthService.Models;
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Fengling.AuthService.Controllers;
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
public class OAuthClientsController : ControllerBase
|
public class OAuthClientsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
@ -21,9 +24,60 @@ public class OAuthClientsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<IEnumerable<OAuthApplication>>> GetClients()
|
public async Task<ActionResult<object>> GetClients(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 10,
|
||||||
|
[FromQuery] string? displayName = null,
|
||||||
|
[FromQuery] string? clientId = null,
|
||||||
|
[FromQuery] string? status = null)
|
||||||
{
|
{
|
||||||
return await _context.OAuthApplications.ToListAsync();
|
var query = _context.OAuthApplications.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(displayName))
|
||||||
|
{
|
||||||
|
query = query.Where(c => c.DisplayName.Contains(displayName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(clientId))
|
||||||
|
{
|
||||||
|
query = query.Where(c => c.ClientId.Contains(clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(status))
|
||||||
|
{
|
||||||
|
query = query.Where(c => c.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var clients = await query
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = clients.Select(c => new
|
||||||
|
{
|
||||||
|
id = c.Id,
|
||||||
|
clientId = c.ClientId,
|
||||||
|
displayName = c.DisplayName,
|
||||||
|
redirectUris = c.RedirectUris,
|
||||||
|
postLogoutRedirectUris = c.PostLogoutRedirectUris,
|
||||||
|
scopes = c.Scopes,
|
||||||
|
grantTypes = c.GrantTypes,
|
||||||
|
clientType = c.ClientType,
|
||||||
|
consentType = c.ConsentType,
|
||||||
|
status = c.Status,
|
||||||
|
description = c.Description,
|
||||||
|
createdAt = c.CreatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
items = result,
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -34,27 +88,97 @@ public class OAuthClientsController : ControllerBase
|
|||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
return client;
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
id = client.Id,
|
||||||
|
clientId = client.ClientId,
|
||||||
|
displayName = client.DisplayName,
|
||||||
|
redirectUris = client.RedirectUris,
|
||||||
|
postLogoutRedirectUris = client.PostLogoutRedirectUris,
|
||||||
|
scopes = client.Scopes,
|
||||||
|
grantTypes = client.GrantTypes,
|
||||||
|
clientType = client.ClientType,
|
||||||
|
consentType = client.ConsentType,
|
||||||
|
status = client.Status,
|
||||||
|
description = client.Description,
|
||||||
|
createdAt = client.CreatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/secret")]
|
||||||
|
public async Task<ActionResult<object>> GetClientSecret(long id)
|
||||||
|
{
|
||||||
|
var client = await _context.OAuthApplications.FindAsync(id);
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
clientId = client.ClientId,
|
||||||
|
clientSecret = client.ClientSecret,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<OAuthApplication>> CreateClient(OAuthApplication application)
|
public async Task<ActionResult<OAuthApplication>> CreateClient(CreateOAuthClientDto dto)
|
||||||
{
|
{
|
||||||
_context.OAuthApplications.Add(application);
|
if (await _context.OAuthApplications.AnyAsync(c => c.ClientId == dto.ClientId))
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = "Client ID 已存在" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = new OAuthApplication
|
||||||
|
{
|
||||||
|
ClientId = dto.ClientId,
|
||||||
|
ClientSecret = dto.ClientSecret,
|
||||||
|
DisplayName = dto.DisplayName,
|
||||||
|
RedirectUris = dto.RedirectUris,
|
||||||
|
PostLogoutRedirectUris = dto.PostLogoutRedirectUris,
|
||||||
|
Scopes = dto.Scopes,
|
||||||
|
GrantTypes = dto.GrantTypes,
|
||||||
|
ClientType = dto.ClientType,
|
||||||
|
ConsentType = dto.ConsentType,
|
||||||
|
Status = dto.Status,
|
||||||
|
Description = dto.Description,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.OAuthApplications.Add(client);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
return CreatedAtAction(nameof(GetClient), new { id = application.Id }, application);
|
|
||||||
|
await CreateAuditLog("oauth", "create", "OAuthClient", client.Id, client.DisplayName, null, SerializeToJson(dto));
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetClient), new { id = client.Id }, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> UpdateClient(long id, OAuthApplication application)
|
public async Task<IActionResult> UpdateClient(long id, UpdateOAuthClientDto dto)
|
||||||
{
|
{
|
||||||
if (id != application.Id)
|
var client = await _context.OAuthApplications.FindAsync(id);
|
||||||
|
if (client == null)
|
||||||
{
|
{
|
||||||
return BadRequest();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
_context.Entry(application).State = EntityState.Modified;
|
var oldValue = SerializeToJson(client);
|
||||||
|
|
||||||
|
client.DisplayName = dto.DisplayName;
|
||||||
|
client.RedirectUris = dto.RedirectUris;
|
||||||
|
client.PostLogoutRedirectUris = dto.PostLogoutRedirectUris;
|
||||||
|
client.Scopes = dto.Scopes;
|
||||||
|
client.GrantTypes = dto.GrantTypes;
|
||||||
|
client.ClientType = dto.ClientType;
|
||||||
|
client.ConsentType = dto.ConsentType;
|
||||||
|
client.Status = dto.Status;
|
||||||
|
client.Description = dto.Description;
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("oauth", "update", "OAuthClient", client.Id, client.DisplayName, oldValue, SerializeToJson(client));
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,8 +191,73 @@ public class OAuthClientsController : ControllerBase
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var oldValue = SerializeToJson(client);
|
||||||
|
|
||||||
_context.OAuthApplications.Remove(client);
|
_context.OAuthApplications.Remove(client);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("oauth", "delete", "OAuthClient", client.Id, client.DisplayName, oldValue);
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null)
|
||||||
|
{
|
||||||
|
var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system";
|
||||||
|
var tenantId = User.FindFirstValue("TenantId");
|
||||||
|
|
||||||
|
var log = new AuditLog
|
||||||
|
{
|
||||||
|
Operator = userName,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Operation = operation,
|
||||||
|
Action = action,
|
||||||
|
TargetType = targetType,
|
||||||
|
TargetId = targetId,
|
||||||
|
TargetName = targetName,
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
Status = "success",
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = newValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.AuditLogs.Add(log);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SerializeToJson(object obj)
|
||||||
|
{
|
||||||
|
return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateOAuthClientDto
|
||||||
|
{
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string[] RedirectUris { get; set; } = Array.Empty<string>();
|
||||||
|
public string[] PostLogoutRedirectUris { get; set; } = Array.Empty<string>();
|
||||||
|
public string[] Scopes { get; set; } = Array.Empty<string>();
|
||||||
|
public string[] GrantTypes { get; set; } = Array.Empty<string>();
|
||||||
|
public string ClientType { get; set; } = "confidential";
|
||||||
|
public string ConsentType { get; set; } = "implicit";
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateOAuthClientDto
|
||||||
|
{
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string[] RedirectUris { get; set; } = Array.Empty<string>();
|
||||||
|
public string[] PostLogoutRedirectUris { get; set; } = Array.Empty<string>();
|
||||||
|
public string[] Scopes { get; set; } = Array.Empty<string>();
|
||||||
|
public string[] GrantTypes { get; set; } = Array.Empty<string>();
|
||||||
|
public string ClientType { get; set; } = "confidential";
|
||||||
|
public string ConsentType { get; set; } = "implicit";
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public string? Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
290
src/Fengling.AuthService/Controllers/RolesController.cs
Normal file
290
src/Fengling.AuthService/Controllers/RolesController.cs
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class RolesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly RoleManager<ApplicationRole> _roleManager;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly ILogger<RolesController> _logger;
|
||||||
|
|
||||||
|
public RolesController(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
RoleManager<ApplicationRole> roleManager,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
ILogger<RolesController> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_roleManager = roleManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<object>> GetRoles(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 10,
|
||||||
|
[FromQuery] string? name = null,
|
||||||
|
[FromQuery] string? tenantId = null)
|
||||||
|
{
|
||||||
|
var query = _context.Roles.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
query = query.Where(r => r.Name != null && r.Name.Contains(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
query = query.Where(r => r.TenantId.ToString() == tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var roles = await query
|
||||||
|
.OrderByDescending(r => r.CreatedTime)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new List<object>();
|
||||||
|
|
||||||
|
foreach (var role in roles)
|
||||||
|
{
|
||||||
|
var users = await _userManager.GetUsersInRoleAsync(role.Name!);
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
id = role.Id,
|
||||||
|
name = role.Name,
|
||||||
|
displayName = role.DisplayName,
|
||||||
|
description = role.Description,
|
||||||
|
tenantId = role.TenantId,
|
||||||
|
isSystem = role.IsSystem,
|
||||||
|
permissions = role.Permissions,
|
||||||
|
userCount = users.Count,
|
||||||
|
createdAt = role.CreatedTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
items = result,
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<ApplicationRole>> GetRole(long id)
|
||||||
|
{
|
||||||
|
var role = await _context.Roles.FindAsync(id);
|
||||||
|
if (role == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
id = role.Id,
|
||||||
|
name = role.Name,
|
||||||
|
displayName = role.DisplayName,
|
||||||
|
description = role.Description,
|
||||||
|
tenantId = role.TenantId,
|
||||||
|
isSystem = role.IsSystem,
|
||||||
|
permissions = role.Permissions,
|
||||||
|
createdAt = role.CreatedTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/users")]
|
||||||
|
public async Task<ActionResult<List<object>>> GetRoleUsers(long id)
|
||||||
|
{
|
||||||
|
var role = await _context.Roles.FindAsync(id);
|
||||||
|
if (role == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = await _userManager.GetUsersInRoleAsync(role.Name!);
|
||||||
|
|
||||||
|
var result = users.Select(async u => new
|
||||||
|
{
|
||||||
|
id = u.Id,
|
||||||
|
userName = u.UserName,
|
||||||
|
email = u.Email,
|
||||||
|
realName = u.RealName,
|
||||||
|
tenantId = u.TenantId,
|
||||||
|
roles = await _userManager.GetRolesAsync(u),
|
||||||
|
isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow,
|
||||||
|
createdAt = u.CreatedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(await Task.WhenAll(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<ApplicationRole>> CreateRole(CreateRoleDto dto)
|
||||||
|
{
|
||||||
|
var role = new ApplicationRole
|
||||||
|
{
|
||||||
|
Name = dto.Name,
|
||||||
|
DisplayName = dto.DisplayName,
|
||||||
|
Description = dto.Description,
|
||||||
|
TenantId = dto.TenantId,
|
||||||
|
Permissions = dto.Permissions,
|
||||||
|
IsSystem = false,
|
||||||
|
CreatedTime = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _roleManager.CreateAsync(role);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateAuditLog("role", "create", "Role", role.Id, role.DisplayName, null, SerializeToJson(dto));
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetRole), new { id = role.Id }, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> UpdateRole(long id, UpdateRoleDto dto)
|
||||||
|
{
|
||||||
|
var role = await _context.Roles.FindAsync(id);
|
||||||
|
if (role == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.IsSystem)
|
||||||
|
{
|
||||||
|
return BadRequest("系统角色不能修改");
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldValue = System.Text.Json.JsonSerializer.Serialize(role);
|
||||||
|
|
||||||
|
role.DisplayName = dto.DisplayName;
|
||||||
|
role.Description = dto.Description;
|
||||||
|
role.Permissions = dto.Permissions;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("role", "update", "Role", role.Id, role.DisplayName, oldValue, System.Text.Json.JsonSerializer.Serialize(role));
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> DeleteRole(long id)
|
||||||
|
{
|
||||||
|
var role = await _context.Roles.FindAsync(id);
|
||||||
|
if (role == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.IsSystem)
|
||||||
|
{
|
||||||
|
return BadRequest("系统角色不能删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldValue = System.Text.Json.JsonSerializer.Serialize(role);
|
||||||
|
var users = await _userManager.GetUsersInRoleAsync(role.Name!);
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
await _userManager.RemoveFromRoleAsync(user, role.Name!);
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.Roles.Remove(role);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("role", "delete", "Role", role.Id, role.DisplayName, oldValue);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}/users/{userId}")]
|
||||||
|
public async Task<IActionResult> RemoveUserFromRole(long id, long userId)
|
||||||
|
{
|
||||||
|
var role = await _context.Roles.FindAsync(id);
|
||||||
|
if (role == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userManager.RemoveFromRoleAsync(user, role.Name!);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateAuditLog("role", "update", "UserRole", null, $"{role.Name} - {user.UserName}");
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null)
|
||||||
|
{
|
||||||
|
var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system";
|
||||||
|
var tenantId = User.FindFirstValue("TenantId");
|
||||||
|
|
||||||
|
var log = new AuditLog
|
||||||
|
{
|
||||||
|
Operator = userName,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Operation = operation,
|
||||||
|
Action = action,
|
||||||
|
TargetType = targetType,
|
||||||
|
TargetId = targetId,
|
||||||
|
TargetName = targetName,
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
Status = "success",
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = newValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.AuditLogs.Add(log);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SerializeToJson(object obj)
|
||||||
|
{
|
||||||
|
return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateRoleDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public long? TenantId { get; set; }
|
||||||
|
public List<string> Permissions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateRoleDto
|
||||||
|
{
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public List<string> Permissions { get; set; } = new();
|
||||||
|
}
|
||||||
62
src/Fengling.AuthService/Controllers/StatsController.cs
Normal file
62
src/Fengling.AuthService/Controllers/StatsController.cs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class StatsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly ILogger<StatsController> _logger;
|
||||||
|
|
||||||
|
public StatsController(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
ILogger<StatsController> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("dashboard")]
|
||||||
|
public async Task<ActionResult<object>> GetDashboardStats()
|
||||||
|
{
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
var tomorrow = today.AddDays(1);
|
||||||
|
|
||||||
|
var userCount = await _context.Users.CountAsync(u => !u.IsDeleted);
|
||||||
|
var tenantCount = await _context.Tenants.CountAsync(t => !t.IsDeleted);
|
||||||
|
var oauthClientCount = await _context.OAuthApplications.CountAsync();
|
||||||
|
var todayAccessCount = await _context.AccessLogs
|
||||||
|
.CountAsync(l => l.CreatedAt >= today && l.CreatedAt < tomorrow);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
userCount,
|
||||||
|
tenantCount,
|
||||||
|
oauthClientCount,
|
||||||
|
todayAccessCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("system")]
|
||||||
|
public ActionResult<object> GetSystemStats()
|
||||||
|
{
|
||||||
|
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
|
||||||
|
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
uptime = $"{uptime.Days}天 {uptime.Hours}小时 {uptime.Minutes}分钟",
|
||||||
|
memoryUsed = process.WorkingSet64 / 1024 / 1024,
|
||||||
|
cpuTime = process.TotalProcessorTime,
|
||||||
|
machineName = Environment.MachineName,
|
||||||
|
osVersion = Environment.OSVersion.ToString(),
|
||||||
|
processorCount = Environment.ProcessorCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
344
src/Fengling.AuthService/Controllers/TenantsController.cs
Normal file
344
src/Fengling.AuthService/Controllers/TenantsController.cs
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class TenantsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly ILogger<TenantsController> _logger;
|
||||||
|
|
||||||
|
public TenantsController(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
ILogger<TenantsController> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_userManager = userManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<object>> GetTenants(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 10,
|
||||||
|
[FromQuery] string? name = null,
|
||||||
|
[FromQuery] string? tenantId = null,
|
||||||
|
[FromQuery] string? status = null)
|
||||||
|
{
|
||||||
|
var query = _context.Tenants.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
query = query.Where(t => t.Name.Contains(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
query = query.Where(t => t.TenantId.Contains(tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(status))
|
||||||
|
{
|
||||||
|
query = query.Where(t => t.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var tenants = await query
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new List<object>();
|
||||||
|
|
||||||
|
foreach (var tenant in tenants)
|
||||||
|
{
|
||||||
|
var userCount = await _context.Users.CountAsync(u => u.TenantId == tenant.Id && !u.IsDeleted);
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
id = tenant.Id,
|
||||||
|
tenantId = tenant.TenantId,
|
||||||
|
name = tenant.Name,
|
||||||
|
contactName = tenant.ContactName,
|
||||||
|
contactEmail = tenant.ContactEmail,
|
||||||
|
contactPhone = tenant.ContactPhone,
|
||||||
|
maxUsers = tenant.MaxUsers,
|
||||||
|
userCount,
|
||||||
|
status = tenant.Status,
|
||||||
|
expiresAt = tenant.ExpiresAt,
|
||||||
|
description = tenant.Description,
|
||||||
|
createdAt = tenant.CreatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
items = result,
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<Tenant>> GetTenant(long id)
|
||||||
|
{
|
||||||
|
var tenant = await _context.Tenants.FindAsync(id);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
id = tenant.Id,
|
||||||
|
tenantId = tenant.TenantId,
|
||||||
|
name = tenant.Name,
|
||||||
|
contactName = tenant.ContactName,
|
||||||
|
contactEmail = tenant.ContactEmail,
|
||||||
|
contactPhone = tenant.ContactPhone,
|
||||||
|
maxUsers = tenant.MaxUsers,
|
||||||
|
status = tenant.Status,
|
||||||
|
expiresAt = tenant.ExpiresAt,
|
||||||
|
description = tenant.Description,
|
||||||
|
createdAt = tenant.CreatedAt,
|
||||||
|
updatedAt = tenant.UpdatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{tenantId}/users")]
|
||||||
|
public async Task<ActionResult<List<object>>> GetTenantUsers(string tenantId)
|
||||||
|
{
|
||||||
|
var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = await _context.Users
|
||||||
|
.Where(u => u.TenantId == tenant.Id && !u.IsDeleted)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = users.Select(async u => new
|
||||||
|
{
|
||||||
|
id = u.Id,
|
||||||
|
userName = u.UserName,
|
||||||
|
email = u.Email,
|
||||||
|
realName = u.RealName,
|
||||||
|
tenantId = u.TenantId,
|
||||||
|
roles = await _userManager.GetRolesAsync(u),
|
||||||
|
isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow,
|
||||||
|
createdAt = u.CreatedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(await Task.WhenAll(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{tenantId}/roles")]
|
||||||
|
public async Task<ActionResult<List<object>>> GetTenantRoles(string tenantId)
|
||||||
|
{
|
||||||
|
var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles = await _context.Roles
|
||||||
|
.Where(r => r.TenantId == tenant.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = roles.Select(r => new
|
||||||
|
{
|
||||||
|
id = r.Id,
|
||||||
|
name = r.Name,
|
||||||
|
displayName = r.DisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{tenantId}/settings")]
|
||||||
|
public async Task<ActionResult<TenantSettings>> GetTenantSettings(string tenantId)
|
||||||
|
{
|
||||||
|
var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = new TenantSettings
|
||||||
|
{
|
||||||
|
AllowRegistration = false,
|
||||||
|
AllowedEmailDomains = "",
|
||||||
|
DefaultRoleId = null,
|
||||||
|
PasswordPolicy = new List<string> { "requireNumber", "requireLowercase" },
|
||||||
|
MinPasswordLength = 8,
|
||||||
|
SessionTimeout = 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{tenantId}/settings")]
|
||||||
|
public async Task<IActionResult> UpdateTenantSettings(string tenantId, TenantSettings settings)
|
||||||
|
{
|
||||||
|
var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateAuditLog("tenant", "update", "TenantSettings", tenant.Id, tenant.TenantId, null, JsonSerializer.Serialize(settings));
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<Tenant>> CreateTenant(CreateTenantDto dto)
|
||||||
|
{
|
||||||
|
var tenant = new Tenant
|
||||||
|
{
|
||||||
|
TenantId = dto.TenantId,
|
||||||
|
Name = dto.Name,
|
||||||
|
ContactName = dto.ContactName,
|
||||||
|
ContactEmail = dto.ContactEmail,
|
||||||
|
ContactPhone = dto.ContactPhone,
|
||||||
|
MaxUsers = dto.MaxUsers,
|
||||||
|
Description = dto.Description,
|
||||||
|
Status = dto.Status,
|
||||||
|
ExpiresAt = dto.ExpiresAt,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.Tenants.Add(tenant);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("tenant", "create", "Tenant", tenant.Id, tenant.TenantId, null, JsonSerializer.Serialize(dto));
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetTenant), new { id = tenant.Id }, tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> UpdateTenant(long id, UpdateTenantDto dto)
|
||||||
|
{
|
||||||
|
var tenant = await _context.Tenants.FindAsync(id);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldValue = JsonSerializer.Serialize(tenant);
|
||||||
|
|
||||||
|
tenant.Name = dto.Name;
|
||||||
|
tenant.ContactName = dto.ContactName;
|
||||||
|
tenant.ContactEmail = dto.ContactEmail;
|
||||||
|
tenant.ContactPhone = dto.ContactPhone;
|
||||||
|
tenant.MaxUsers = dto.MaxUsers;
|
||||||
|
tenant.Description = dto.Description;
|
||||||
|
tenant.Status = dto.Status;
|
||||||
|
tenant.ExpiresAt = dto.ExpiresAt;
|
||||||
|
tenant.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("tenant", "update", "Tenant", tenant.Id, tenant.TenantId, oldValue, JsonSerializer.Serialize(tenant));
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> DeleteTenant(long id)
|
||||||
|
{
|
||||||
|
var tenant = await _context.Tenants.FindAsync(id);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldValue = JsonSerializer.Serialize(tenant);
|
||||||
|
|
||||||
|
var users = await _context.Users.Where(u => u.TenantId == tenant.Id).ToListAsync();
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
user.IsDeleted = true;
|
||||||
|
user.UpdatedTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant.IsDeleted = true;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("tenant", "delete", "Tenant", tenant.Id, tenant.TenantId, oldValue);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null)
|
||||||
|
{
|
||||||
|
var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system";
|
||||||
|
var tenantId = User.FindFirstValue("TenantId");
|
||||||
|
|
||||||
|
var log = new AuditLog
|
||||||
|
{
|
||||||
|
Operator = userName,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Operation = operation,
|
||||||
|
Action = action,
|
||||||
|
TargetType = targetType,
|
||||||
|
TargetId = targetId,
|
||||||
|
TargetName = targetName,
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
Status = "success",
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = newValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.AuditLogs.Add(log);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateTenantDto
|
||||||
|
{
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string ContactName { get; set; } = string.Empty;
|
||||||
|
public string ContactEmail { get; set; } = string.Empty;
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
public int? MaxUsers { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateTenantDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string ContactName { get; set; } = string.Empty;
|
||||||
|
public string ContactEmail { get; set; } = string.Empty;
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
public int? MaxUsers { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TenantSettings
|
||||||
|
{
|
||||||
|
public bool AllowRegistration { get; set; }
|
||||||
|
public string AllowedEmailDomains { get; set; } = string.Empty;
|
||||||
|
public long? DefaultRoleId { get; set; }
|
||||||
|
public List<string> PasswordPolicy { get; set; } = new();
|
||||||
|
public int MinPasswordLength { get; set; } = 8;
|
||||||
|
public int SessionTimeout { get; set; } = 120;
|
||||||
|
}
|
||||||
255
src/Fengling.AuthService/Controllers/TokenController.cs
Normal file
255
src/Fengling.AuthService/Controllers/TokenController.cs
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using OpenIddict.Abstractions;
|
||||||
|
using OpenIddict.Server.AspNetCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore;
|
||||||
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("connect")]
|
||||||
|
public class TokenController(
|
||||||
|
IOpenIddictApplicationManager applicationManager,
|
||||||
|
IOpenIddictAuthorizationManager authorizationManager,
|
||||||
|
IOpenIddictScopeManager scopeManager,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
SignInManager<ApplicationUser> signInManager,
|
||||||
|
ILogger<TokenController> logger)
|
||||||
|
: ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<TokenController> _logger = logger;
|
||||||
|
|
||||||
|
[HttpPost("token")]
|
||||||
|
public async Task<IActionResult> Exchange()
|
||||||
|
{
|
||||||
|
var request = HttpContext.GetOpenIddictServerRequest() ??
|
||||||
|
throw new InvalidOperationException("OpenIddict request is null");
|
||||||
|
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
|
||||||
|
|
||||||
|
if (request.IsAuthorizationCodeGrantType())
|
||||||
|
{
|
||||||
|
return await ExchangeAuthorizationCodeAsync(request, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsRefreshTokenGrantType())
|
||||||
|
{
|
||||||
|
return await ExchangeRefreshTokenAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsPasswordGrantType())
|
||||||
|
{
|
||||||
|
return await ExchangePasswordAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new OpenIddictResponse
|
||||||
|
{
|
||||||
|
Error = Errors.UnsupportedGrantType,
|
||||||
|
ErrorDescription = "The specified grant type is not supported."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ExchangeAuthorizationCodeAsync(OpenIddictRequest request,
|
||||||
|
AuthenticateResult result)
|
||||||
|
{
|
||||||
|
var application = await applicationManager.FindByClientIdAsync(request.ClientId);
|
||||||
|
if (application == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new OpenIddictResponse
|
||||||
|
{
|
||||||
|
Error = Errors.InvalidClient,
|
||||||
|
ErrorDescription = "The specified client is invalid."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorization = await authorizationManager.FindAsync(
|
||||||
|
subject: result.Principal?.GetClaim(Claims.Subject),
|
||||||
|
client: await applicationManager.GetIdAsync(application),
|
||||||
|
status: Statuses.Valid,
|
||||||
|
type: AuthorizationTypes.Permanent,
|
||||||
|
scopes: request.GetScopes()).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (authorization == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new OpenIddictResponse
|
||||||
|
{
|
||||||
|
Error = Errors.InvalidGrant,
|
||||||
|
ErrorDescription = "The authorization code is invalid."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await userManager.FindByIdAsync(result.Principal?.GetClaim(Claims.Subject));
|
||||||
|
if (user == null || user.IsDeleted)
|
||||||
|
{
|
||||||
|
return BadRequest(new OpenIddictResponse
|
||||||
|
{
|
||||||
|
Error = Errors.InvalidGrant,
|
||||||
|
ErrorDescription = "The user is no longer valid."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(Claims.Subject, await userManager.GetUserIdAsync(user)),
|
||||||
|
new(Claims.Name, await userManager.GetUserNameAsync(user)),
|
||||||
|
new(Claims.Email, await userManager.GetEmailAsync(user) ?? ""),
|
||||||
|
new("tenant_id", user.TenantId.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
foreach (var role in roles)
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(Claims.Role, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
principal.SetScopes(request.GetScopes());
|
||||||
|
principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());
|
||||||
|
principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization));
|
||||||
|
|
||||||
|
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ExchangeRefreshTokenAsync(OpenIddictRequest request)
|
||||||
|
{
|
||||||
|
var principalResult =
|
||||||
|
await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
if (principalResult is not { Succeeded: true })
|
||||||
|
{
|
||||||
|
return BadRequest(new OpenIddictResponse
|
||||||
|
{
|
||||||
|
Error = Errors.InvalidGrant,
|
||||||
|
ErrorDescription = "The refresh token is invalid."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await userManager.GetUserAsync(principalResult.Principal);
|
||||||
|
if (user == null || user.IsDeleted)
|
||||||
|
{
|
||||||
|
return Forbid(
|
||||||
|
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||||
|
properties: new AuthenticationProperties(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid."
|
||||||
|
}!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the user is still allowed to sign in.
|
||||||
|
if (!await signInManager.CanSignInAsync(user))
|
||||||
|
{
|
||||||
|
return Forbid(
|
||||||
|
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||||
|
properties: new AuthenticationProperties(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
|
||||||
|
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
|
||||||
|
"The user is no longer allowed to sign in."
|
||||||
|
}!));
|
||||||
|
}
|
||||||
|
|
||||||
|
var principal = principalResult.Principal;
|
||||||
|
foreach (var claim in principal!.Claims)
|
||||||
|
{
|
||||||
|
claim.SetDestinations(GetDestinations(claim, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
principal.SetScopes(request.GetScopes());
|
||||||
|
principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());
|
||||||
|
|
||||||
|
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
private IEnumerable<string> GetDestinations(Claim claim, ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
// Note: by default, claims are NOT automatically included in the access and identity tokens.
|
||||||
|
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
|
||||||
|
// whether they should be included in access tokens, in identity tokens or in both.
|
||||||
|
|
||||||
|
switch (claim.Type)
|
||||||
|
{
|
||||||
|
case OpenIddictConstants.Claims.Name:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
|
||||||
|
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Profile))
|
||||||
|
yield return OpenIddictConstants.Destinations.IdentityToken;
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
case OpenIddictConstants.Claims.Email:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
|
||||||
|
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Email))
|
||||||
|
yield return OpenIddictConstants.Destinations.IdentityToken;
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
case OpenIddictConstants.Claims.Role:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
|
||||||
|
if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Roles))
|
||||||
|
yield return OpenIddictConstants.Destinations.IdentityToken;
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// Never include the security stamp in the access and identity tokens, as it's a secret value.
|
||||||
|
case "AspNet.Identity.SecurityStamp": yield break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
yield return OpenIddictConstants.Destinations.AccessToken;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ExchangePasswordAsync(OpenIddictRequest request)
|
||||||
|
{
|
||||||
|
var user = await userManager.FindByNameAsync(request.Username);
|
||||||
|
if (user == null || user.IsDeleted)
|
||||||
|
{
|
||||||
|
return BadRequest(new OpenIddictResponse
|
||||||
|
{
|
||||||
|
Error = Errors.InvalidGrant,
|
||||||
|
ErrorDescription = "用户名或密码错误"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var signInResult = await signInManager.CheckPasswordSignInAsync(user, request.Password, false);
|
||||||
|
if (!signInResult.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new OpenIddictResponse
|
||||||
|
{
|
||||||
|
Error = Errors.InvalidGrant,
|
||||||
|
ErrorDescription = "用户名或密码错误"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(Claims.Subject, await userManager.GetUserIdAsync(user)),
|
||||||
|
new(Claims.Name, await userManager.GetUserNameAsync(user)),
|
||||||
|
new(Claims.Email, await userManager.GetEmailAsync(user) ?? ""),
|
||||||
|
new("tenant_id", user.TenantId.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
foreach (var role in roles)
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(Claims.Role, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
principal.SetScopes(request.GetScopes());
|
||||||
|
principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());
|
||||||
|
|
||||||
|
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/Fengling.AuthService/Controllers/UsersController.cs
Normal file
291
src/Fengling.AuthService/Controllers/UsersController.cs
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class UsersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly RoleManager<ApplicationRole> _roleManager;
|
||||||
|
private readonly ILogger<UsersController> _logger;
|
||||||
|
|
||||||
|
public UsersController(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
RoleManager<ApplicationRole> roleManager,
|
||||||
|
ILogger<UsersController> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_userManager = userManager;
|
||||||
|
_roleManager = roleManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<object>> GetUsers(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 10,
|
||||||
|
[FromQuery] string? userName = null,
|
||||||
|
[FromQuery] string? email = null,
|
||||||
|
[FromQuery] string? tenantId = null)
|
||||||
|
{
|
||||||
|
var query = _context.Users.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(userName))
|
||||||
|
{
|
||||||
|
query = query.Where(u => u.UserName!.Contains(userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(email))
|
||||||
|
{
|
||||||
|
query = query.Where(u => u.Email != null && u.Email.Contains(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
query = query.Where(u => u.TenantId.ToString() == tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var users = await query
|
||||||
|
.OrderByDescending(u => u.CreatedTime)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = users.Select(async u => new
|
||||||
|
{
|
||||||
|
id = u.Id,
|
||||||
|
userName = u.UserName,
|
||||||
|
email = u.Email,
|
||||||
|
realName = u.RealName,
|
||||||
|
phone = u.Phone,
|
||||||
|
tenantId = u.TenantId,
|
||||||
|
roles = (await _userManager.GetRolesAsync(u)).ToList(),
|
||||||
|
emailConfirmed = u.EmailConfirmed,
|
||||||
|
isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow,
|
||||||
|
createdAt = u.CreatedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
items = await Task.WhenAll(result),
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<object>> GetUser(long id)
|
||||||
|
{
|
||||||
|
var user = await _context.Users.FindAsync(id);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
id = user.Id,
|
||||||
|
userName = user.UserName,
|
||||||
|
email = user.Email,
|
||||||
|
realName = user.RealName,
|
||||||
|
phone = user.Phone,
|
||||||
|
tenantId = user.TenantId,
|
||||||
|
roles,
|
||||||
|
emailConfirmed = user.EmailConfirmed,
|
||||||
|
isActive = !user.LockoutEnabled || user.LockoutEnd == null || user.LockoutEnd < DateTimeOffset.UtcNow,
|
||||||
|
createdAt = user.CreatedTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<ApplicationUser>> CreateUser(CreateUserDto dto)
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = dto.UserName,
|
||||||
|
Email = dto.Email,
|
||||||
|
RealName = dto.RealName,
|
||||||
|
Phone = dto.Phone,
|
||||||
|
TenantId = dto.TenantId ?? 0,
|
||||||
|
EmailConfirmed = dto.EmailConfirmed,
|
||||||
|
CreatedTime = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _userManager.CreateAsync(user, dto.Password);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.RoleIds != null && dto.RoleIds.Any())
|
||||||
|
{
|
||||||
|
foreach (var roleId in dto.RoleIds)
|
||||||
|
{
|
||||||
|
var role = await _roleManager.FindByIdAsync(roleId.ToString());
|
||||||
|
if (role != null)
|
||||||
|
{
|
||||||
|
await _userManager.AddToRoleAsync(user, role.Name!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.IsActive)
|
||||||
|
{
|
||||||
|
await _userManager.SetLockoutEnabledAsync(user, true);
|
||||||
|
await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateAuditLog("user", "create", "User", user.Id, user.UserName, null, SerializeToJson(dto));
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> UpdateUser(long id, UpdateUserDto dto)
|
||||||
|
{
|
||||||
|
var user = await _context.Users.FindAsync(id);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldValue = System.Text.Json.JsonSerializer.Serialize(user);
|
||||||
|
|
||||||
|
user.Email = dto.Email;
|
||||||
|
user.RealName = dto.RealName;
|
||||||
|
user.Phone = dto.Phone;
|
||||||
|
user.EmailConfirmed = dto.EmailConfirmed;
|
||||||
|
user.UpdatedTime = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (dto.IsActive)
|
||||||
|
{
|
||||||
|
await _userManager.SetLockoutEnabledAsync(user, false);
|
||||||
|
await _userManager.SetLockoutEndDateAsync(user, null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _userManager.SetLockoutEnabledAsync(user, true);
|
||||||
|
await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("user", "update", "User", user.Id, user.UserName, oldValue, System.Text.Json.JsonSerializer.Serialize(user));
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}/password")]
|
||||||
|
public async Task<IActionResult> ResetPassword(long id, ResetPasswordDto dto)
|
||||||
|
{
|
||||||
|
var user = await _userManager.FindByIdAsync(id.ToString());
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||||
|
var result = await _userManager.ResetPasswordAsync(user, token, dto.NewPassword);
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateAuditLog("user", "reset_password", "User", user.Id, user.UserName);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> DeleteUser(long id)
|
||||||
|
{
|
||||||
|
var user = await _context.Users.FindAsync(id);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldValue = System.Text.Json.JsonSerializer.Serialize(user);
|
||||||
|
user.IsDeleted = true;
|
||||||
|
user.UpdatedTime = DateTime.UtcNow;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await CreateAuditLog("user", "delete", "User", user.Id, user.UserName, oldValue);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null)
|
||||||
|
{
|
||||||
|
var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system";
|
||||||
|
var tenantId = User.FindFirstValue("TenantId");
|
||||||
|
|
||||||
|
var log = new AuditLog
|
||||||
|
{
|
||||||
|
Operator = userName,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Operation = operation,
|
||||||
|
Action = action,
|
||||||
|
TargetType = targetType,
|
||||||
|
TargetId = targetId,
|
||||||
|
TargetName = targetName,
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
Status = "success",
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = newValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.AuditLogs.Add(log);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SerializeToJson(object obj)
|
||||||
|
{
|
||||||
|
return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateUserDto
|
||||||
|
{
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string RealName { get; set; } = string.Empty;
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public long? TenantId { get; set; }
|
||||||
|
public List<long> RoleIds { get; set; } = new();
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public bool EmailConfirmed { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateUserDto
|
||||||
|
{
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string RealName { get; set; } = string.Empty;
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public bool EmailConfirmed { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ResetPasswordDto
|
||||||
|
{
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -1,8 +0,0 @@
|
|||||||
namespace Fengling.AuthService.DTOs;
|
|
||||||
|
|
||||||
public class LoginRequest
|
|
||||||
{
|
|
||||||
public string UserName { get; set; } = string.Empty;
|
|
||||||
public string Password { get; set; } = string.Empty;
|
|
||||||
public long TenantId { get; set; }
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
namespace Fengling.AuthService.DTOs;
|
|
||||||
|
|
||||||
public class LoginResponse
|
|
||||||
{
|
|
||||||
public string AccessToken { get; set; } = string.Empty;
|
|
||||||
public string RefreshToken { get; set; } = string.Empty;
|
|
||||||
public int ExpiresIn { get; set; }
|
|
||||||
public string TokenType { get; set; } = "Bearer";
|
|
||||||
}
|
|
||||||
@ -12,6 +12,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, Applicati
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<OAuthApplication> OAuthApplications { get; set; }
|
public DbSet<OAuthApplication> OAuthApplications { get; set; }
|
||||||
|
public DbSet<Tenant> Tenants { get; set; }
|
||||||
|
public DbSet<AccessLog> AccessLogs { get; set; }
|
||||||
|
public DbSet<AuditLog> AuditLogs { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
@ -40,6 +43,57 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, Applicati
|
|||||||
entity.Property(e => e.ClientType).HasMaxLength(20);
|
entity.Property(e => e.ClientType).HasMaxLength(20);
|
||||||
entity.Property(e => e.ConsentType).HasMaxLength(20);
|
entity.Property(e => e.ConsentType).HasMaxLength(20);
|
||||||
entity.Property(e => e.Status).HasMaxLength(20);
|
entity.Property(e => e.Status).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<Tenant>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.TenantId).IsUnique();
|
||||||
|
entity.Property(e => e.TenantId).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Name).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.ContactName).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.ContactEmail).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.ContactPhone).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<AccessLog>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.CreatedAt);
|
||||||
|
entity.HasIndex(e => e.UserName);
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
|
entity.HasIndex(e => e.Action);
|
||||||
|
entity.HasIndex(e => e.Status);
|
||||||
|
entity.Property(e => e.UserName).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.TenantId).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Action).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Resource).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Method).HasMaxLength(10);
|
||||||
|
entity.Property(e => e.IpAddress).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.UserAgent).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<AuditLog>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.CreatedAt);
|
||||||
|
entity.HasIndex(e => e.Operator);
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
|
entity.HasIndex(e => e.Operation);
|
||||||
|
entity.HasIndex(e => e.Action);
|
||||||
|
entity.Property(e => e.Operator).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.TenantId).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Operation).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Action).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.TargetType).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.TargetName).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.IpAddress).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(20);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
621
src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs
generated
Normal file
621
src/Fengling.AuthService/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs
generated
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260202031310_AddTenantAndLogs")]
|
||||||
|
partial class AddTenantAndLogs
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Duration")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Method")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("RequestData")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Resource")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseData")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Action");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserName");
|
||||||
|
|
||||||
|
b.ToTable("AccessLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Permissions")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<long?>("TenantId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("RealName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("TenantId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.HasIndex("Phone")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("NewValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("OldValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Operation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Operator")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<long?>("TargetId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("TargetName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Action");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Operation");
|
||||||
|
|
||||||
|
b.HasIndex("Operator");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClientId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientSecret")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("ConsentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("GrantTypes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("PostLogoutRedirectUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("RedirectUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("Scopes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClientId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("OAuthApplications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ContactEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPhone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxUsers")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Tenants");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("RoleId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("RoleId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.Tenant", null)
|
||||||
|
.WithMany("Users")
|
||||||
|
.HasForeignKey("TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Users");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,214 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTenantAndLogs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsSystem",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<List<string>>(
|
||||||
|
name: "Permissions",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "text[]",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AccessLogs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
UserName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
TenantId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Action = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Resource = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
|
||||||
|
IpAddress = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Duration = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
RequestData = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ResponseData = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AccessLogs", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AuditLogs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Operator = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
TenantId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Operation = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Action = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
TargetType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
TargetId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
TargetName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
IpAddress = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
OldValue = table.Column<string>(type: "text", nullable: true),
|
||||||
|
NewValue = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AuditLogs", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Tenants",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
TenantId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
ContactName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
ContactEmail = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
ContactPhone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||||
|
MaxUsers = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Tenants", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AccessLogs_Action",
|
||||||
|
table: "AccessLogs",
|
||||||
|
column: "Action");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AccessLogs_CreatedAt",
|
||||||
|
table: "AccessLogs",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AccessLogs_Status",
|
||||||
|
table: "AccessLogs",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AccessLogs_TenantId",
|
||||||
|
table: "AccessLogs",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AccessLogs_UserName",
|
||||||
|
table: "AccessLogs",
|
||||||
|
column: "UserName");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuditLogs_Action",
|
||||||
|
table: "AuditLogs",
|
||||||
|
column: "Action");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuditLogs_CreatedAt",
|
||||||
|
table: "AuditLogs",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuditLogs_Operation",
|
||||||
|
table: "AuditLogs",
|
||||||
|
column: "Operation");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuditLogs_Operator",
|
||||||
|
table: "AuditLogs",
|
||||||
|
column: "Operator");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuditLogs_TenantId",
|
||||||
|
table: "AuditLogs",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tenants_TenantId",
|
||||||
|
table: "Tenants",
|
||||||
|
column: "TenantId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AspNetUsers_Tenants_TenantId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "TenantId",
|
||||||
|
principalTable: "Tenants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AspNetUsers_Tenants_TenantId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AccessLogs");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AuditLogs");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Tenants");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsSystem",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Permissions",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
625
src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs
generated
Normal file
625
src/Fengling.AuthService/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs
generated
Normal file
@ -0,0 +1,625 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Fengling.AuthService.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260202064650_AddOAuthDescription")]
|
||||||
|
partial class AddOAuthDescription
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Duration")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Method")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("RequestData")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Resource")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseData")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Action");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserName");
|
||||||
|
|
||||||
|
b.ToTable("AccessLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Permissions")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<long?>("TenantId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("RealName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("TenantId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.HasIndex("Phone")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("NewValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("OldValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Operation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Operator")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<long?>("TargetId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("TargetName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Action");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Operation");
|
||||||
|
|
||||||
|
b.HasIndex("Operator");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClientId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientSecret")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("ConsentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("GrantTypes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("PostLogoutRedirectUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("RedirectUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("Scopes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClientId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("OAuthApplications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ContactEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPhone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxUsers")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Tenants");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("RoleId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("RoleId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.Tenant", null)
|
||||||
|
.WithMany("Users")
|
||||||
|
.HasForeignKey("TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Users");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOAuthDescription : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Description",
|
||||||
|
table: "OAuthApplications",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Description",
|
||||||
|
table: "OAuthApplications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Fengling.AuthService.Data;
|
using Fengling.AuthService.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@ -22,6 +23,78 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Duration")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Method")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("RequestData")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Resource")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseData")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Action");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserName");
|
||||||
|
|
||||||
|
b.ToTable("AccessLogs");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b =>
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@ -41,6 +114,12 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
@ -49,6 +128,12 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Permissions")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<long?>("TenantId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
b.HasIndex("NormalizedName")
|
||||||
@ -150,6 +235,85 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("NewValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("OldValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Operation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Operator")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<long?>("TargetId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("TargetName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Action");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Operation");
|
||||||
|
|
||||||
|
b.HasIndex("Operator");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b =>
|
modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@ -180,6 +344,10 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
b.Property<string>("DisplayName")
|
b.Property<string>("DisplayName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@ -217,6 +385,70 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
b.ToTable("OAuthApplications");
|
b.ToTable("OAuthApplications");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ContactEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPhone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxUsers")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Tenants");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -320,6 +552,15 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
b.ToTable("AspNetUserTokens", (string)null);
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Fengling.AuthService.Models.Tenant", null)
|
||||||
|
.WithMany("Users")
|
||||||
|
.HasForeignKey("TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
|
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
|
||||||
@ -370,6 +611,11 @@ namespace Fengling.AuthService.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Users");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,18 +15,65 @@ public static class SeedData
|
|||||||
|
|
||||||
context.Database.EnsureCreated();
|
context.Database.EnsureCreated();
|
||||||
|
|
||||||
|
var defaultTenant = await context.Tenants
|
||||||
|
.FirstOrDefaultAsync(t => t.TenantId == "default");
|
||||||
|
if (defaultTenant == null)
|
||||||
|
{
|
||||||
|
defaultTenant = new Tenant
|
||||||
|
{
|
||||||
|
TenantId = "default",
|
||||||
|
Name = "默认租户",
|
||||||
|
ContactName = "系统管理员",
|
||||||
|
ContactEmail = "admin@fengling.local",
|
||||||
|
ContactPhone = "13800138000",
|
||||||
|
MaxUsers = 1000,
|
||||||
|
Description = "系统默认租户",
|
||||||
|
Status = "active",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
context.Tenants.Add(defaultTenant);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
var adminRole = await roleManager.FindByNameAsync("Admin");
|
var adminRole = await roleManager.FindByNameAsync("Admin");
|
||||||
if (adminRole == null)
|
if (adminRole == null)
|
||||||
{
|
{
|
||||||
adminRole = new ApplicationRole
|
adminRole = new ApplicationRole
|
||||||
{
|
{
|
||||||
Name = "Admin",
|
Name = "Admin",
|
||||||
|
DisplayName = "管理员",
|
||||||
Description = "System administrator",
|
Description = "System administrator",
|
||||||
|
TenantId = defaultTenant.Id,
|
||||||
|
IsSystem = true,
|
||||||
|
Permissions = new List<string>
|
||||||
|
{
|
||||||
|
"user.manage", "user.view",
|
||||||
|
"role.manage", "role.view",
|
||||||
|
"tenant.manage", "tenant.view",
|
||||||
|
"oauth.manage", "oauth.view",
|
||||||
|
"log.view", "system.config"
|
||||||
|
},
|
||||||
CreatedTime = DateTime.UtcNow
|
CreatedTime = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
await roleManager.CreateAsync(adminRole);
|
await roleManager.CreateAsync(adminRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var userRole = await roleManager.FindByNameAsync("User");
|
||||||
|
if (userRole == null)
|
||||||
|
{
|
||||||
|
userRole = new ApplicationRole
|
||||||
|
{
|
||||||
|
Name = "User",
|
||||||
|
DisplayName = "普通用户",
|
||||||
|
Description = "Regular user",
|
||||||
|
TenantId = defaultTenant.Id,
|
||||||
|
IsSystem = true,
|
||||||
|
Permissions = new List<string> { "user.view" },
|
||||||
|
CreatedTime = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await roleManager.CreateAsync(userRole);
|
||||||
|
}
|
||||||
|
|
||||||
var adminUser = await userManager.FindByNameAsync("admin");
|
var adminUser = await userManager.FindByNameAsync("admin");
|
||||||
if (adminUser == null)
|
if (adminUser == null)
|
||||||
{
|
{
|
||||||
@ -36,7 +83,7 @@ public static class SeedData
|
|||||||
Email = "admin@fengling.local",
|
Email = "admin@fengling.local",
|
||||||
RealName = "系统管理员",
|
RealName = "系统管理员",
|
||||||
Phone = "13800138000",
|
Phone = "13800138000",
|
||||||
TenantId = 1,
|
TenantId = defaultTenant.Id,
|
||||||
EmailConfirmed = true,
|
EmailConfirmed = true,
|
||||||
IsDeleted = false,
|
IsDeleted = false,
|
||||||
CreatedTime = DateTime.UtcNow
|
CreatedTime = DateTime.UtcNow
|
||||||
@ -58,7 +105,7 @@ public static class SeedData
|
|||||||
Email = "test@fengling.local",
|
Email = "test@fengling.local",
|
||||||
RealName = "测试用户",
|
RealName = "测试用户",
|
||||||
Phone = "13900139000",
|
Phone = "13900139000",
|
||||||
TenantId = 1,
|
TenantId = defaultTenant.Id,
|
||||||
EmailConfirmed = true,
|
EmailConfirmed = true,
|
||||||
IsDeleted = false,
|
IsDeleted = false,
|
||||||
CreatedTime = DateTime.UtcNow
|
CreatedTime = DateTime.UtcNow
|
||||||
@ -67,13 +114,6 @@ public static class SeedData
|
|||||||
var result = await userManager.CreateAsync(testUser, "Test@123");
|
var result = await userManager.CreateAsync(testUser, "Test@123");
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
var userRole = new ApplicationRole
|
|
||||||
{
|
|
||||||
Name = "User",
|
|
||||||
Description = "普通用户",
|
|
||||||
CreatedTime = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
await roleManager.CreateAsync(userRole);
|
|
||||||
await userManager.AddToRoleAsync(testUser, "User");
|
await userManager.AddToRoleAsync(testUser, "User");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,23 +6,30 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.0.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Npgsql" Version="8.0.2" />
|
<PackageReference Include="AspNetCore.HealthChecks.Npgsql" Version="9.0.0" />
|
||||||
<PackageReference Include="OpenIddict.AspNetCore" Version="7.2.0" />
|
<PackageReference Include="OpenIddict.AspNetCore" Version="7.2.0" />
|
||||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
|
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="OpenTelemetry" Version="1.11.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.0" />
|
<PackageReference Include="OpenTelemetry" Version="1.15.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="appsettings.Testing.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
43
src/Fengling.AuthService/Models/AccessLog.cs
Normal file
43
src/Fengling.AuthService/Models/AccessLog.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Models;
|
||||||
|
|
||||||
|
public class AccessLog
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string? Resource { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(10)]
|
||||||
|
public string? Method { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Status { get; set; } = "success";
|
||||||
|
|
||||||
|
public int Duration { get; set; }
|
||||||
|
|
||||||
|
public string? RequestData { get; set; }
|
||||||
|
|
||||||
|
public string? ResponseData { get; set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace Fengling.AuthService.Models;
|
namespace Fengling.AuthService.Models;
|
||||||
|
|
||||||
@ -6,4 +7,8 @@ public class ApplicationRole : IdentityRole<long>
|
|||||||
{
|
{
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
|
||||||
|
public long? TenantId { get; set; }
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public List<string>? Permissions { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/Fengling.AuthService/Models/AuditLog.cs
Normal file
47
src/Fengling.AuthService/Models/AuditLog.cs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Models;
|
||||||
|
|
||||||
|
public class AuditLog
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
[Required]
|
||||||
|
public string Operator { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Operation { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string? TargetType { get; set; }
|
||||||
|
|
||||||
|
public long? TargetId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? TargetName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string IpAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public string? OldValue { get; set; }
|
||||||
|
|
||||||
|
public string? NewValue { get; set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Status { get; set; } = "success";
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Fengling.AuthService.Models;
|
namespace Fengling.AuthService.Models;
|
||||||
|
|
||||||
public class OAuthApplication
|
public class OAuthApplication
|
||||||
@ -13,6 +15,7 @@ public class OAuthApplication
|
|||||||
public string ClientType { get; set; } = "public";
|
public string ClientType { get; set; } = "public";
|
||||||
public string ConsentType { get; set; } = "implicit";
|
public string ConsentType { get; set; } = "implicit";
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
|
public string? Description { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/Fengling.AuthService/Models/Tenant.cs
Normal file
47
src/Fengling.AuthService/Models/Tenant.cs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Models;
|
||||||
|
|
||||||
|
public class Tenant
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
[Required]
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
[Required]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
[Required]
|
||||||
|
public string ContactName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
|
public string ContactEmail { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
|
||||||
|
public int? MaxUsers { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||||
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
using Fengling.AuthService.Configuration;
|
using Fengling.AuthService.Configuration;
|
||||||
using Fengling.AuthService.Data;
|
using Fengling.AuthService.Data;
|
||||||
using Fengling.AuthService.Models;
|
using Fengling.AuthService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi;
|
||||||
using OpenTelemetry;
|
using OpenTelemetry;
|
||||||
using OpenTelemetry.Resources;
|
using OpenTelemetry.Resources;
|
||||||
using OpenTelemetry.Trace;
|
using OpenTelemetry.Trace;
|
||||||
@ -19,13 +20,37 @@ Log.Logger = new LoggerConfiguration()
|
|||||||
|
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
{
|
||||||
|
if (connectionString.StartsWith("DataSource="))
|
||||||
|
{
|
||||||
|
options.UseInMemoryDatabase(connectionString);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
options.UseNpgsql(connectionString);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddRazorPages();
|
||||||
|
builder.Services.AddControllersWithViews();
|
||||||
|
|
||||||
builder.Services.AddIdentity<ApplicationUser, ApplicationRole>()
|
builder.Services.AddIdentity<ApplicationUser, ApplicationRole>()
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.Cookie.Name = "Fengling.Auth";
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.None;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddOpenIddictConfiguration(builder.Configuration);
|
builder.Services.AddOpenIddictConfiguration(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddOpenTelemetry()
|
builder.Services.AddOpenTelemetry()
|
||||||
@ -37,7 +62,7 @@ builder.Services.AddOpenTelemetry()
|
|||||||
.AddSource("OpenIddict.Server.AspNetCore")
|
.AddSource("OpenIddict.Server.AspNetCore")
|
||||||
.AddOtlpExporter());
|
.AddOtlpExporter());
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllersWithViews();
|
||||||
|
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!);
|
.AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!);
|
||||||
@ -71,18 +96,24 @@ using (var scope = app.Services.CreateScope())
|
|||||||
await SeedData.Initialize(scope.ServiceProvider);
|
await SeedData.Initialize(scope.ServiceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseStaticFiles();
|
||||||
app.UseSwaggerUI(options =>
|
|
||||||
{
|
|
||||||
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1");
|
|
||||||
options.OAuthClientId("swagger-ui");
|
|
||||||
options.OAuthUsePkce();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
var isTesting = builder.Configuration.GetValue<bool>("Testing", false);
|
||||||
|
if (!isTesting)
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI(options =>
|
||||||
|
{
|
||||||
|
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1");
|
||||||
|
options.OAuthClientId("swagger-ui");
|
||||||
|
options.OAuthUsePkce();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapRazorPages();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"http": {
|
"http": {
|
||||||
@ -9,15 +9,6 @@
|
|||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "https://localhost:7150;http://localhost:5132",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Fengling.AuthService.ViewModels;
|
||||||
|
|
||||||
|
public record AuthorizeViewModel(string? ApplicationName, string? Scope)
|
||||||
|
{
|
||||||
|
public string[]? Scopes => Scope?.Split(' ') ?? null;
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
namespace Fengling.AuthService.ViewModels;
|
||||||
|
|
||||||
|
public class DashboardViewModel
|
||||||
|
{
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
}
|
||||||
14
src/Fengling.AuthService/ViewModels/LoginViewModel.cs
Normal file
14
src/Fengling.AuthService/ViewModels/LoginViewModel.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace Fengling.AuthService.ViewModels;
|
||||||
|
|
||||||
|
public class LoginViewModel
|
||||||
|
{
|
||||||
|
public string ReturnUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LoginInputModel
|
||||||
|
{
|
||||||
|
public string Username { get; set; }
|
||||||
|
public string Password { get; set; }
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
|
public string ReturnUrl { get; set; }
|
||||||
|
}
|
||||||
26
src/Fengling.AuthService/ViewModels/RegisterViewModel.cs
Normal file
26
src/Fengling.AuthService/ViewModels/RegisterViewModel.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.ViewModels;
|
||||||
|
|
||||||
|
public class RegisterViewModel
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "用户名不能为空")]
|
||||||
|
[StringLength(50, MinimumLength = 3, ErrorMessage = "用户名长度必须在3-50个字符之间")]
|
||||||
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "邮箱不能为空")]
|
||||||
|
[EmailAddress(ErrorMessage = "请输入有效的邮箱地址")]
|
||||||
|
public string Email { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "密码不能为空")]
|
||||||
|
[StringLength(100, MinimumLength = 6, ErrorMessage = "密码长度必须在6-100个字符之间")]
|
||||||
|
[DataType(DataType.Password)]
|
||||||
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "确认密码不能为空")]
|
||||||
|
[DataType(DataType.Password)]
|
||||||
|
[Compare("Password", ErrorMessage = "两次输入的密码不一致")]
|
||||||
|
public string ConfirmPassword { get; set; }
|
||||||
|
|
||||||
|
public string ReturnUrl { get; set; }
|
||||||
|
}
|
||||||
81
src/Fengling.AuthService/Views/Account/Login.cshtml
Normal file
81
src/Fengling.AuthService/Views/Account/Login.cshtml
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
@model Fengling.AuthService.ViewModels.LoginInputModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "登录";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-8">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mb-4">
|
||||||
|
<svg class="h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold">欢迎回来</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">登录到 Fengling Auth</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg shadow-sm p-6">
|
||||||
|
@if (!ViewData.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<div class="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
|
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
||||||
|
{
|
||||||
|
<p>@error.ErrorMessage</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post" class="space-y-4">
|
||||||
|
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" asp-for="ReturnUrl" />
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="Username" class="text-sm font-medium">用户名</label>
|
||||||
|
<input type="text"
|
||||||
|
id="Username"
|
||||||
|
name="Username"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
value="@Model.Username"
|
||||||
|
required
|
||||||
|
autocomplete="username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="Password" class="text-sm font-medium">密码</label>
|
||||||
|
<input type="password"
|
||||||
|
id="Password"
|
||||||
|
name="Password"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
required
|
||||||
|
autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="RememberMe"
|
||||||
|
name="RememberMe"
|
||||||
|
class="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||||
|
<label for="RememberMe" class="text-sm font-medium">记住我</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-11 w-full px-8 shadow">
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm">
|
||||||
|
<span class="text-muted-foreground">还没有账号?</span>
|
||||||
|
<a href="/account/register?returnUrl=@Model.ReturnUrl" class="text-primary hover:underline ml-1">立即注册</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
95
src/Fengling.AuthService/Views/Account/Register.cshtml
Normal file
95
src/Fengling.AuthService/Views/Account/Register.cshtml
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
@model Fengling.AuthService.ViewModels.RegisterViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "注册";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-8">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mb-4">
|
||||||
|
<svg class="h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="8.5" cy="7" r="4"/>
|
||||||
|
<line x1="20" y1="8" x2="20" y2="14"/>
|
||||||
|
<line x1="23" y1="11" x2="17" y2="11"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold">创建账号</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">加入 Fengling Auth</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg shadow-sm p-6">
|
||||||
|
@if (!ViewData.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<div class="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
|
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
||||||
|
{
|
||||||
|
<p>@error.ErrorMessage</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post" class="space-y-4">
|
||||||
|
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" asp-for="ReturnUrl" />
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="Username" class="text-sm font-medium">用户名</label>
|
||||||
|
<input type="text"
|
||||||
|
id="Username"
|
||||||
|
name="Username"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
value="@Model.Username"
|
||||||
|
required
|
||||||
|
autocomplete="username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="Email" class="text-sm font-medium">邮箱</label>
|
||||||
|
<input type="email"
|
||||||
|
id="Email"
|
||||||
|
name="Email"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
value="@Model.Email"
|
||||||
|
required
|
||||||
|
autocomplete="email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="Password" class="text-sm font-medium">密码</label>
|
||||||
|
<input type="password"
|
||||||
|
id="Password"
|
||||||
|
name="Password"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="请输入密码(至少6个字符)"
|
||||||
|
required
|
||||||
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="ConfirmPassword" class="text-sm font-medium">确认密码</label>
|
||||||
|
<input type="password"
|
||||||
|
id="ConfirmPassword"
|
||||||
|
name="ConfirmPassword"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
required
|
||||||
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-11 w-full px-8 shadow">
|
||||||
|
注册
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm">
|
||||||
|
<span class="text-muted-foreground">已有账号?</span>
|
||||||
|
<a href="/account/login?returnUrl=@Model.ReturnUrl" class="text-primary hover:underline ml-1">立即登录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
146
src/Fengling.AuthService/Views/Authorization/Authorize.cshtml
Normal file
146
src/Fengling.AuthService/Views/Authorization/Authorize.cshtml
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
@model Fengling.AuthService.ViewModels.AuthorizeViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "授权确认";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-8">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mb-4">
|
||||||
|
<svg class="h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold">授权确认</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">
|
||||||
|
<span class="text-primary font-medium">@Model.ApplicationName</span>
|
||||||
|
请求访问您的账户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authorization Card -->
|
||||||
|
<div class="bg-card border border-border rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<!-- App Info Section -->
|
||||||
|
<div class="p-6 border-b border-border">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="h-12 w-12 rounded-lg bg-primary flex items-center justify-center text-primary-foreground text-lg font-semibold">
|
||||||
|
@(Model.ApplicationName?.Substring(0, Math.Min(1, Model.ApplicationName.Length)).ToUpper() ?? "A")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h2 class="font-semibold text-lg truncate">@Model.ApplicationName</h2>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
该应用将获得以下权限:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permissions Section -->
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-sm font-medium text-foreground mb-4">请求的权限</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@if (Model.Scopes != null && Model.Scopes.Length > 0)
|
||||||
|
{
|
||||||
|
@foreach (var scope in Model.Scopes)
|
||||||
|
{
|
||||||
|
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<svg class="h-5 w-5 text-primary mt-0.5 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium">@GetScopeDisplayName(scope)</p>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">@GetScopeDescription(scope)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-sm text-muted-foreground">无特定权限请求</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning Section -->
|
||||||
|
<div class="p-4 bg-destructive/5 border-t border-border">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<svg class="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
|
||||||
|
<path d="M12 9v4"/>
|
||||||
|
<path d="M12 17h.01"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
授予权限后,该应用将能够访问您的账户信息。您可以随时在授权管理中撤销权限。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<form method="post" class="mt-6 space-y-3">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button type="submit" name="action" value="accept"
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 shadow">
|
||||||
|
<svg class="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
授权
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="deny"
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-11 px-8">
|
||||||
|
<svg class="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Footer Links -->
|
||||||
|
<div class="mt-6 text-center text-sm">
|
||||||
|
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
了解关于 OAuth 授权的更多信息
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
private string GetScopeDisplayName(string scope)
|
||||||
|
{
|
||||||
|
return scope switch
|
||||||
|
{
|
||||||
|
"openid" => "OpenID Connect",
|
||||||
|
"profile" => "个人资料",
|
||||||
|
"email" => "电子邮件地址",
|
||||||
|
"phone" => "电话号码",
|
||||||
|
"address" => "地址信息",
|
||||||
|
"roles" => "角色权限",
|
||||||
|
"offline_access" => "离线访问",
|
||||||
|
_ => scope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetScopeDescription(string scope)
|
||||||
|
{
|
||||||
|
return scope switch
|
||||||
|
{
|
||||||
|
"openid" => "用于用户身份验证",
|
||||||
|
"profile" => "访问您的姓名、头像等基本信息",
|
||||||
|
"email" => "访问您的电子邮件地址",
|
||||||
|
"phone" => "访问您的电话号码",
|
||||||
|
"address" => "访问您的地址信息",
|
||||||
|
"roles" => "访问您的角色和权限信息",
|
||||||
|
"offline_access" => "在您离线时仍可访问数据",
|
||||||
|
_ => "自定义权限范围"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/Fengling.AuthService/Views/Dashboard/Index.cshtml
Normal file
155
src/Fengling.AuthService/Views/Dashboard/Index.cshtml
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
@model Fengling.AuthService.ViewModels.DashboardViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "控制台";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold">欢迎,@Model.Username</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">这里是您的控制台首页</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-muted-foreground">已登录应用</p>
|
||||||
|
<p class="text-2xl font-bold mt-2">3</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<svg class="h-6 w-6 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-muted-foreground">授权次数</p>
|
||||||
|
<p class="text-2xl font-bold mt-2">12</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||||
|
<svg class="h-6 w-6 text-green-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-muted-foreground">活跃会话</p>
|
||||||
|
<p class="text-2xl font-bold mt-2">5</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-12 w-12 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||||
|
<svg class="h-6 w-6 text-blue-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-muted-foreground">安全评分</p>
|
||||||
|
<p class="text-2xl font-bold mt-2">92%</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-12 w-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
|
||||||
|
<svg class="h-6 w-6 text-yellow-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="lg:col-span-2 bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">最近活动</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||||
|
<div class="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
|
||||||
|
F
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium">登录成功</p>
|
||||||
|
<p class="text-xs text-muted-foreground">通过 Fengling.Console.Web 登录</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted-foreground">2分钟前</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||||
|
<div class="h-10 w-10 rounded-full bg-green-600 flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium">授权成功</p>
|
||||||
|
<p class="text-xs text-muted-foreground">授予 Fengling.Console.Web 访问权限</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted-foreground">5分钟前</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||||
|
<div class="h-10 w-10 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
🔄
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium">令牌刷新</p>
|
||||||
|
<p class="text-xs text-muted-foreground">刷新访问令牌</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted-foreground">1小时前</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">快捷操作</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<a href="/profile" class="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted transition-colors">
|
||||||
|
<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">个人资料</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/settings" class="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted transition-colors">
|
||||||
|
<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">账户设置</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/connect/authorize" class="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted transition-colors">
|
||||||
|
<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">授权管理</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form method="post" action="/account/logout">
|
||||||
|
<button type="submit" class="w-full flex items-center gap-3 p-3 rounded-lg border border-destructive/20 hover:bg-destructive/10 transition-colors text-left">
|
||||||
|
<svg class="h-5 w-5 text-destructive" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-destructive">退出登录</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
50
src/Fengling.AuthService/Views/Dashboard/Profile.cshtml
Normal file
50
src/Fengling.AuthService/Views/Dashboard/Profile.cshtml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
@model Fengling.AuthService.ViewModels.DashboardViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "个人资料";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold">个人资料</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">管理您的个人信息</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<div class="flex items-center gap-6 mb-6">
|
||||||
|
<div class="h-24 w-24 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-3xl font-bold">
|
||||||
|
@(Model.Username?.Substring(0, 1).ToUpper() ?? "U")
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">@Model.Username</h2>
|
||||||
|
<p class="text-muted-foreground">@Model.Email</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium">用户名</label>
|
||||||
|
<div class="p-3 rounded-md border border-input bg-muted/50 text-sm">
|
||||||
|
@Model.Username
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium">邮箱</label>
|
||||||
|
<div class="p-3 rounded-md border border-input bg-muted/50 text-sm">
|
||||||
|
@Model.Email
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium">注册时间</label>
|
||||||
|
<div class="p-3 rounded-md border border-input bg-muted/50 text-sm">
|
||||||
|
2026-01-15
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
90
src/Fengling.AuthService/Views/Dashboard/Settings.cshtml
Normal file
90
src/Fengling.AuthService/Views/Dashboard/Settings.cshtml
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
@model Fengling.AuthService.ViewModels.DashboardViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "设置";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold">账户设置</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">管理您的账户设置和偏好</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">修改密码</h2>
|
||||||
|
<form method="post" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="currentPassword" class="text-sm font-medium">当前密码</label>
|
||||||
|
<input type="password"
|
||||||
|
id="currentPassword"
|
||||||
|
name="currentPassword"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
placeholder="请输入当前密码">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="newPassword" class="text-sm font-medium">新密码</label>
|
||||||
|
<input type="password"
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
placeholder="请输入新密码(至少6个字符)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="confirmPassword" class="text-sm font-medium">确认新密码</label>
|
||||||
|
<input type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
placeholder="请再次输入新密码">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-8">
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">安全选项</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">两步验证</p>
|
||||||
|
<p class="text-sm text-muted-foreground">为您的账户添加额外的安全保护</p>
|
||||||
|
</div>
|
||||||
|
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4">
|
||||||
|
启用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">登录通知</p>
|
||||||
|
<p class="text-sm text-muted-foreground">当有新设备登录时发送通知</p>
|
||||||
|
</div>
|
||||||
|
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4">
|
||||||
|
配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">危险区域</h2>
|
||||||
|
<div class="flex items-center justify-between p-4 rounded-lg border border-destructive/20">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-destructive">删除账户</p>
|
||||||
|
<p class="text-sm text-muted-foreground">永久删除您的账户和所有数据</p>
|
||||||
|
</div>
|
||||||
|
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-destructive text-destructive-foreground hover:bg-destructive/90 h-9 px-4">
|
||||||
|
删除账户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
162
src/Fengling.AuthService/Views/Shared/_Layout.cshtml
Normal file
162
src/Fengling.AuthService/Views/Shared/_Layout.cshtml
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fengling 认证服务系统 - 提供安全可靠的身份认证和授权服务" />
|
||||||
|
<meta name="author" content="Fengling Team" />
|
||||||
|
<meta name="keywords" content="认证,授权,登录,注册,SSO,OAuth2" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>@ViewData["Title"] - Fengling Auth</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- shadcn Theme CSS -->
|
||||||
|
<link rel="stylesheet" href="~/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen antialiased flex flex-col">
|
||||||
|
|
||||||
|
<!-- Header / Navigation -->
|
||||||
|
<header class="sticky top-0 z-50 w-full border-b border-border bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
||||||
|
<div class="container mx-auto flex h-16 items-center justify-between px-4">
|
||||||
|
<!-- Logo and Brand -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-bold">Fengling Auth</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Links -->
|
||||||
|
<nav class="hidden md:flex items-center gap-6 text-sm font-medium">
|
||||||
|
<a href="/" class="transition-colors hover:text-foreground text-foreground">首页</a>
|
||||||
|
<a href="/dashboard" class="transition-colors hover:text-foreground text-muted-foreground">控制台</a>
|
||||||
|
<a href="/docs" class="transition-colors hover:text-foreground text-muted-foreground">文档</a>
|
||||||
|
<a href="/api" class="transition-colors hover:text-foreground text-muted-foreground">API</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- User Actions -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
<!-- User Menu (Logged In) -->
|
||||||
|
<div class="relative group">
|
||||||
|
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted transition-colors">
|
||||||
|
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
|
||||||
|
@(User.Identity.Name?.Substring(0, 1).ToUpper() ?? "U")
|
||||||
|
</div>
|
||||||
|
<span class="hidden sm:inline">@User.Identity.Name</span>
|
||||||
|
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m6 9 6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dropdown Menu -->
|
||||||
|
<div class="absolute right-0 top-full mt-1 w-48 rounded-md border border-border bg-white shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all">
|
||||||
|
<div class="p-2">
|
||||||
|
<a href="/profile" class="block rounded-sm px-3 py-2 text-sm hover:bg-muted transition-colors">个人资料</a>
|
||||||
|
<a href="/settings" class="block rounded-sm px-3 py-2 text-sm hover:bg-muted transition-colors">设置</a>
|
||||||
|
<hr class="my-2 border-border">
|
||||||
|
<a href="/logout" class="block rounded-sm px-3 py-2 text-sm hover:bg-muted transition-colors text-red-600">退出登录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<!-- Login/Register Buttons (Guest) -->
|
||||||
|
<a href="/login" class="text-sm font-medium hover:text-foreground text-muted-foreground transition-colors">登录</a>
|
||||||
|
<a href="/register" class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2">
|
||||||
|
注册
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1">
|
||||||
|
@RenderBody()
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="border-t border-border bg-muted/50">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
<!-- Brand Column -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="font-bold">Fengling Auth</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
提供安全可靠的身份认证和授权服务,帮助企业快速实现用户管理和权限控制。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Column -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold">产品</h3>
|
||||||
|
<ul class="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">功能特性</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">定价方案</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">集成文档</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">更新日志</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resources Column -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold">资源</h3>
|
||||||
|
<ul class="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">文档中心</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">API 参考</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">SDK 下载</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">常见问题</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Column -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold">关于</h3>
|
||||||
|
<ul class="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">关于我们</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">联系方式</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">隐私政策</a></li>
|
||||||
|
<li><a href="#" class="hover:text-foreground transition-colors">服务条款</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Bar -->
|
||||||
|
<div class="mt-8 pt-8 border-t border-border">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
© 2026 Fengling Team. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
src/Fengling.AuthService/Views/_ViewImports.cshtml
Normal file
3
src/Fengling.AuthService/Views/_ViewImports.cshtml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@using Fengling.AuthService
|
||||||
|
@using Fengling.AuthService.ViewModels
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
3
src/Fengling.AuthService/Views/_ViewStart.cshtml
Normal file
3
src/Fengling.AuthService/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
22
src/Fengling.AuthService/appsettings.Testing.json
Normal file
22
src/Fengling.AuthService/appsettings.Testing.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "DataSource=:memory:"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "https://auth.fengling.local",
|
||||||
|
"Audience": "fengling-api",
|
||||||
|
"Secret": "FenglingAuthSecretKey2024!ChangeThisInProduction!"
|
||||||
|
},
|
||||||
|
"OpenIddict": {
|
||||||
|
"Issuer": "https://auth.fengling.local",
|
||||||
|
"Audience": "fengling-api"
|
||||||
|
},
|
||||||
|
"Testing": true,
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@ -2,6 +2,11 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_auth;Username=movingsam;Password=sl52788542"
|
"DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_auth;Username=movingsam;Password=sl52788542"
|
||||||
},
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "https://auth.fengling.local",
|
||||||
|
"Audience": "fengling-api",
|
||||||
|
"Secret": "FenglingAuthSecretKey2024!ChangeThisInProduction!"
|
||||||
|
},
|
||||||
"OpenIddict": {
|
"OpenIddict": {
|
||||||
"Issuer": "https://auth.fengling.local",
|
"Issuer": "https://auth.fengling.local",
|
||||||
"Audience": "fengling-api"
|
"Audience": "fengling-api"
|
||||||
|
|||||||
210
src/Fengling.AuthService/wwwroot/css/styles.css
Normal file
210
src/Fengling.AuthService/wwwroot/css/styles.css
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
/* ============================================
|
||||||
|
shadcn UI Theme Variables
|
||||||
|
Based on shadcn/ui default theme
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Light Mode Variables */
|
||||||
|
:root {
|
||||||
|
/* Background & Foreground */
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
/* Popover */
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
/* Primary */
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
/* Secondary */
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
/* Muted */
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
/* Destructive */
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
/* Borders & Inputs */
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
|
||||||
|
/* Ring */
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode Variables */
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Base Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Utility Classes
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Background colors */
|
||||||
|
.bg-primary {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-secondary {
|
||||||
|
background-color: hsl(var(--secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-muted {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-accent {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-destructive {
|
||||||
|
background-color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
.text-primary-foreground {
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary-foreground {
|
||||||
|
color: hsl(var(--secondary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted-foreground {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent-foreground {
|
||||||
|
color: hsl(var(--accent-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-destructive-foreground {
|
||||||
|
color: hsl(var(--destructive-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-foreground {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
.border-border {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects */
|
||||||
|
.hover\:bg-muted:hover {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:bg-primary:hover {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-foreground:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Transitions
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.transition-colors {
|
||||||
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-all {
|
||||||
|
transition-property: all;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Component Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Button Styles */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: hsl(var(--primary) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styles */
|
||||||
|
.card {
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foreground {
|
||||||
|
color: hsl(var(--card-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Styles */
|
||||||
|
.input {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
border: 1px solid hsl(var(--input));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(var(--ring));
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
|
||||||
|
}
|
||||||
193
src/Fengling.AuthService/wwwroot/login.html
Normal file
193
src/Fengling.AuthService/wwwroot/login.html
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录 - 风铃认证服务</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
.remember-me {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.remember-me input {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c33;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: white;
|
||||||
|
animation: spin 0.8s ease-in-out infinite;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>风铃认证服务</h1>
|
||||||
|
<div id="error-message" class="error" style="display: none;"></div>
|
||||||
|
<form id="login-form">
|
||||||
|
<input type="hidden" id="returnUrl" name="returnUrl">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="remember-me">
|
||||||
|
<input type="checkbox" id="rememberMe" name="rememberMe">
|
||||||
|
<label for="rememberMe" style="margin-bottom: 0;">记住我</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="submit-btn">
|
||||||
|
<span id="btn-text">登录</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const errorElement = document.getElementById('error-message');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const btnText = document.getElementById('btn-text');
|
||||||
|
|
||||||
|
// Get returnUrl from query parameter
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const returnUrl = urlParams.get('returnUrl') || '/';
|
||||||
|
document.getElementById('returnUrl').value = returnUrl;
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const rememberMe = document.getElementById('rememberMe').checked;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
btnText.innerHTML = '<span class="loading"></span>登录中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/account/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
rememberMe: rememberMe,
|
||||||
|
returnUrl: returnUrl
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Redirect to returnUrl
|
||||||
|
window.location.href = data.returnUrl;
|
||||||
|
} else {
|
||||||
|
// Show error
|
||||||
|
errorElement.textContent = data.error || '登录失败,请重试';
|
||||||
|
errorElement.style.display = 'block';
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
btnText.textContent = '登录';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorElement.textContent = '网络错误,请重试';
|
||||||
|
errorElement.style.display = 'block';
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
btnText.textContent = '登录';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# Fengling.Console.Web 运管中心前端
|
# Fengling.Console.Web 运管中心前端
|
||||||
|
|
||||||
## 开发环境配置
|
## 开发环境配置
|
||||||
VITE_AUTH_SERVICE_URL=http://localhost:5000
|
VITE_AUTH_SERVER_URL=http://localhost:5132
|
||||||
VITE_GATEWAY_SERVICE_URL=http://localhost:5001
|
VITE_GATEWAY_SERVICE_URL=http://localhost:5001
|
||||||
VITE_CLIENT_ID=fengling-console
|
VITE_CLIENT_ID=fengling-console
|
||||||
VITE_REDIRECT_URI=http://localhost:5173/auth/callback
|
VITE_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Fengling.Console.Web 运管中心前端
|
# Fengling.Console.Web 运管中心前端
|
||||||
|
|
||||||
## 生产环境配置
|
## 生产环境配置
|
||||||
VITE_AUTH_SERVICE_URL=https://auth.fengling.local
|
VITE_AUTH_SERVER_URL=https://auth.fengling.local
|
||||||
VITE_GATEWAY_SERVICE_URL=https://gateway.fengling.local
|
VITE_GATEWAY_SERVICE_URL=https://gateway.fengling.local
|
||||||
VITE_CLIENT_ID=fengling-console
|
VITE_CLIENT_ID=fengling-console
|
||||||
VITE_REDIRECT_URI=https://console.fengling.local/auth/callback
|
VITE_REDIRECT_URI=https://console.fengling.local/auth/callback
|
||||||
|
|||||||
22
src/Fengling.Console.Web/package-lock.json
generated
22
src/Fengling.Console.Web/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "fengling-console-web",
|
"name": "fengling-console-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"oidc-client-ts": "^3.4.1",
|
||||||
"vue": "^3.5.24"
|
"vue": "^3.5.24"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -1174,6 +1175,15 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwt-decode": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -1208,6 +1218,18 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-client-ts": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jwt-decode": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"oidc-client-ts": "^3.4.1",
|
||||||
"vue": "^3.5.24"
|
"vue": "^3.5.24"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
25
src/Fengling.Console.Web/public/silent-renew.html
Normal file
25
src/Fengling.Console.Web/public/silent-renew.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Silent Renew</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/oidc-client-ts@3.4.1/dist/umd/oidc-client-ts.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const userManager = new oidc.UserManager({
|
||||||
|
authority: window.location.origin,
|
||||||
|
client_id: 'fengling-console',
|
||||||
|
redirect_uri: window.location.origin + '/auth/callback',
|
||||||
|
silent_redirect_uri: window.location.origin + '/silent-renew.html',
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile api offline_access',
|
||||||
|
automaticSilentRenew: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
userManager.signinSilentCallback().catch(function (err) {
|
||||||
|
console.error('Silent renew error:', err)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,30 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<router-view />
|
||||||
<a href="https://vite.dev" target="_blank">
|
|
||||||
<img src="/vite.svg" class="logo" alt="Vite logo" />
|
|
||||||
</a>
|
|
||||||
<a href="https://vuejs.org/" target="_blank">
|
|
||||||
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<HelloWorld msg="Vite + Vue" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.logo {
|
* {
|
||||||
height: 6em;
|
margin: 0;
|
||||||
padding: 1.5em;
|
padding: 0;
|
||||||
will-change: filter;
|
box-sizing: border-box;
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
}
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
html, body, #app {
|
||||||
}
|
width: 100%;
|
||||||
.logo.vue:hover {
|
height: 100%;
|
||||||
filter: drop-shadow(0 0 2em #42b883aa);
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import type { AxiosInstance } from 'axios'
|
|
||||||
|
|
||||||
const authAxios: AxiosInstance = axios.create({
|
|
||||||
baseURL: '/api/auth',
|
|
||||||
timeout: 10000,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
authAxios.interceptors.request.use(
|
|
||||||
config => {
|
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export const authService = {
|
|
||||||
async login(username: string, password: string) {
|
|
||||||
const response = await authAxios.post('/connect/token', new URLSearchParams({
|
|
||||||
grant_type: 'password',
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
scope: 'api offline_access',
|
|
||||||
}), {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshToken(refreshToken: string) {
|
|
||||||
const response = await authAxios.post('/connect/token', new URLSearchParams({
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
scope: 'api offline_access',
|
|
||||||
}), {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
await authAxios.post('/connect/logout')
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('refresh_token')
|
|
||||||
localStorage.removeItem('user_info')
|
|
||||||
},
|
|
||||||
|
|
||||||
async getClients() {
|
|
||||||
const response = await authAxios.get('/api/oauthclients')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async createClient(client: any) {
|
|
||||||
const response = await authAxios.post('/api/oauthclients', client)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateClient(id: number, client: any) {
|
|
||||||
const response = await authAxios.put(`/api/oauthclients/${id}`, client)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteClient(id: number) {
|
|
||||||
await authAxios.delete(`/api/oauthclients/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
initiateOAuthLogin() {
|
|
||||||
const clientId = import.meta.env.VITE_CLIENT_ID || 'fengling-console'
|
|
||||||
const redirectUri = import.meta.env.VITE_REDIRECT_URI || 'http://localhost:5173/auth/callback'
|
|
||||||
const scope = 'api offline_access'
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
response_type: 'code',
|
|
||||||
client_id: clientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
scope: scope,
|
|
||||||
state: Date.now().toString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const authServiceUrl = import.meta.env.VITE_AUTH_SERVICE_URL || 'http://localhost:5000'
|
|
||||||
window.location.href = `${authServiceUrl}/connect/authorize?${params.toString()}`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default authAxios
|
|
||||||
49
src/Fengling.Console.Web/src/api/index.ts
Normal file
49
src/Fengling.Console.Web/src/api/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { useAuthStore } = await import('@/stores/auth')
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
await authStore.refresh()
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
return api(originalRequest)
|
||||||
|
} catch (refreshError) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
localStorage.removeItem('user_info')
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
@ -1,5 +1,15 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
@ -17,45 +17,45 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Dashboard',
|
|
||||||
component: () => import('@/views/Gateway/Dashboard.vue'),
|
component: () => import('@/views/Gateway/Dashboard.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
children: [
|
||||||
{
|
{
|
||||||
path: '/tenants',
|
path: '',
|
||||||
name: 'TenantList',
|
name: 'Dashboard',
|
||||||
component: () => import('@/views/Gateway/TenantList.vue'),
|
component: () => import('@/views/Dashboard/Dashboard.vue'),
|
||||||
meta: { requiresAuth: true },
|
},
|
||||||
},
|
{
|
||||||
{
|
path: 'oauth/clients',
|
||||||
path: '/tenants/:tenantId/routes',
|
name: 'OAuthClients',
|
||||||
name: 'TenantRoutes',
|
component: () => import('@/views/OAuth/ClientList.vue'),
|
||||||
component: () => import('@/views/Gateway/TenantRoutes.vue'),
|
},
|
||||||
meta: { requiresAuth: true },
|
{
|
||||||
},
|
path: 'users',
|
||||||
{
|
name: 'UserList',
|
||||||
path: '/clusters',
|
component: () => import('@/views/Users/UserList.vue'),
|
||||||
name: 'ClusterInstances',
|
},
|
||||||
component: () => import('@/views/Gateway/ClusterInstances.vue'),
|
{
|
||||||
meta: { requiresAuth: true },
|
path: 'roles',
|
||||||
},
|
name: 'RoleList',
|
||||||
{
|
component: () => import('@/views/Users/RoleList.vue'),
|
||||||
path: '/routes/global',
|
},
|
||||||
name: 'GlobalRoutes',
|
{
|
||||||
component: () => import('@/views/Gateway/GlobalRoutes.vue'),
|
path: 'tenants',
|
||||||
meta: { requiresAuth: true },
|
name: 'TenantList',
|
||||||
},
|
component: () => import('@/views/Users/TenantList.vue'),
|
||||||
{
|
},
|
||||||
path: '/oauth/clients',
|
{
|
||||||
name: 'OAuthClients',
|
path: 'logs/access',
|
||||||
component: () => import('@/views/OAuth/ClientList.vue'),
|
name: 'AccessLog',
|
||||||
meta: { requiresAuth: true },
|
component: () => import('@/views/Audit/AccessLog.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/users',
|
path: 'logs/audit',
|
||||||
name: 'UserList',
|
name: 'AuditLog',
|
||||||
component: () => import('@/views/Users/UserList.vue'),
|
component: () => import('@/views/Audit/AuditLog.vue'),
|
||||||
meta: { requiresAuth: true },
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -64,12 +64,16 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated.value) {
|
if (!authStore.accessToken && !authStore.user) {
|
||||||
|
await authStore.loadFromStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
next({ name: 'Login' })
|
next({ name: 'Login' })
|
||||||
} else if (to.name === 'Login' && authStore.isAuthenticated.value) {
|
} else if (to.name === 'Login' && authStore.isAuthenticated) {
|
||||||
next({ name: 'Dashboard' })
|
next({ name: 'Dashboard' })
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
|
|||||||
87
src/Fengling.Console.Web/src/services/oidc.ts
Normal file
87
src/Fengling.Console.Web/src/services/oidc.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { UserManager, User } from 'oidc-client-ts'
|
||||||
|
|
||||||
|
const AUTH_SERVER = import.meta.env.VITE_AUTH_SERVER_URL || 'http://localhost:5000'
|
||||||
|
const REDIRECT_URI = import.meta.env.VITE_REDIRECT_URI || window.location.origin + '/auth/callback'
|
||||||
|
const CLIENT_ID = import.meta.env.VITE_CLIENT_ID || 'fengling-console'
|
||||||
|
|
||||||
|
const userManager = new UserManager({
|
||||||
|
authority: AUTH_SERVER,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile api offline_access',
|
||||||
|
automaticSilentRenew: true,
|
||||||
|
silent_redirect_uri: `${window.location.origin}/silent-renew.html`,
|
||||||
|
loadUserInfo: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
sub?: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
tenant_id?: string
|
||||||
|
role?: string | string[]
|
||||||
|
preferred_username?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_at: number
|
||||||
|
profile: UserInfo | null
|
||||||
|
expired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserToAuthUser(user: User | null): AuthUser | null {
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: user.access_token,
|
||||||
|
refresh_token: user.refresh_token || '',
|
||||||
|
token_type: user.token_type || 'Bearer',
|
||||||
|
expires_at: user.expires_at || 0,
|
||||||
|
profile: (user.profile as UserInfo) || null,
|
||||||
|
expired: user.expired ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
async login(): Promise<void> {
|
||||||
|
await userManager.signinRedirect()
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleCallback(): Promise<AuthUser> {
|
||||||
|
const user = await userManager.signinCallback()
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('登录失败:无效的用户信息')
|
||||||
|
}
|
||||||
|
return mapUserToAuthUser(user)!
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUser(): Promise<AuthUser | null> {
|
||||||
|
const user = await userManager.getUser()
|
||||||
|
return mapUserToAuthUser(user)
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh(): Promise<AuthUser> {
|
||||||
|
const user = await userManager.signinSilent()
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('刷新令牌失败')
|
||||||
|
}
|
||||||
|
return mapUserToAuthUser(user)!
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await userManager.signoutRedirect()
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeUser(): Promise<void> {
|
||||||
|
await userManager.removeUser()
|
||||||
|
},
|
||||||
|
|
||||||
|
async isAuthenticated(): Promise<boolean> {
|
||||||
|
const user = await userManager.getUser()
|
||||||
|
return user !== null && !user.expired
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -1,43 +1,70 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { authService, type AuthUser } from '@/services/oidc'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number
|
id: string
|
||||||
userName: string
|
userName: string
|
||||||
email: string
|
email: string
|
||||||
realName: string
|
tenantId: string
|
||||||
tenantId: number
|
|
||||||
roles: string[]
|
roles: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessToken = ref<string | null>(null)
|
const accessToken = ref<string>('')
|
||||||
const refreshToken = ref<string | null>(null)
|
const refreshToken = ref<string>('')
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const isAuthenticated = computed(() => !!accessToken.value)
|
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
||||||
|
|
||||||
function setTokens(accessTokenValue: string, refreshTokenValue: string) {
|
function setTokens(authUser: AuthUser) {
|
||||||
accessToken.value = accessTokenValue
|
accessToken.value = authUser.access_token
|
||||||
refreshToken.value = refreshTokenValue
|
refreshToken.value = authUser.refresh_token
|
||||||
localStorage.setItem('access_token', accessTokenValue)
|
|
||||||
localStorage.setItem('refresh_token', refreshTokenValue)
|
if (authUser.profile) {
|
||||||
|
user.value = {
|
||||||
|
id: authUser.profile.sub || '',
|
||||||
|
userName: authUser.profile.preferred_username || authUser.profile.name || '',
|
||||||
|
email: authUser.profile.email || '',
|
||||||
|
tenantId: authUser.profile.tenant_id || '1',
|
||||||
|
roles: Array.isArray(authUser.profile.role) ? authUser.profile.role : (authUser.profile.role ? [authUser.profile.role] : []),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', authUser.access_token)
|
||||||
|
localStorage.setItem('refresh_token', authUser.refresh_token)
|
||||||
|
localStorage.setItem('user_info', JSON.stringify(user.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUser(userData: User) {
|
async function login() {
|
||||||
user.value = userData
|
await authService.login()
|
||||||
localStorage.setItem('user_info', JSON.stringify(userData))
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const authUser = await authService.refresh()
|
||||||
|
setTokens(authUser)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed:', error)
|
||||||
|
clearAuth()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await authService.logout()
|
||||||
|
clearAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAuth() {
|
function clearAuth() {
|
||||||
accessToken.value = null
|
accessToken.value = ''
|
||||||
refreshToken.value = null
|
refreshToken.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
localStorage.removeItem('refresh_token')
|
localStorage.removeItem('refresh_token')
|
||||||
localStorage.removeItem('user_info')
|
localStorage.removeItem('user_info')
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage() {
|
async function loadFromStorage() {
|
||||||
const storedToken = localStorage.getItem('access_token')
|
const storedToken = localStorage.getItem('access_token')
|
||||||
const storedRefreshToken = localStorage.getItem('refresh_token')
|
const storedRefreshToken = localStorage.getItem('refresh_token')
|
||||||
const storedUser = localStorage.getItem('user_info')
|
const storedUser = localStorage.getItem('user_info')
|
||||||
@ -49,7 +76,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
refreshToken.value = storedRefreshToken
|
refreshToken.value = storedRefreshToken
|
||||||
}
|
}
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
user.value = JSON.parse(storedUser)
|
try {
|
||||||
|
user.value = JSON.parse(storedUser)
|
||||||
|
} catch {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = await authService.getUser()
|
||||||
|
if (authUser && !authUser.expired) {
|
||||||
|
setTokens(authUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,8 +94,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
user,
|
user,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
refresh,
|
||||||
|
logout,
|
||||||
setTokens,
|
setTokens,
|
||||||
setUser,
|
|
||||||
clearAuth,
|
clearAuth,
|
||||||
loadFromStorage,
|
loadFromStorage,
|
||||||
}
|
}
|
||||||
|
|||||||
346
src/Fengling.Console.Web/src/views/Audit/AccessLog.vue
Normal file
346
src/Fengling.Console.Web/src/views/Audit/AccessLog.vue
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
<template>
|
||||||
|
<div class="access-log">
|
||||||
|
<el-container>
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="header-content">
|
||||||
|
<h2>访问日志</h2>
|
||||||
|
<el-button type="primary" @click="handleExport">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" style="margin-bottom: 20px">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="searchForm.userName" placeholder="请输入用户名" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户ID">
|
||||||
|
<el-input v-model="searchForm.tenantId" placeholder="请输入租户ID" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作类型">
|
||||||
|
<el-select v-model="searchForm.action" placeholder="全部" clearable>
|
||||||
|
<el-option label="登录" value="login" />
|
||||||
|
<el-option label="登出" value="logout" />
|
||||||
|
<el-option label="创建" value="create" />
|
||||||
|
<el-option label="更新" value="update" />
|
||||||
|
<el-option label="删除" value="delete" />
|
||||||
|
<el-option label="查询" value="query" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="searchForm.status" placeholder="全部" clearable>
|
||||||
|
<el-option label="成功" value="success" />
|
||||||
|
<el-option label="失败" value="failure" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchLogs">查询</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="logs" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="userName" label="用户名" width="120" />
|
||||||
|
<el-table-column prop="tenantId" label="租户ID" width="100" />
|
||||||
|
<el-table-column prop="action" label="操作类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getActionType(row.action)" size="small">
|
||||||
|
{{ getActionText(row.action) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="resource" label="资源" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="word-break: break-all">{{ row.resource }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="method" label="方法" width="80" />
|
||||||
|
<el-table-column prop="ipAddress" label="IP地址" width="140" />
|
||||||
|
<el-table-column prop="userAgent" label="User Agent" width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="duration" label="耗时(ms)" width="90" />
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="handleViewDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
@current-change="fetchLogs"
|
||||||
|
@size-change="fetchLogs"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
style="margin-top: 20px"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-dialog v-model="detailDialogVisible" title="日志详情" width="800px">
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="日志ID">{{ currentLog.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户名">{{ currentLog.userName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="租户ID">{{ currentLog.tenantId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作类型">
|
||||||
|
<el-tag :type="getActionType(currentLog.action)" size="small">
|
||||||
|
{{ getActionText(currentLog.action) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="资源" :span="2">{{ currentLog.resource }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="方法">{{ currentLog.method }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="IP地址">{{ currentLog.ipAddress }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="currentLog.status === 'success' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ currentLog.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="耗时">{{ currentLog.duration }}ms</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="时间" :span="2">{{ formatDate(currentLog.createdAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="User Agent" :span="2">
|
||||||
|
<div style="max-height: 100px; overflow-y: auto; word-break: break-all">
|
||||||
|
{{ currentLog.userAgent }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="请求参数" :span="2" v-if="currentLog.requestData">
|
||||||
|
<pre style="max-height: 200px; overflow-y: auto; background: #f5f7fa; padding: 10px; border-radius: 4px; font-size: 12px">{{ formatJson(currentLog.requestData) }}</pre>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="响应数据" :span="2" v-if="currentLog.responseData">
|
||||||
|
<pre style="max-height: 200px; overflow-y: auto; background: #f5f7fa; padding: 10px; border-radius: 4px; font-size: 12px">{{ formatJson(currentLog.responseData) }}</pre>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="错误信息" :span="2" v-if="currentLog.errorMessage">
|
||||||
|
<div style="color: #f56c6c; max-height: 150px; overflow-y: auto">
|
||||||
|
{{ currentLog.errorMessage }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Download } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
interface AccessLog {
|
||||||
|
id: number
|
||||||
|
userName: string
|
||||||
|
tenantId?: string
|
||||||
|
action: string
|
||||||
|
resource: string
|
||||||
|
method: string
|
||||||
|
ipAddress: string
|
||||||
|
userAgent?: string
|
||||||
|
status: 'success' | 'failure'
|
||||||
|
duration: number
|
||||||
|
requestData?: string
|
||||||
|
responseData?: string
|
||||||
|
errorMessage?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = ref<AccessLog[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const currentLog = ref<AccessLog>({} as AccessLog)
|
||||||
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
userName: '',
|
||||||
|
tenantId: '',
|
||||||
|
action: '',
|
||||||
|
status: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionTypeMap: Record<string, string> = {
|
||||||
|
login: 'success',
|
||||||
|
logout: 'info',
|
||||||
|
create: 'primary',
|
||||||
|
update: 'warning',
|
||||||
|
delete: 'danger',
|
||||||
|
query: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTextMap: Record<string, string> = {
|
||||||
|
login: '登录',
|
||||||
|
logout: '登出',
|
||||||
|
create: '创建',
|
||||||
|
update: '更新',
|
||||||
|
delete: '删除',
|
||||||
|
query: '查询',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionType = (action: string) => {
|
||||||
|
return actionTypeMap[action] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionText = (action: string) => {
|
||||||
|
return actionTextMap[action] || action
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJson = (jsonString: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(jsonString), null, 2)
|
||||||
|
} catch {
|
||||||
|
return jsonString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (dateRange.value) {
|
||||||
|
searchForm.startTime = dateRange.value[0]
|
||||||
|
searchForm.endTime = dateRange.value[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.value.toString(),
|
||||||
|
pageSize: pageSize.value.toString(),
|
||||||
|
...searchForm,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`/api/auth/access-logs?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('获取访问日志失败')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
logs.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||||
|
total.value = data.totalCount || logs.value.length
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取访问日志失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewDetail = (row: AccessLog) => {
|
||||||
|
currentLog.value = row
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
ElMessage.info('正在导出,请稍候...')
|
||||||
|
|
||||||
|
if (dateRange.value) {
|
||||||
|
searchForm.startTime = dateRange.value[0]
|
||||||
|
searchForm.endTime = dateRange.value[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...searchForm,
|
||||||
|
export: 'true',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`/api/auth/access-logs/export?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('导出失败')
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `access-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('导出失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, {
|
||||||
|
userName: '',
|
||||||
|
tenantId: '',
|
||||||
|
action: '',
|
||||||
|
status: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
})
|
||||||
|
dateRange.value = null
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLogs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.access-log {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
377
src/Fengling.Console.Web/src/views/Audit/AuditLog.vue
Normal file
377
src/Fengling.Console.Web/src/views/Audit/AuditLog.vue
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
<template>
|
||||||
|
<div class="audit-log">
|
||||||
|
<el-container>
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="header-content">
|
||||||
|
<h2>审计日志</h2>
|
||||||
|
<el-button type="primary" @click="handleExport">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" style="margin-bottom: 20px">
|
||||||
|
<el-form-item label="操作人">
|
||||||
|
<el-input v-model="searchForm.operator" placeholder="请输入操作人" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户ID">
|
||||||
|
<el-input v-model="searchForm.tenantId" placeholder="请输入租户ID" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作类型">
|
||||||
|
<el-select v-model="searchForm.operation" placeholder="全部" clearable>
|
||||||
|
<el-option label="用户管理" value="user" />
|
||||||
|
<el-option label="角色管理" value="role" />
|
||||||
|
<el-option label="租户管理" value="tenant" />
|
||||||
|
<el-option label="OAuth应用" value="oauth" />
|
||||||
|
<el-option label="系统配置" value="system" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作动作">
|
||||||
|
<el-select v-model="searchForm.action" placeholder="全部" clearable>
|
||||||
|
<el-option label="创建" value="create" />
|
||||||
|
<el-option label="更新" value="update" />
|
||||||
|
<el-option label="删除" value="delete" />
|
||||||
|
<el-option label="启用" value="enable" />
|
||||||
|
<el-option label="禁用" value="disable" />
|
||||||
|
<el-option label="重置密码" value="reset_password" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchLogs">查询</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="logs" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="operator" label="操作人" width="120" />
|
||||||
|
<el-table-column prop="tenantId" label="租户ID" width="100" />
|
||||||
|
<el-table-column prop="operation" label="操作类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getOperationType(row.operation)" size="small">
|
||||||
|
{{ getOperationText(row.operation) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="action" label="操作动作" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getActionType(row.action)" size="small">
|
||||||
|
{{ getActionText(row.action) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="targetType" label="目标类型" width="100" />
|
||||||
|
<el-table-column prop="targetId" label="目标ID" width="100" />
|
||||||
|
<el-table-column prop="targetName" label="目标名称" width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="ipAddress" label="IP地址" width="140" />
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="handleViewDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
@current-change="fetchLogs"
|
||||||
|
@size-change="fetchLogs"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
style="margin-top: 20px"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-dialog v-model="detailDialogVisible" title="审计日志详情" width="900px">
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="日志ID">{{ currentLog.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作人">{{ currentLog.operator }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="租户ID">{{ currentLog.tenantId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作类型">
|
||||||
|
<el-tag :type="getOperationType(currentLog.operation)" size="small">
|
||||||
|
{{ getOperationText(currentLog.operation) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作动作">
|
||||||
|
<el-tag :type="getActionType(currentLog.action)" size="small">
|
||||||
|
{{ getActionText(currentLog.action) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="currentLog.status === 'success' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ currentLog.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="目标类型">{{ currentLog.targetType }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="目标ID">{{ currentLog.targetId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="目标名称" :span="2">{{ currentLog.targetName || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="IP地址" :span="2">{{ currentLog.ipAddress }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="时间" :span="2">{{ formatDate(currentLog.createdAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作描述" :span="2">{{ currentLog.description }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="变更前数据" :span="2" v-if="currentLog.oldValue">
|
||||||
|
<pre style="max-height: 200px; overflow-y: auto; background: #f5f7fa; padding: 10px; border-radius: 4px; font-size: 12px">{{ formatJson(currentLog.oldValue) }}</pre>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="变更后数据" :span="2" v-if="currentLog.newValue">
|
||||||
|
<pre style="max-height: 200px; overflow-y: auto; background: #f5f7fa; padding: 10px; border-radius: 4px; font-size: 12px">{{ formatJson(currentLog.newValue) }}</pre>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="错误信息" :span="2" v-if="currentLog.errorMessage">
|
||||||
|
<div style="color: #f56c6c; max-height: 150px; overflow-y: auto">
|
||||||
|
{{ currentLog.errorMessage }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Download } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: number
|
||||||
|
operator: string
|
||||||
|
tenantId?: string
|
||||||
|
operation: string
|
||||||
|
action: string
|
||||||
|
targetType: string
|
||||||
|
targetId?: string
|
||||||
|
targetName?: string
|
||||||
|
ipAddress: string
|
||||||
|
description?: string
|
||||||
|
oldValue?: string
|
||||||
|
newValue?: string
|
||||||
|
errorMessage?: string
|
||||||
|
status: 'success' | 'failure'
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = ref<AuditLog[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const currentLog = ref<AuditLog>({} as AuditLog)
|
||||||
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
operator: '',
|
||||||
|
tenantId: '',
|
||||||
|
operation: '',
|
||||||
|
action: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const operationTypeMap: Record<string, string> = {
|
||||||
|
user: 'primary',
|
||||||
|
role: 'success',
|
||||||
|
tenant: 'warning',
|
||||||
|
oauth: 'danger',
|
||||||
|
system: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationTextMap: Record<string, string> = {
|
||||||
|
user: '用户管理',
|
||||||
|
role: '角色管理',
|
||||||
|
tenant: '租户管理',
|
||||||
|
oauth: 'OAuth应用',
|
||||||
|
system: '系统配置',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypeMap: Record<string, string> = {
|
||||||
|
create: 'primary',
|
||||||
|
update: 'warning',
|
||||||
|
delete: 'danger',
|
||||||
|
enable: 'success',
|
||||||
|
disable: 'info',
|
||||||
|
reset_password: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTextMap: Record<string, string> = {
|
||||||
|
create: '创建',
|
||||||
|
update: '更新',
|
||||||
|
delete: '删除',
|
||||||
|
enable: '启用',
|
||||||
|
disable: '禁用',
|
||||||
|
reset_password: '重置密码',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperationType = (operation: string) => {
|
||||||
|
return operationTypeMap[operation] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperationText = (operation: string) => {
|
||||||
|
return operationTextMap[operation] || operation
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionType = (action: string) => {
|
||||||
|
return actionTypeMap[action] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionText = (action: string) => {
|
||||||
|
return actionTextMap[action] || action
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJson = (jsonString: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(jsonString), null, 2)
|
||||||
|
} catch {
|
||||||
|
return jsonString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (dateRange.value) {
|
||||||
|
searchForm.startTime = dateRange.value[0]
|
||||||
|
searchForm.endTime = dateRange.value[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.value.toString(),
|
||||||
|
pageSize: pageSize.value.toString(),
|
||||||
|
...searchForm,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`/api/auth/audit-logs?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('获取审计日志失败')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
logs.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||||
|
total.value = data.totalCount || logs.value.length
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取审计日志失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewDetail = (row: AuditLog) => {
|
||||||
|
currentLog.value = row
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
ElMessage.info('正在导出,请稍候...')
|
||||||
|
|
||||||
|
if (dateRange.value) {
|
||||||
|
searchForm.startTime = dateRange.value[0]
|
||||||
|
searchForm.endTime = dateRange.value[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...searchForm,
|
||||||
|
export: 'true',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`/api/auth/audit-logs/export?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('导出失败')
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('导出失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, {
|
||||||
|
operator: '',
|
||||||
|
tenantId: '',
|
||||||
|
operation: '',
|
||||||
|
action: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
})
|
||||||
|
dateRange.value = null
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLogs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.audit-log {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,32 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="callback-container">
|
<div class="callback-container">
|
||||||
<el-card>
|
<el-card class="callback-card">
|
||||||
<template #header>
|
<div class="loading-content">
|
||||||
<h2>处理中...</h2>
|
<el-icon class="is-loading" :size="50" color="#409eff"><Loading /></el-icon>
|
||||||
</template>
|
<p>{{ message }}</p>
|
||||||
<el-empty description="正在处理OAuth回调" />
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
import { authService } from '@/services/oidc'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const message = ref('正在处理登录...')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const code = route.query.code as string
|
try {
|
||||||
|
const authUser = await authService.handleCallback()
|
||||||
if (code) {
|
if (authUser && !authUser.expired && authUser.access_token) {
|
||||||
ElMessage.success('OAuth登录成功')
|
localStorage.setItem('access_token', authUser.access_token)
|
||||||
setTimeout(() => {
|
if (authUser.refresh_token) {
|
||||||
router.push({ name: 'Login' })
|
localStorage.setItem('refresh_token', authUser.refresh_token)
|
||||||
}, 2000)
|
}
|
||||||
} else {
|
if (authUser.profile) {
|
||||||
ElMessage.error('无效的OAuth回调')
|
localStorage.setItem('user_info', JSON.stringify(authUser.profile))
|
||||||
|
}
|
||||||
|
message.value = '登录成功,正在跳转...'
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({ name: 'Dashboard' })
|
||||||
|
}, 500)
|
||||||
|
} else {
|
||||||
|
throw new Error('登录失败:无效的用户信息')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Callback error:', error)
|
||||||
|
message.value = '登录失败'
|
||||||
|
ElMessage.error(error.message || '登录失败')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push({ name: 'Login' })
|
router.push({ name: 'Login' })
|
||||||
}, 2000)
|
}, 2000)
|
||||||
@ -40,16 +55,34 @@ onMounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f0f2f5;
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-card {
|
.callback-card {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.loading-content {
|
||||||
margin: 0;
|
padding: 40px 20px;
|
||||||
color: #409eff;
|
}
|
||||||
|
|
||||||
|
.is-loading {
|
||||||
|
animation: rotating 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotating {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -4,72 +4,31 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<h2>风铃运管中心</h2>
|
<h2>风铃运管中心</h2>
|
||||||
</template>
|
</template>
|
||||||
<el-form :model="loginForm" :rules="rules" ref="formRef" label-width="80px">
|
<div class="login-content">
|
||||||
<el-form-item label="用户名" prop="username">
|
<p>点击下方按钮跳转到身份认证服务器进行登录</p>
|
||||||
<el-input v-model="loginForm.username" placeholder="请输入用户名" />
|
<el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%; margin-top: 20px;">
|
||||||
</el-form-item>
|
去登录
|
||||||
<el-form-item label="密码" prop="password">
|
</el-button>
|
||||||
<el-input
|
</div>
|
||||||
v-model="loginForm.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
show-password
|
|
||||||
@keyup.enter="handleLogin"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
|
|
||||||
登录
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
|
||||||
import { authService } from '@/api/auth'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const formRef = ref<FormInstance>()
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const loginForm = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const rules: FormRules = {
|
|
||||||
username: [
|
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
|
||||||
{ min: 3, message: '用户名长度不能少于3个字符', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
password: [
|
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
|
||||||
{ min: 6, message: '密码长度不能少于6个字符', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
const valid = await formRef.value?.validate()
|
|
||||||
if (!valid) return
|
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response: any = await authService.login(loginForm.username, loginForm.password)
|
await authStore.login()
|
||||||
|
|
||||||
authStore.setTokens(response.access_token, response.refresh_token)
|
|
||||||
|
|
||||||
ElMessage.success('登录成功')
|
|
||||||
|
|
||||||
router.push({ name: 'Dashboard' })
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error.response?.data?.error_description || '登录失败,请检查用户名和密码')
|
console.error('Login error:', error)
|
||||||
|
ElMessage.error(error.message || '登录失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
257
src/Fengling.Console.Web/src/views/Dashboard/Dashboard.vue
Normal file
257
src/Fengling.Console.Web/src/views/Dashboard/Dashboard.vue
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<el-row :gutter="20" style="margin-bottom: 20px">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card class="stat-card" shadow="hover">
|
||||||
|
<div class="stat-content">
|
||||||
|
<el-icon class="stat-icon" color="#409eff"><User /></el-icon>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ stats.userCount }}</div>
|
||||||
|
<div class="stat-label">总用户数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card class="stat-card" shadow="hover">
|
||||||
|
<div class="stat-content">
|
||||||
|
<el-icon class="stat-icon" color="#67c23a"><OfficeBuilding /></el-icon>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ stats.tenantCount }}</div>
|
||||||
|
<div class="stat-label">租户数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card class="stat-card" shadow="hover">
|
||||||
|
<div class="stat-content">
|
||||||
|
<el-icon class="stat-icon" color="#e6a23c"><Key /></el-icon>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ stats.oauthClientCount }}</div>
|
||||||
|
<div class="stat-label">OAuth应用数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card class="stat-card" shadow="hover">
|
||||||
|
<div class="stat-content">
|
||||||
|
<el-icon class="stat-icon" color="#f56c6c"><Document /></el-icon>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ stats.todayAccessCount }}</div>
|
||||||
|
<div class="stat-label">今日访问</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>最近活动</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="log in recentAuditLogs"
|
||||||
|
:key="log.id"
|
||||||
|
:timestamp="formatTime(log.createdAt)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">{{ log.operator }} {{ getOperationText(log.operation) }} {{ log.targetName }}</div>
|
||||||
|
<div class="timeline-desc">{{ log.action }} - {{ log.targetType }}</div>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
<el-timeline-item v-if="recentAuditLogs.length === 0">
|
||||||
|
暂无活动记录
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>系统信息</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="系统名称">Fengling 认证中心</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="版本号">v1.0.0</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="运行时间">{{ systemUptime }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据库状态">
|
||||||
|
<el-tag type="success">正常</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最后更新">{{ lastUpdateTime }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed, onUnmounted } from 'vue'
|
||||||
|
import { User, OfficeBuilding, Key, Document } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const stats = reactive({
|
||||||
|
userCount: 0,
|
||||||
|
tenantCount: 0,
|
||||||
|
oauthClientCount: 0,
|
||||||
|
todayAccessCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const recentAuditLogs = ref<any[]>([])
|
||||||
|
const lastUpdateTime = ref('')
|
||||||
|
let uptimeTimer: number | null = null
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
const systemUptime = computed(() => {
|
||||||
|
const seconds = Math.floor((Date.now() - startTime) / 1000)
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
return `${days}天 ${hours}小时 ${minutes}分钟`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperationText = (operation: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
user: '用户',
|
||||||
|
role: '角色',
|
||||||
|
tenant: '租户',
|
||||||
|
oauth: 'OAuth应用',
|
||||||
|
system: '系统',
|
||||||
|
}
|
||||||
|
return map[operation] || operation
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/stats/dashboard', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
Object.assign(stats, data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecentAuditLogs = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/audit-logs?pageSize=5', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
recentAuditLogs.value = data.items || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取最近日志失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStats()
|
||||||
|
fetchRecentAuditLogs()
|
||||||
|
lastUpdateTime.value = new Date().toLocaleString('zh-CN')
|
||||||
|
uptimeTimer = window.setInterval(() => {
|
||||||
|
}, 60000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (uptimeTimer) {
|
||||||
|
clearInterval(uptimeTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-timeline-item__timestamp) {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard-layout">
|
||||||
<el-container>
|
<el-container>
|
||||||
<el-aside width="200px">
|
<el-aside width="220px">
|
||||||
|
<div class="logo">
|
||||||
|
<h2>风铃认证中心</h2>
|
||||||
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
class="el-menu-vertical"
|
class="el-menu-vertical"
|
||||||
@ -11,26 +14,34 @@
|
|||||||
<el-icon><Monitor /></el-icon>
|
<el-icon><Monitor /></el-icon>
|
||||||
<span>仪表盘</span>
|
<span>仪表盘</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="TenantList">
|
|
||||||
<el-icon><OfficeBuilding /></el-icon>
|
<el-sub-menu index="user">
|
||||||
<span>租户管理</span>
|
<template #title>
|
||||||
</el-menu-item>
|
<el-icon><User /></el-icon>
|
||||||
<el-menu-item index="ClusterInstances">
|
<span>用户管理</span>
|
||||||
<el-icon><Connection /></el-icon>
|
</template>
|
||||||
<span>集群实例</span>
|
<el-menu-item index="UserList">用户列表</el-menu-item>
|
||||||
</el-menu-item>
|
<el-menu-item index="RoleList">角色管理</el-menu-item>
|
||||||
<el-menu-item index="GlobalRoutes">
|
<el-menu-item index="TenantList">租户管理</el-menu-item>
|
||||||
<el-icon><Share /></el-icon>
|
</el-sub-menu>
|
||||||
<span>全局路由</span>
|
|
||||||
</el-menu-item>
|
<el-sub-menu index="oauth">
|
||||||
<el-menu-item index="OAuthClients">
|
<template #title>
|
||||||
<el-icon><Key /></el-icon>
|
<el-icon><Key /></el-icon>
|
||||||
<span>OAuth应用</span>
|
<span>OAuth管理</span>
|
||||||
</el-menu-item>
|
</template>
|
||||||
<el-menu-item index="UserList">
|
<el-menu-item index="OAuthClients">应用列表</el-menu-item>
|
||||||
<el-icon><User /></el-icon>
|
</el-sub-menu>
|
||||||
<span>用户管理</span>
|
|
||||||
</el-menu-item>
|
<el-sub-menu index="logs">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>日志管理</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="AccessLog">访问日志</el-menu-item>
|
||||||
|
<el-menu-item index="AuditLog">审计日志</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
<el-menu-item index="Logout" @click="handleLogout">
|
<el-menu-item index="Logout" @click="handleLogout">
|
||||||
<el-icon><SwitchButton /></el-icon>
|
<el-icon><SwitchButton /></el-icon>
|
||||||
<span>退出登录</span>
|
<span>退出登录</span>
|
||||||
@ -39,23 +50,24 @@
|
|||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-main>
|
<el-main>
|
||||||
<el-breadcrumb separator="/">
|
<el-breadcrumb separator="/" v-if="route.name !== 'Dashboard'">
|
||||||
<el-breadcrumb-item :to="{ name: 'Dashboard' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ name: 'Dashboard' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>{{ currentBreadcrumb }}</el-breadcrumb-item>
|
<el-breadcrumb-item>{{ currentBreadcrumb }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
|
|
||||||
<router-view />
|
<div class="content-wrapper">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Monitor, OfficeBuilding, Connection, Share, Key, User, SwitchButton } from '@element-plus/icons-vue'
|
import { Monitor, User, Key, Document, SwitchButton } from '@element-plus/icons-vue'
|
||||||
import { authService } from '@/api/auth'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -66,12 +78,12 @@ const activeMenu = ref('Dashboard')
|
|||||||
const currentBreadcrumb = computed(() => {
|
const currentBreadcrumb = computed(() => {
|
||||||
const breadcrumbMap: Record<string, string> = {
|
const breadcrumbMap: Record<string, string> = {
|
||||||
Dashboard: '仪表盘',
|
Dashboard: '仪表盘',
|
||||||
|
UserList: '用户列表',
|
||||||
|
RoleList: '角色管理',
|
||||||
TenantList: '租户管理',
|
TenantList: '租户管理',
|
||||||
TenantRoutes: '租户路由',
|
OAuthClients: '应用列表',
|
||||||
ClusterInstances: '集群实例',
|
AccessLog: '访问日志',
|
||||||
GlobalRoutes: '全局路由',
|
AuditLog: '审计日志',
|
||||||
OAuthClients: 'OAuth应用',
|
|
||||||
UserList: '用户管理',
|
|
||||||
}
|
}
|
||||||
return breadcrumbMap[route.name as string] || ''
|
return breadcrumbMap[route.name as string] || ''
|
||||||
})
|
})
|
||||||
@ -81,13 +93,13 @@ const handleMenuSelect = (index: string) => {
|
|||||||
handleLogout()
|
handleLogout()
|
||||||
} else {
|
} else {
|
||||||
activeMenu.value = index
|
activeMenu.value = index
|
||||||
|
router.push({ name: index })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await authService.logout()
|
await authStore.logout()
|
||||||
authStore.clearAuth()
|
|
||||||
ElMessage.success('退出成功')
|
ElMessage.success('退出成功')
|
||||||
router.push({ name: 'Login' })
|
router.push({ name: 'Login' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -95,10 +107,18 @@ const handleLogout = async () => {
|
|||||||
ElMessage.error('退出失败')
|
ElMessage.error('退出失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => route.name, (newName) => {
|
||||||
|
activeMenu.value = newName as string
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
activeMenu.value = route.name as string
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard {
|
.dashboard-layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,20 +127,66 @@ const handleLogout = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-aside {
|
.el-aside {
|
||||||
background-color: #545c64;
|
background-color: #304156;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid #434a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.el-menu {
|
.el-menu {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-vertical:not(.el-menu--collapse) {
|
.el-menu-item {
|
||||||
width: 200px;
|
color: #bfcbd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu-item:hover {
|
||||||
|
background-color: #263445 !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu-item.is-active {
|
||||||
|
background-color: #409eff !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-sub-menu__title) {
|
||||||
|
color: #bfcbd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-sub-menu__title:hover) {
|
||||||
|
background-color: #263445 !important;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-main {
|
.el-main {
|
||||||
background-color: #f0f2f5;
|
background-color: #f0f2f5;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-breadcrumb) {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #e6e6e6;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -12,20 +12,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="clients" style="width: 100%">
|
<el-form :inline="true" :model="searchForm" style="margin-bottom: 20px">
|
||||||
<el-table-column prop="displayName" label="应用名称" />
|
<el-form-item label="应用名称">
|
||||||
<el-table-column prop="clientId" label="Client ID" />
|
<el-input v-model="searchForm.displayName" placeholder="请输入应用名称" clearable />
|
||||||
<el-table-column prop="clientType" label="类型" width="100" />
|
</el-form-item>
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-form-item label="Client ID">
|
||||||
|
<el-input v-model="searchForm.clientId" placeholder="请输入Client ID" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="searchForm.status" placeholder="全部" clearable>
|
||||||
|
<el-option label="活跃" value="active" />
|
||||||
|
<el-option label="禁用" value="inactive" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchClients">查询</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="clients" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="displayName" label="应用名称" width="150" />
|
||||||
|
<el-table-column prop="clientId" label="Client ID" width="180" />
|
||||||
|
<el-table-column prop="clientType" label="类型" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">
|
<el-tag :type="row.clientType === 'confidential' ? 'primary' : 'info'" size="small">
|
||||||
|
{{ row.clientType === 'confidential' ? '机密' : '公开' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="grantTypes" label="授权类型" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="type in row.grantTypes" :key="type" size="small" style="margin-right: 5px">
|
||||||
|
{{ getGrantTypeText(type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||||
{{ row.status === 'active' ? '活跃' : '禁用' }}
|
{{ row.status === 'active' ? '活跃' : '禁用' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200">
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" @click="handleViewSecret(row)">密钥</el-button>
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -36,38 +75,75 @@
|
|||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:total="total"
|
:total="total"
|
||||||
@current-change="fetchClients"
|
@current-change="fetchClients"
|
||||||
layout="total, prev, pager, next"
|
@size-change="fetchClients"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
style="margin-top: 20px"
|
style="margin-top: 20px"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px">
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="140px">
|
||||||
<el-form-item label="应用名称" prop="displayName">
|
<el-form-item label="应用名称" prop="displayName">
|
||||||
<el-input v-model="form.displayName" />
|
<el-input v-model="form.displayName" placeholder="如:风铃管理后台" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Client ID" prop="clientId">
|
<el-form-item label="Client ID" prop="clientId">
|
||||||
<el-input v-model="form.clientId" />
|
<el-input v-model="form.clientId" :disabled="isEdit" placeholder="英文标识,如:admin-console" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Client Secret" prop="clientSecret">
|
<el-form-item label="Client Secret" prop="clientSecret" v-if="!isEdit">
|
||||||
<el-input v-model="form.clientSecret" type="password" show-password />
|
<el-input v-model="form.clientSecret" type="password" show-password placeholder="至少16位" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="重定向URI" prop="redirectUris">
|
<el-form-item label="重定向URI" prop="redirectUrisString">
|
||||||
<el-input v-model="form.redirectUrisString" placeholder="多个URI用逗号分隔" />
|
<el-input
|
||||||
|
v-model="form.redirectUrisString"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="每行一个URI,如: http://localhost:3000/auth/callback http://localhost:3000/callback"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="登出重定向URI" prop="postLogoutRedirectUrisString">
|
||||||
|
<el-input
|
||||||
|
v-model="form.postLogoutRedirectUrisString"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="每行一个URI http://localhost:3000/"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="授权类型" prop="grantTypes">
|
<el-form-item label="授权类型" prop="grantTypes">
|
||||||
<el-select v-model="form.grantTypes" multiple>
|
<el-checkbox-group v-model="form.grantTypes">
|
||||||
<el-option label="授权码" value="authorization_code" />
|
<el-checkbox label="authorization_code">授权码模式</el-checkbox>
|
||||||
<el-option label="密码" value="password" />
|
<el-checkbox label="password">密码模式</el-checkbox>
|
||||||
<el-option label="刷新令牌" value="refresh_token" />
|
<el-checkbox label="client_credentials">客户端凭证模式</el-checkbox>
|
||||||
|
<el-checkbox label="refresh_token">刷新令牌</el-checkbox>
|
||||||
|
<el-checkbox label="implicit">简化模式</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="权限范围" prop="scopesString">
|
||||||
|
<el-input
|
||||||
|
v-model="form.scopesString"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="每行一个范围,如: api offline_access"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="客户端类型" prop="clientType">
|
||||||
|
<el-radio-group v-model="form.clientType">
|
||||||
|
<el-radio label="confidential">机密客户端(有Secret)</el-radio>
|
||||||
|
<el-radio label="public">公开客户端(无Secret)</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="许可类型" prop="consentType">
|
||||||
|
<el-select v-model="form.consentType" style="width: 100%">
|
||||||
|
<el-option label="无需许可(隐式)" value="implicit" />
|
||||||
|
<el-option label="需要用户同意" value="explicit" />
|
||||||
|
<el-option label="系统自动同意" value="systematic" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-select v-model="form.status">
|
<el-switch v-model="form.status" active-value="active" inactive-value="inactive" active-text="活跃" inactive-text="禁用" />
|
||||||
<el-option label="活跃" value="active" />
|
</el-form-item>
|
||||||
<el-option label="禁用" value="inactive" />
|
<el-form-item label="描述" prop="description">
|
||||||
</el-select>
|
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -75,23 +151,67 @@
|
|||||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="secretDialogVisible" title="Client Secret" width="500px">
|
||||||
|
<el-alert
|
||||||
|
title="请妥善保存此密钥,关闭后无法再次查看"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 20px"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="currentClientSecret"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
readonly
|
||||||
|
style="font-family: monospace"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="secretDialogVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="handleCopySecret">复制</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Plus } from '@element-plus/icons-vue'
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
import { authService } from '@/api/auth'
|
|
||||||
|
|
||||||
const clients = ref<any[]>([])
|
interface OAuthClient {
|
||||||
|
id: number
|
||||||
|
clientId: string
|
||||||
|
clientSecret?: string
|
||||||
|
displayName: string
|
||||||
|
redirectUris: string[]
|
||||||
|
postLogoutRedirectUris: string[]
|
||||||
|
scopes: string[]
|
||||||
|
grantTypes: string[]
|
||||||
|
clientType: string
|
||||||
|
consentType: string
|
||||||
|
status: string
|
||||||
|
description?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = ref<OAuthClient[]>([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
|
const secretDialogVisible = ref(false)
|
||||||
const dialogTitle = ref('添加OAuth应用')
|
const dialogTitle = ref('添加OAuth应用')
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
|
const currentClientSecret = ref('')
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
displayName: '',
|
||||||
|
clientId: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
@ -99,27 +219,87 @@ const form = reactive({
|
|||||||
clientId: '',
|
clientId: '',
|
||||||
clientSecret: '',
|
clientSecret: '',
|
||||||
redirectUrisString: '',
|
redirectUrisString: '',
|
||||||
|
postLogoutRedirectUrisString: '',
|
||||||
|
scopesString: 'api\noffline_access',
|
||||||
grantTypes: ['authorization_code', 'refresh_token'],
|
grantTypes: ['authorization_code', 'refresh_token'],
|
||||||
|
clientType: 'confidential',
|
||||||
|
consentType: 'implicit',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
displayName: [
|
displayName: [
|
||||||
{ required: true, message: '请输入应用名称', trigger: 'blur' },
|
{ required: true, message: '请输入应用名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 50, message: '应用名称长度2-50个字符', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
clientId: [
|
clientId: [
|
||||||
{ required: true, message: '请输入Client ID', trigger: 'blur' },
|
{ required: true, message: '请输入Client ID', trigger: 'blur' },
|
||||||
|
{ pattern: /^[a-zA-Z0-9_-]+$/, message: 'Client ID只能包含字母、数字、下划线和连字符', trigger: 'blur' },
|
||||||
|
{ min: 4, max: 50, message: 'Client ID长度4-50个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
clientSecret: [
|
||||||
|
{ required: true, message: '请输入Client Secret', trigger: 'blur' },
|
||||||
|
{ min: 16, message: 'Client Secret至少16位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
grantTypes: [
|
||||||
|
{
|
||||||
|
type: 'array',
|
||||||
|
required: true,
|
||||||
|
message: '请至少选择一种授权类型',
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getGrantTypeText = (type: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
authorization_code: '授权码',
|
||||||
|
password: '密码',
|
||||||
|
client_credentials: '客户端凭证',
|
||||||
|
refresh_token: '刷新令牌',
|
||||||
|
implicit: '简化',
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const fetchClients = async () => {
|
const fetchClients = async () => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await authService.getClients()
|
const params = new URLSearchParams({
|
||||||
clients.value = Array.isArray(response) ? response : []
|
page: currentPage.value.toString(),
|
||||||
total.value = clients.value.length
|
pageSize: pageSize.value.toString(),
|
||||||
|
...searchForm,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`/api/auth/oauthclients?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('获取OAuth应用列表失败')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
clients.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||||
|
total.value = data.totalCount || clients.value.length
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('获取OAuth应用列表失败')
|
ElMessage.error('获取OAuth应用列表失败')
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,35 +312,85 @@ const handleAdd = () => {
|
|||||||
clientId: '',
|
clientId: '',
|
||||||
clientSecret: '',
|
clientSecret: '',
|
||||||
redirectUrisString: '',
|
redirectUrisString: '',
|
||||||
|
postLogoutRedirectUrisString: '',
|
||||||
|
scopesString: 'api\noffline_access',
|
||||||
grantTypes: ['authorization_code', 'refresh_token'],
|
grantTypes: ['authorization_code', 'refresh_token'],
|
||||||
|
clientType: 'confidential',
|
||||||
|
consentType: 'implicit',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
description: '',
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (row: any) => {
|
const handleEdit = (row: OAuthClient) => {
|
||||||
dialogTitle.value = '编辑OAuth应用'
|
dialogTitle.value = '编辑OAuth应用'
|
||||||
isEdit.value = true
|
isEdit.value = true
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
clientId: row.clientId,
|
clientId: row.clientId,
|
||||||
clientSecret: row.clientSecret || '',
|
clientSecret: '',
|
||||||
redirectUrisString: Array.isArray(row.redirectUris) ? row.redirectUris.join(',') : '',
|
redirectUrisString: Array.isArray(row.redirectUris) ? row.redirectUris.join('\n') : '',
|
||||||
grantTypes: row.grantTypes,
|
postLogoutRedirectUrisString: Array.isArray(row.postLogoutRedirectUris) ? row.postLogoutRedirectUris.join('\n') : '',
|
||||||
|
scopesString: Array.isArray(row.scopes) ? row.scopes.join('\n') : '',
|
||||||
|
grantTypes: row.grantTypes || [],
|
||||||
|
clientType: row.clientType,
|
||||||
|
consentType: row.consentType,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
description: row.description || '',
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (row: any) => {
|
const handleViewSecret = async (row: OAuthClient) => {
|
||||||
try {
|
try {
|
||||||
await authService.deleteClient(row.id)
|
const response = await fetch(`/api/auth/oauthclients/${row.id}/secret`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('获取密钥失败')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
currentClientSecret.value = data.clientSecret || ''
|
||||||
|
secretDialogVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取密钥失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopySecret = () => {
|
||||||
|
navigator.clipboard.writeText(currentClientSecret.value)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: OAuthClient) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除应用 "${row.displayName}" 吗?此操作不可恢复。`,
|
||||||
|
'确认删除',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await fetch(`/api/auth/oauthclients/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('删除失败')
|
||||||
|
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
await fetchClients()
|
await fetchClients()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('删除失败')
|
if (error !== 'cancel') {
|
||||||
console.error(error)
|
ElMessage.error('删除失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,27 +398,80 @@ const handleSubmit = async () => {
|
|||||||
const valid = await formRef.value?.validate()
|
const valid = await formRef.value?.validate()
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
||||||
|
const redirectUris = form.redirectUrisString
|
||||||
|
.split('\n')
|
||||||
|
.map(uri => uri.trim())
|
||||||
|
.filter(uri => uri.length > 0)
|
||||||
|
|
||||||
|
const postLogoutRedirectUris = form.postLogoutRedirectUrisString
|
||||||
|
.split('\n')
|
||||||
|
.map(uri => uri.trim())
|
||||||
|
.filter(uri => uri.length > 0)
|
||||||
|
|
||||||
|
const scopes = form.scopesString
|
||||||
|
.split('\n')
|
||||||
|
.map(scope => scope.trim())
|
||||||
|
.filter(scope => scope.length > 0)
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
...form,
|
displayName: form.displayName,
|
||||||
redirectUris: form.redirectUrisString.split(',').map(uri => uri.trim()),
|
clientId: form.clientId,
|
||||||
|
clientSecret: form.clientSecret,
|
||||||
|
redirectUris,
|
||||||
|
postLogoutRedirectUris,
|
||||||
|
scopes,
|
||||||
|
grantTypes: form.grantTypes,
|
||||||
|
clientType: form.clientType,
|
||||||
|
consentType: form.consentType,
|
||||||
|
status: form.status,
|
||||||
|
description: form.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let url = '/api/auth/oauthclients'
|
||||||
|
let method = 'POST'
|
||||||
|
|
||||||
|
const payload = { ...formData }
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await authService.updateClient(formData.id, formData)
|
url = `/api/auth/oauthclients/${form.id}`
|
||||||
ElMessage.success('更新成功')
|
method = 'PUT'
|
||||||
} else {
|
const { clientSecret, ...updateData } = payload
|
||||||
await authService.createClient(formData)
|
Object.assign(payload, updateData)
|
||||||
ElMessage.success('创建成功')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || '操作失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
await fetchClients()
|
await fetchClients()
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
ElMessage.error('操作失败')
|
ElMessage.error(error.message || '操作失败')
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, {
|
||||||
|
displayName: '',
|
||||||
|
clientId: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchClients()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchClients()
|
fetchClients()
|
||||||
})
|
})
|
||||||
|
|||||||
404
src/Fengling.Console.Web/src/views/Users/RoleList.vue
Normal file
404
src/Fengling.Console.Web/src/views/Users/RoleList.vue
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
<template>
|
||||||
|
<div class="role-list">
|
||||||
|
<el-container>
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="header-content">
|
||||||
|
<h2>角色管理</h2>
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加角色
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" style="margin-bottom: 20px">
|
||||||
|
<el-form-item label="角色名称">
|
||||||
|
<el-input v-model="searchForm.name" placeholder="请输入角色名称" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户ID">
|
||||||
|
<el-input v-model="searchForm.tenantId" placeholder="请输入租户ID" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchRoles">查询</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="roles" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="角色名称" width="150" />
|
||||||
|
<el-table-column prop="displayName" label="显示名称" width="150" />
|
||||||
|
<el-table-column prop="description" label="描述" />
|
||||||
|
<el-table-column prop="tenantId" label="租户ID" width="100" />
|
||||||
|
<el-table-column prop="isSystem" label="系统角色" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isSystem ? 'warning' : 'success'" size="small">
|
||||||
|
{{ row.isSystem ? '是' : '否' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用户数量" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.userCount || 0 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" @click="handleUsers(row)">用户</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)" :disabled="row.isSystem">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
@current-change="fetchRoles"
|
||||||
|
@size-change="fetchRoles"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
style="margin-top: 20px"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
|
<el-form-item label="角色名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="英文标识,如:admin" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="显示名称" prop="displayName">
|
||||||
|
<el-input v-model="form.displayName" placeholder="如:管理员" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户ID" prop="tenantId">
|
||||||
|
<el-input v-model="form.tenantId" placeholder="留空为系统默认角色" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述" prop="description">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="权限">
|
||||||
|
<el-select v-model="form.permissions" multiple placeholder="请选择权限" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="permission in allPermissions"
|
||||||
|
:key="permission.value"
|
||||||
|
:label="permission.label"
|
||||||
|
:value="permission.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="usersDialogVisible" title="角色用户" width="800px">
|
||||||
|
<el-table :data="roleUsers" style="width: 100%" v-loading="usersLoading">
|
||||||
|
<el-table-column prop="userName" label="用户名" width="150" />
|
||||||
|
<el-table-column prop="email" label="邮箱" />
|
||||||
|
<el-table-column prop="realName" label="姓名" />
|
||||||
|
<el-table-column prop="tenantId" label="租户ID" width="100" />
|
||||||
|
<el-table-column label="操作" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="danger" @click="handleRemoveUser(row)">移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
description?: string
|
||||||
|
tenantId?: string
|
||||||
|
isSystem: boolean
|
||||||
|
permissions: string[]
|
||||||
|
userCount: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
userName: string
|
||||||
|
email: string
|
||||||
|
realName?: string
|
||||||
|
tenantId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = ref<Role[]>([])
|
||||||
|
const roleUsers = ref<User[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const usersLoading = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const usersDialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加角色')
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentRoleId = ref(0)
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
name: '',
|
||||||
|
tenantId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
displayName: '',
|
||||||
|
description: '',
|
||||||
|
tenantId: '',
|
||||||
|
permissions: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const allPermissions = [
|
||||||
|
{ label: '用户管理', value: 'user.manage' },
|
||||||
|
{ label: '用户查看', value: 'user.view' },
|
||||||
|
{ label: '角色管理', value: 'role.manage' },
|
||||||
|
{ label: '角色查看', value: 'role.view' },
|
||||||
|
{ label: '租户管理', value: 'tenant.manage' },
|
||||||
|
{ label: '租户查看', value: 'tenant.view' },
|
||||||
|
{ label: 'OAuth应用管理', value: 'oauth.manage' },
|
||||||
|
{ label: 'OAuth应用查看', value: 'oauth.view' },
|
||||||
|
{ label: '日志查看', value: 'log.view' },
|
||||||
|
{ label: '系统配置', value: 'system.config' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入角色名称', trigger: 'blur' },
|
||||||
|
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '角色名称只能包含字母、数字、下划线和连字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
displayName: [
|
||||||
|
{ required: true, message: '请输入显示名称', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.value.toString(),
|
||||||
|
pageSize: pageSize.value.toString(),
|
||||||
|
...searchForm,
|
||||||
|
})
|
||||||
|
const response = await fetch(`/api/auth/roles?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('获取角色列表失败')
|
||||||
|
const data = await response.json()
|
||||||
|
roles.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||||
|
total.value = data.totalCount || roles.value.length
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取角色列表失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRoleUsers = async (roleId: number) => {
|
||||||
|
usersLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/roles/${roleId}/users`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('获取角色用户失败')
|
||||||
|
const data = await response.json()
|
||||||
|
roleUsers.value = Array.isArray(data) ? data : []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取角色用户失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogTitle.value = '添加角色'
|
||||||
|
isEdit.value = false
|
||||||
|
Object.assign(form, {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
displayName: '',
|
||||||
|
description: '',
|
||||||
|
tenantId: '',
|
||||||
|
permissions: [],
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: Role) => {
|
||||||
|
dialogTitle.value = '编辑角色'
|
||||||
|
isEdit.value = true
|
||||||
|
Object.assign(form, {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
displayName: row.displayName,
|
||||||
|
description: row.description || '',
|
||||||
|
tenantId: row.tenantId || '',
|
||||||
|
permissions: row.permissions || [],
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUsers = (row: Role) => {
|
||||||
|
currentRoleId.value = row.id
|
||||||
|
fetchRoleUsers(row.id)
|
||||||
|
usersDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveUser = async (row: User) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要将用户 "${row.userName}" 从角色中移除吗?`, '确认移除', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
const response = await fetch(`/api/auth/roles/${currentRoleId.value}/users/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('移除失败')
|
||||||
|
ElMessage.success('移除成功')
|
||||||
|
await fetchRoleUsers(currentRoleId.value)
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('移除失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: Role) => {
|
||||||
|
if (row.isSystem) {
|
||||||
|
ElMessage.warning('系统角色不能删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除角色 "${row.displayName}" 吗?`, '确认删除', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
const response = await fetch(`/api/auth/roles/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('删除失败')
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await fetchRoles()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const valid = await formRef.value?.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
displayName: form.displayName,
|
||||||
|
description: form.description,
|
||||||
|
tenantId: form.tenantId || null,
|
||||||
|
permissions: form.permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = '/api/auth/roles'
|
||||||
|
let method = 'POST'
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
url = `/api/auth/roles/${form.id}`
|
||||||
|
method = 'PUT'
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('操作失败')
|
||||||
|
|
||||||
|
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
await fetchRoles()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, {
|
||||||
|
name: '',
|
||||||
|
tenantId: '',
|
||||||
|
})
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchRoles()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRoles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.role-list {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
575
src/Fengling.Console.Web/src/views/Users/TenantList.vue
Normal file
575
src/Fengling.Console.Web/src/views/Users/TenantList.vue
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tenant-list">
|
||||||
|
<el-container>
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="header-content">
|
||||||
|
<h2>租户管理</h2>
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加租户
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" style="margin-bottom: 20px">
|
||||||
|
<el-form-item label="租户名称">
|
||||||
|
<el-input v-model="searchForm.name" placeholder="请输入租户名称" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户ID">
|
||||||
|
<el-input v-model="searchForm.tenantId" placeholder="请输入租户ID" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="searchForm.status" placeholder="全部" clearable>
|
||||||
|
<el-option label="活跃" value="active" />
|
||||||
|
<el-option label="禁用" value="inactive" />
|
||||||
|
<el-option label="过期" value="expired" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchTenants">查询</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="tenants" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="tenantId" label="租户ID" width="150" />
|
||||||
|
<el-table-column prop="name" label="租户名称" width="150" />
|
||||||
|
<el-table-column prop="contactName" label="联系人" width="120" />
|
||||||
|
<el-table-column prop="contactEmail" label="联系邮箱" width="180" />
|
||||||
|
<el-table-column prop="contactPhone" label="联系电话" width="120" />
|
||||||
|
<el-table-column prop="maxUsers" label="用户上限" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.maxUsers || '无限制' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="userCount" label="用户数" width="80" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="row.status === 'active' ? 'success' : row.status === 'expired' ? 'danger' : 'info'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="expiresAt" label="过期时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.expiresAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" @click="handleUsers(row)">用户</el-button>
|
||||||
|
<el-button size="small" @click="handleSettings(row)">设置</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
@current-change="fetchTenants"
|
||||||
|
@size-change="fetchTenants"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
style="margin-top: 20px"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
|
||||||
|
<el-form-item label="租户ID" prop="tenantId">
|
||||||
|
<el-input v-model="form.tenantId" :disabled="isEdit" placeholder="英文标识,如:company-001" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="如:某某科技有限公司" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="联系人" prop="contactName">
|
||||||
|
<el-input v-model="form.contactName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="联系邮箱" prop="contactEmail">
|
||||||
|
<el-input v-model="form.contactEmail" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="联系电话" prop="contactPhone">
|
||||||
|
<el-input v-model="form.contactPhone" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户上限" prop="maxUsers">
|
||||||
|
<el-input-number v-model="form.maxUsers" :min="0" controls-position="right" style="width: 100%" />
|
||||||
|
<div style="color: #909399; font-size: 12px; margin-top: 5px">0 表示无限制</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="过期时间" prop="expiresAt">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.expiresAt"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择过期时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-select v-model="form.status" style="width: 100%">
|
||||||
|
<el-option label="活跃" value="active" />
|
||||||
|
<el-option label="禁用" value="inactive" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述" prop="description">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="usersDialogVisible" title="租户用户" width="900px">
|
||||||
|
<el-table :data="tenantUsers" style="width: 100%" v-loading="usersLoading">
|
||||||
|
<el-table-column prop="userName" label="用户名" width="150" />
|
||||||
|
<el-table-column prop="email" label="邮箱" width="200" />
|
||||||
|
<el-table-column prop="realName" label="姓名" width="120" />
|
||||||
|
<el-table-column label="角色" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="role in row.roles" :key="role" type="success" size="small" style="margin-right: 5px">
|
||||||
|
{{ role }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="isActive" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isActive ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.isActive ? '活跃' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="settingsDialogVisible" title="租户设置" width="600px">
|
||||||
|
<el-form :model="settingsForm" ref="settingsFormRef" label-width="120px">
|
||||||
|
<el-form-item label="允许注册">
|
||||||
|
<el-switch v-model="settingsForm.allowRegistration" />
|
||||||
|
<div style="color: #909399; font-size: 12px; margin-top: 5px">是否允许用户自主注册</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱域名限制">
|
||||||
|
<el-input
|
||||||
|
v-model="settingsForm.allowedEmailDomains"
|
||||||
|
placeholder="多个域名用逗号分隔,如:company.com,company.cn"
|
||||||
|
/>
|
||||||
|
<div style="color: #909399; font-size: 12px; margin-top: 5px">留空表示不限制</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认角色">
|
||||||
|
<el-select v-model="settingsForm.defaultRoleId" placeholder="选择默认角色" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="role in tenantRoles"
|
||||||
|
:key="role.id"
|
||||||
|
:label="role.displayName"
|
||||||
|
:value="role.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码策略">
|
||||||
|
<el-checkbox-group v-model="settingsForm.passwordPolicy">
|
||||||
|
<el-checkbox label="requireNumber">包含数字</el-checkbox>
|
||||||
|
<el-checkbox label="requireUppercase">包含大写字母</el-checkbox>
|
||||||
|
<el-checkbox label="requireLowercase">包含小写字母</el-checkbox>
|
||||||
|
<el-checkbox label="requireSpecial">包含特殊字符</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码最小长度">
|
||||||
|
<el-input-number v-model="settingsForm.minPasswordLength" :min="6" :max="32" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="会话超时">
|
||||||
|
<el-input-number v-model="settingsForm.sessionTimeout" :min="10" :max="1440" controls-position="right" style="width: 100%" />
|
||||||
|
<div style="color: #909399; font-size: 12px; margin-top: 5px">单位:分钟</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="settingsDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSettingsSubmit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: number
|
||||||
|
tenantId: string
|
||||||
|
name: string
|
||||||
|
contactName: string
|
||||||
|
contactEmail: string
|
||||||
|
contactPhone: string
|
||||||
|
maxUsers?: number
|
||||||
|
userCount: number
|
||||||
|
status: 'active' | 'inactive' | 'expired'
|
||||||
|
expiresAt?: string
|
||||||
|
description?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
userName: string
|
||||||
|
email: string
|
||||||
|
realName?: string
|
||||||
|
roles: string[]
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = ref<Tenant[]>([])
|
||||||
|
const tenantUsers = ref<User[]>([])
|
||||||
|
const tenantRoles = ref<Role[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const usersLoading = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const usersDialogVisible = ref(false)
|
||||||
|
const settingsDialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加租户')
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentTenantId = ref(0)
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
name: '',
|
||||||
|
tenantId: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
id: 0,
|
||||||
|
tenantId: '',
|
||||||
|
name: '',
|
||||||
|
contactName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
maxUsers: 0,
|
||||||
|
expiresAt: '',
|
||||||
|
status: 'active',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsForm = reactive({
|
||||||
|
allowRegistration: false,
|
||||||
|
allowedEmailDomains: '',
|
||||||
|
defaultRoleId: undefined as number | undefined,
|
||||||
|
passwordPolicy: ['requireNumber', 'requireLowercase'] as string[],
|
||||||
|
minPasswordLength: 8,
|
||||||
|
sessionTimeout: 120,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
tenantId: [
|
||||||
|
{ required: true, message: '请输入租户ID', trigger: 'blur' },
|
||||||
|
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '租户ID只能包含字母、数字、下划线和连字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入租户名称', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
contactName: [
|
||||||
|
{ required: true, message: '请输入联系人', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
contactEmail: [
|
||||||
|
{ required: true, message: '请输入联系邮箱', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
status: [
|
||||||
|
{ required: true, message: '请选择状态', trigger: 'change' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
active: '活跃',
|
||||||
|
inactive: '禁用',
|
||||||
|
expired: '过期',
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTenants = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.value.toString(),
|
||||||
|
pageSize: pageSize.value.toString(),
|
||||||
|
...searchForm,
|
||||||
|
})
|
||||||
|
const response = await fetch(`/api/auth/tenants?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('获取租户列表失败')
|
||||||
|
const data = await response.json()
|
||||||
|
tenants.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||||
|
total.value = data.totalCount || tenants.value.length
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取租户列表失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTenantUsers = async (tenantId: string) => {
|
||||||
|
usersLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/tenants/${tenantId}/users`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('获取租户用户失败')
|
||||||
|
const data = await response.json()
|
||||||
|
tenantUsers.value = Array.isArray(data) ? data : []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取租户用户失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTenantRoles = async (tenantId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/tenants/${tenantId}/roles`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) return
|
||||||
|
const data = await response.json()
|
||||||
|
tenantRoles.value = Array.isArray(data) ? data : []
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogTitle.value = '添加租户'
|
||||||
|
isEdit.value = false
|
||||||
|
Object.assign(form, {
|
||||||
|
id: 0,
|
||||||
|
tenantId: '',
|
||||||
|
name: '',
|
||||||
|
contactName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
maxUsers: 0,
|
||||||
|
expiresAt: '',
|
||||||
|
status: 'active',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: Tenant) => {
|
||||||
|
dialogTitle.value = '编辑租户'
|
||||||
|
isEdit.value = true
|
||||||
|
Object.assign(form, {
|
||||||
|
id: row.id,
|
||||||
|
tenantId: row.tenantId,
|
||||||
|
name: row.name,
|
||||||
|
contactName: row.contactName,
|
||||||
|
contactEmail: row.contactEmail,
|
||||||
|
contactPhone: row.contactPhone,
|
||||||
|
maxUsers: row.maxUsers || 0,
|
||||||
|
expiresAt: row.expiresAt || '',
|
||||||
|
status: row.status,
|
||||||
|
description: row.description || '',
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUsers = (row: Tenant) => {
|
||||||
|
currentTenantId.value = row.id
|
||||||
|
fetchTenantUsers(row.tenantId)
|
||||||
|
usersDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSettings = async (row: Tenant) => {
|
||||||
|
currentTenantId.value = row.id
|
||||||
|
await fetchTenantRoles(row.tenantId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/tenants/${row.tenantId}/settings`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const settings = await response.json()
|
||||||
|
Object.assign(settingsForm, settings)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: Tenant) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除租户 "${row.name}" 吗?此操作将同时删除该租户下的所有用户和角色数据。`,
|
||||||
|
'确认删除',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
const response = await fetch(`/api/auth/tenants/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('删除失败')
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await fetchTenants()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const valid = await formRef.value?.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
tenantId: form.tenantId,
|
||||||
|
name: form.name,
|
||||||
|
contactName: form.contactName,
|
||||||
|
contactEmail: form.contactEmail,
|
||||||
|
contactPhone: form.contactPhone,
|
||||||
|
maxUsers: form.maxUsers || null,
|
||||||
|
expiresAt: form.expiresAt || null,
|
||||||
|
status: form.status,
|
||||||
|
description: form.description || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = '/api/auth/tenants'
|
||||||
|
let method = 'POST'
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
url = `/api/auth/tenants/${form.id}`
|
||||||
|
method = 'PUT'
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('操作失败')
|
||||||
|
|
||||||
|
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
await fetchTenants()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSettingsSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/tenants/${currentTenantId.value}/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(settingsForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('保存设置失败')
|
||||||
|
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
settingsDialogVisible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('保存设置失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, {
|
||||||
|
name: '',
|
||||||
|
tenantId: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTenants()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTenants()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tenant-list {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -5,18 +5,39 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h2>用户管理</h2>
|
<h2>用户管理</h2>
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加用户
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="users" style="width: 100%">
|
<el-form :inline="true" :model="searchForm" style="margin-bottom: 20px">
|
||||||
<el-table-column prop="userName" label="用户名" />
|
<el-form-item label="用户名">
|
||||||
<el-table-column prop="email" label="邮箱" />
|
<el-input v-model="searchForm.userName" placeholder="请输入用户名" clearable />
|
||||||
<el-table-column prop="realName" label="姓名" />
|
</el-form-item>
|
||||||
<el-table-column prop="phone" label="手机号" />
|
<el-form-item label="邮箱">
|
||||||
|
<el-input v-model="searchForm.email" placeholder="请输入邮箱" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户ID">
|
||||||
|
<el-input v-model="searchForm.tenantId" placeholder="请输入租户ID" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchUsers">查询</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="users" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="userName" label="用户名" width="150" />
|
||||||
|
<el-table-column prop="email" label="邮箱" width="200" />
|
||||||
|
<el-table-column prop="realName" label="姓名" width="120" />
|
||||||
|
<el-table-column prop="phone" label="手机号" width="120" />
|
||||||
<el-table-column prop="tenantId" label="租户ID" width="100" />
|
<el-table-column prop="tenantId" label="租户ID" width="100" />
|
||||||
<el-table-column label="角色">
|
<el-table-column label="角色" width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-for="role in row.roles" :key="role" type="success" size="small">
|
<el-tag v-for="role in row.roles" :key="role" type="success" size="small" style="margin-right: 5px">
|
||||||
{{ role }}
|
{{ role }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@ -28,6 +49,25 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column prop="isActive" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isActive ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.isActive ? '活跃' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" @click="handleResetPassword(row)">重置密码</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-pagination
|
<el-pagination
|
||||||
@ -35,37 +75,399 @@
|
|||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:total="total"
|
:total="total"
|
||||||
@current-change="fetchUsers"
|
@current-change="fetchUsers"
|
||||||
layout="total, prev, pager, next"
|
@size-change="fetchUsers"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
style="margin-top: 20px"
|
style="margin-top: 20px"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
|
<el-form-item label="用户名" prop="userName">
|
||||||
|
<el-input v-model="form.userName" :disabled="isEdit" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input v-model="form.email" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名" prop="realName">
|
||||||
|
<el-input v-model="form.realName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="form.phone" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户ID" prop="tenantId">
|
||||||
|
<el-input v-model="form.tenantId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色" prop="roleIds" v-if="!isEdit">
|
||||||
|
<el-select v-model="form.roleIds" multiple placeholder="请选择角色" style="width: 100%">
|
||||||
|
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password" v-if="!isEdit">
|
||||||
|
<el-input v-model="form.password" type="password" show-password placeholder="至少8位,包含字母和数字" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirmPassword" v-if="!isEdit">
|
||||||
|
<el-input v-model="form.confirmPassword" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱验证">
|
||||||
|
<el-switch v-model="form.emailConfirmed" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="form.isActive" active-text="活跃" inactive-text="禁用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="resetPasswordDialogVisible" title="重置密码" width="400px">
|
||||||
|
<el-form :model="resetPasswordForm" :rules="resetPasswordRules" ref="resetPasswordFormRef" label-width="100px">
|
||||||
|
<el-form-item label="新密码" prop="newPassword">
|
||||||
|
<el-input v-model="resetPasswordForm.newPassword" type="password" show-password placeholder="至少8位,包含字母和数字" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirmPassword">
|
||||||
|
<el-input v-model="resetPasswordForm.confirmPassword" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="resetPasswordDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleResetPasswordSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const users = ref<any[]>([])
|
interface User {
|
||||||
|
id: number
|
||||||
|
userName: string
|
||||||
|
email: string
|
||||||
|
realName?: string
|
||||||
|
phone?: string
|
||||||
|
tenantId?: string
|
||||||
|
roles: string[]
|
||||||
|
emailConfirmed: boolean
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const roles = ref<Role[]>([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const resetPasswordDialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加用户')
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const resetPasswordFormRef = ref<FormInstance>()
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentUserId = ref(0)
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
userName: '',
|
||||||
|
email: '',
|
||||||
|
tenantId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
id: 0,
|
||||||
|
userName: '',
|
||||||
|
email: '',
|
||||||
|
realName: '',
|
||||||
|
phone: '',
|
||||||
|
tenantId: '',
|
||||||
|
roleIds: [] as number[],
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
emailConfirmed: false,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPasswordForm = reactive({
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const validatePassword = (_rule: any, value: string, callback: any) => {
|
||||||
|
const regex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/
|
||||||
|
if (!regex.test(value)) {
|
||||||
|
callback(new Error('密码至少8位,包含字母和数字'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||||
|
if (value !== form.password) {
|
||||||
|
callback(new Error('两次密码输入不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateResetConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||||
|
if (value !== resetPasswordForm.newPassword) {
|
||||||
|
callback(new Error('两次密码输入不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
userName: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 20, message: '用户名长度3-20个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
realName: [
|
||||||
|
{ required: true, message: '请输入姓名', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ validator: validatePassword, trigger: 'blur' },
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||||
|
{ validator: validateConfirmPassword, trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPasswordRules: FormRules = {
|
||||||
|
newPassword: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ validator: validatePassword, trigger: 'blur' },
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||||
|
{ validator: validateResetConfirmPassword, trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/users')
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.value.toString(),
|
||||||
|
pageSize: pageSize.value.toString(),
|
||||||
|
...searchForm,
|
||||||
|
})
|
||||||
|
const response = await fetch(`/api/auth/users?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('获取用户列表失败')
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
users.value = Array.isArray(data) ? data : []
|
users.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||||
total.value = users.value.length
|
total.value = data.totalCount || users.value.length
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('获取用户列表失败')
|
ElMessage.error('获取用户列表失败')
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/roles', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) return
|
||||||
|
const data = await response.json()
|
||||||
|
roles.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogTitle.value = '添加用户'
|
||||||
|
isEdit.value = false
|
||||||
|
Object.assign(form, {
|
||||||
|
id: 0,
|
||||||
|
userName: '',
|
||||||
|
email: '',
|
||||||
|
realName: '',
|
||||||
|
phone: '',
|
||||||
|
tenantId: '',
|
||||||
|
roleIds: [],
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
emailConfirmed: false,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: User) => {
|
||||||
|
dialogTitle.value = '编辑用户'
|
||||||
|
isEdit.value = true
|
||||||
|
Object.assign(form, {
|
||||||
|
id: row.id,
|
||||||
|
userName: row.userName,
|
||||||
|
email: row.email,
|
||||||
|
realName: row.realName || '',
|
||||||
|
phone: row.phone || '',
|
||||||
|
tenantId: row.tenantId || '',
|
||||||
|
roleIds: [],
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
emailConfirmed: row.emailConfirmed,
|
||||||
|
isActive: row.isActive,
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetPassword = (row: User) => {
|
||||||
|
currentUserId.value = row.id
|
||||||
|
Object.assign(resetPasswordForm, {
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
resetPasswordDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: User) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除用户 "${row.userName}" 吗?`, '确认删除', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
const response = await fetch(`/api/auth/users/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('删除失败')
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const valid = await formRef.value?.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
userName: form.userName,
|
||||||
|
email: form.email,
|
||||||
|
realName: form.realName,
|
||||||
|
phone: form.phone,
|
||||||
|
tenantId: form.tenantId,
|
||||||
|
roleIds: form.roleIds,
|
||||||
|
password: form.password,
|
||||||
|
emailConfirmed: form.emailConfirmed,
|
||||||
|
isActive: form.isActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = '/api/auth/users'
|
||||||
|
let method = 'POST'
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
url = `/api/auth/users/${form.id}`
|
||||||
|
method = 'PUT'
|
||||||
|
const { password, roleIds, ...updateData } = payload as any
|
||||||
|
Object.assign(payload, updateData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('操作失败')
|
||||||
|
|
||||||
|
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetPasswordSubmit = async () => {
|
||||||
|
const valid = await resetPasswordFormRef.value?.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/users/${currentUserId.value}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
newPassword: resetPasswordForm.newPassword,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('重置密码失败')
|
||||||
|
|
||||||
|
ElMessage.success('密码重置成功')
|
||||||
|
resetPasswordDialogVisible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('重置密码失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, {
|
||||||
|
userName: '',
|
||||||
|
email: '',
|
||||||
|
tenantId: '',
|
||||||
|
})
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
|
fetchRoles()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -74,7 +476,13 @@ onMounted(() => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content h2 {
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
|
|||||||
@ -3,6 +3,10 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(() => ({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@ -12,16 +12,18 @@ export default defineConfig(({ mode }) => ({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/auth': {
|
'/.well-known': {
|
||||||
target: 'http://localhost:5000',
|
target: 'http://localhost:5132',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api\/auth/, '')
|
|
||||||
},
|
},
|
||||||
'/api/gateway': {
|
'/connect': {
|
||||||
target: 'http://localhost:5001',
|
target: 'http://localhost:5132',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api\/gateway/, '/api')
|
},
|
||||||
}
|
'/api': {
|
||||||
|
target: 'http://localhost:5132',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
108
test/Fengling.AuthService.Tests/AccountIntegrationTests.cs
Normal file
108
test/Fengling.AuthService.Tests/AccountIntegrationTests.cs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Fengling.AuthService.ViewModels;
|
||||||
|
|
||||||
|
namespace Fengling.AuthService.Tests;
|
||||||
|
|
||||||
|
public class AccountIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public AccountIntegrationTests(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_factory = factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
});
|
||||||
|
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_Get_ReturnsLoginPage()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/account/login");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain("欢迎回来");
|
||||||
|
content.Should().Contain("登录到 Fengling Auth");
|
||||||
|
content.Should().Contain("用户名");
|
||||||
|
content.Should().Contain("密码");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_Get_WithReturnUrl_ReturnsLoginPageWithReturnUrl()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/account/login?returnUrl=/dashboard");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain("value=\"/dashboard\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Register_Get_ReturnsRegisterPage()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/account/register");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain("创建账号");
|
||||||
|
content.Should().Contain("加入 Fengling Auth");
|
||||||
|
content.Should().Contain("用户名");
|
||||||
|
content.Should().Contain("邮箱");
|
||||||
|
content.Should().Contain("密码");
|
||||||
|
content.Should().Contain("确认密码");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Register_Get_WithReturnUrl_ReturnsRegisterPageWithReturnUrl()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/account/register?returnUrl=/dashboard");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain("value=\"/dashboard\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dashboard_Index_ReturnsLoginPageWhenNotAuthenticated()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/dashboard");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
|
||||||
|
var location = response.Headers.Location.ToString();
|
||||||
|
location.Should().StartWith("/account/login");
|
||||||
|
location.Should().Contain("dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dashboard_Profile_ReturnsLoginPageWhenNotAuthenticated()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/dashboard/profile");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
|
||||||
|
var location = response.Headers.Location.ToString();
|
||||||
|
location.Should().StartWith("/account/login");
|
||||||
|
location.Should().Contain("dashboard%2Fprofile");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dashboard_Settings_ReturnsLoginPageWhenNotAuthenticated()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/dashboard/settings");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
|
||||||
|
var location = response.Headers.Location.ToString();
|
||||||
|
location.Should().StartWith("/account/login");
|
||||||
|
location.Should().Contain("dashboard%2Fsettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Fengling.AuthService\Fengling.AuthService.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Loading…
Reference in New Issue
Block a user