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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YarpGateway", "src\YarpGateway\YarpGateway.csproj", "{8DDFE39A-06AE-4C02-BA80-27F0C809E959}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.AuthService", "src\Fengling.AuthService\Fengling.AuthService.csproj", "{469FA168-1656-483D-A40D-072FFE8C5E33}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{8DDFE39A-06AE-4C02-BA80-27F0C809E959} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{469FA168-1656-483D-A40D-072FFE8C5E33} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
EndGlobalSection
|
||||
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.IdentityModel.Tokens;
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
|
||||
namespace Fengling.AuthService.Configuration;
|
||||
|
||||
@ -11,42 +11,41 @@ public static class OpenIddictSetup
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
services
|
||||
.AddOpenIddict()
|
||||
.AddCore(options =>
|
||||
{
|
||||
options.UseEntityFrameworkCore().UseDbContext<Data.ApplicationDbContext>();
|
||||
})
|
||||
.AddServer(options =>
|
||||
{
|
||||
options.SetIssuer(
|
||||
configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local"
|
||||
);
|
||||
var isTesting = configuration.GetValue<bool>("Testing", false);
|
||||
|
||||
options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate();
|
||||
var builder = services.AddOpenIddict();
|
||||
|
||||
options
|
||||
.AllowAuthorizationCodeFlow()
|
||||
.AllowPasswordFlow()
|
||||
.AllowRefreshTokenFlow()
|
||||
.RequireProofKeyForCodeExchange();
|
||||
builder.AddCore(options =>
|
||||
{
|
||||
options.UseEntityFrameworkCore().UseDbContext<Data.ApplicationDbContext>();
|
||||
});
|
||||
|
||||
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.UseAspNetCore();
|
||||
})
|
||||
.AddValidation(options =>
|
||||
{
|
||||
options.UseLocalServer();
|
||||
options.UseAspNetCore();
|
||||
});
|
||||
}
|
||||
|
||||
builder.AddValidation(options =>
|
||||
{
|
||||
options.UseLocalServer();
|
||||
});
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme =
|
||||
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme =
|
||||
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
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.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Fengling.AuthService.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class OAuthClientsController : ControllerBase
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
@ -21,9 +24,60 @@ public class OAuthClientsController : ControllerBase
|
||||
}
|
||||
|
||||
[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}")]
|
||||
@ -34,27 +88,97 @@ public class OAuthClientsController : ControllerBase
|
||||
{
|
||||
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]
|
||||
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();
|
||||
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}")]
|
||||
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 CreateAuditLog("oauth", "update", "OAuthClient", client.Id, client.DisplayName, oldValue, SerializeToJson(client));
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -67,8 +191,73 @@ public class OAuthClientsController : ControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var oldValue = SerializeToJson(client);
|
||||
|
||||
_context.OAuthApplications.Remove(client);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await CreateAuditLog("oauth", "delete", "OAuthClient", client.Id, client.DisplayName, 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 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<Tenant> Tenants { get; set; }
|
||||
public DbSet<AccessLog> AccessLogs { get; set; }
|
||||
public DbSet<AuditLog> AuditLogs { get; set; }
|
||||
|
||||
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.ConsentType).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 />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Fengling.AuthService.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@ -22,6 +23,78 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
|
||||
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")
|
||||
@ -41,6 +114,12 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
.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)");
|
||||
@ -49,6 +128,12 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
.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")
|
||||
@ -150,6 +235,85 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
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")
|
||||
@ -180,6 +344,10 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
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)
|
||||
@ -217,6 +385,70 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
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")
|
||||
@ -320,6 +552,15 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
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)
|
||||
@ -370,6 +611,11 @@ namespace Fengling.AuthService.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
|
||||
{
|
||||
b.Navigation("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,18 +15,65 @@ public static class SeedData
|
||||
|
||||
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");
|
||||
if (adminRole == null)
|
||||
{
|
||||
adminRole = new ApplicationRole
|
||||
{
|
||||
Name = "Admin",
|
||||
DisplayName = "管理员",
|
||||
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
|
||||
};
|
||||
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");
|
||||
if (adminUser == null)
|
||||
{
|
||||
@ -36,7 +83,7 @@ public static class SeedData
|
||||
Email = "admin@fengling.local",
|
||||
RealName = "系统管理员",
|
||||
Phone = "13800138000",
|
||||
TenantId = 1,
|
||||
TenantId = defaultTenant.Id,
|
||||
EmailConfirmed = true,
|
||||
IsDeleted = false,
|
||||
CreatedTime = DateTime.UtcNow
|
||||
@ -58,7 +105,7 @@ public static class SeedData
|
||||
Email = "test@fengling.local",
|
||||
RealName = "测试用户",
|
||||
Phone = "13900139000",
|
||||
TenantId = 1,
|
||||
TenantId = defaultTenant.Id,
|
||||
EmailConfirmed = true,
|
||||
IsDeleted = false,
|
||||
CreatedTime = DateTime.UtcNow
|
||||
@ -67,13 +114,6 @@ public static class SeedData
|
||||
var result = await userManager.CreateAsync(testUser, "Test@123");
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var userRole = new ApplicationRole
|
||||
{
|
||||
Name = "User",
|
||||
Description = "普通用户",
|
||||
CreatedTime = DateTime.UtcNow
|
||||
};
|
||||
await roleManager.CreateAsync(userRole);
|
||||
await userManager.AddToRoleAsync(testUser, "User");
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,23 +6,30 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<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">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<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.EntityFrameworkCore" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.11.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.15.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.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>
|
||||
</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 System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fengling.AuthService.Models;
|
||||
|
||||
@ -6,4 +7,8 @@ public class ApplicationRole : IdentityRole<long>
|
||||
{
|
||||
public string? Description { get; set; }
|
||||
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;
|
||||
|
||||
public class OAuthApplication
|
||||
@ -13,6 +15,7 @@ public class OAuthApplication
|
||||
public string ClientType { get; set; } = "public";
|
||||
public string ConsentType { get; set; } = "implicit";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
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.Data;
|
||||
using Fengling.AuthService.Models;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
@ -19,13 +20,37 @@ Log.Logger = new LoggerConfiguration()
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||||
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>()
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.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.AddOpenTelemetry()
|
||||
@ -37,7 +62,7 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddSource("OpenIddict.Server.AspNetCore")
|
||||
.AddOtlpExporter());
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddControllersWithViews();
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!);
|
||||
@ -71,18 +96,24 @@ using (var scope = app.Services.CreateScope())
|
||||
await SeedData.Initialize(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1");
|
||||
options.OAuthClientId("swagger-ui");
|
||||
options.OAuthUsePkce();
|
||||
});
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
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.MapHealthChecks("/health");
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
@ -9,15 +9,6 @@
|
||||
"environmentVariables": {
|
||||
"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": {
|
||||
"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": {
|
||||
"Issuer": "https://auth.fengling.local",
|
||||
"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 运管中心前端
|
||||
|
||||
## 开发环境配置
|
||||
VITE_AUTH_SERVICE_URL=http://localhost:5000
|
||||
VITE_AUTH_SERVER_URL=http://localhost:5132
|
||||
VITE_GATEWAY_SERVICE_URL=http://localhost:5001
|
||||
VITE_CLIENT_ID=fengling-console
|
||||
VITE_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# 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_CLIENT_ID=fengling-console
|
||||
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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1174,6 +1175,15 @@
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"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_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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"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">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<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" />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vue:hover {
|
||||
filter: drop-shadow(0 0 2em #42b883aa);
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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>
|
||||
|
||||
@ -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 { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
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: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Gateway/Dashboard.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/tenants',
|
||||
name: 'TenantList',
|
||||
component: () => import('@/views/Gateway/TenantList.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/tenants/:tenantId/routes',
|
||||
name: 'TenantRoutes',
|
||||
component: () => import('@/views/Gateway/TenantRoutes.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/clusters',
|
||||
name: 'ClusterInstances',
|
||||
component: () => import('@/views/Gateway/ClusterInstances.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/routes/global',
|
||||
name: 'GlobalRoutes',
|
||||
component: () => import('@/views/Gateway/GlobalRoutes.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/oauth/clients',
|
||||
name: 'OAuthClients',
|
||||
component: () => import('@/views/OAuth/ClientList.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'UserList',
|
||||
component: () => import('@/views/Users/UserList.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: 'oauth/clients',
|
||||
name: 'OAuthClients',
|
||||
component: () => import('@/views/OAuth/ClientList.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'UserList',
|
||||
component: () => import('@/views/Users/UserList.vue'),
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'RoleList',
|
||||
component: () => import('@/views/Users/RoleList.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tenants',
|
||||
name: 'TenantList',
|
||||
component: () => import('@/views/Users/TenantList.vue'),
|
||||
},
|
||||
{
|
||||
path: 'logs/access',
|
||||
name: 'AccessLog',
|
||||
component: () => import('@/views/Audit/AccessLog.vue'),
|
||||
},
|
||||
{
|
||||
path: 'logs/audit',
|
||||
name: 'AuditLog',
|
||||
component: () => import('@/views/Audit/AuditLog.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -64,12 +64,16 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
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' })
|
||||
} else if (to.name === 'Login' && authStore.isAuthenticated.value) {
|
||||
} else if (to.name === 'Login' && authStore.isAuthenticated) {
|
||||
next({ name: 'Dashboard' })
|
||||
} else {
|
||||
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 { ref, computed } from 'vue'
|
||||
import { authService, type AuthUser } from '@/services/oidc'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
id: string
|
||||
userName: string
|
||||
email: string
|
||||
realName: string
|
||||
tenantId: number
|
||||
tenantId: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessToken = ref<string | null>(null)
|
||||
const refreshToken = ref<string | null>(null)
|
||||
const accessToken = ref<string>('')
|
||||
const refreshToken = ref<string>('')
|
||||
const user = ref<User | null>(null)
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
||||
|
||||
function setTokens(accessTokenValue: string, refreshTokenValue: string) {
|
||||
accessToken.value = accessTokenValue
|
||||
refreshToken.value = refreshTokenValue
|
||||
localStorage.setItem('access_token', accessTokenValue)
|
||||
localStorage.setItem('refresh_token', refreshTokenValue)
|
||||
function setTokens(authUser: AuthUser) {
|
||||
accessToken.value = authUser.access_token
|
||||
refreshToken.value = authUser.refresh_token
|
||||
|
||||
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) {
|
||||
user.value = userData
|
||||
localStorage.setItem('user_info', JSON.stringify(userData))
|
||||
async function login() {
|
||||
await authService.login()
|
||||
}
|
||||
|
||||
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() {
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_info')
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
async function loadFromStorage() {
|
||||
const storedToken = localStorage.getItem('access_token')
|
||||
const storedRefreshToken = localStorage.getItem('refresh_token')
|
||||
const storedUser = localStorage.getItem('user_info')
|
||||
@ -49,7 +76,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
refreshToken.value = storedRefreshToken
|
||||
}
|
||||
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,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
refresh,
|
||||
logout,
|
||||
setTokens,
|
||||
setUser,
|
||||
clearAuth,
|
||||
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>
|
||||
<div class="callback-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<h2>处理中...</h2>
|
||||
</template>
|
||||
<el-empty description="正在处理OAuth回调" />
|
||||
<el-card class="callback-card">
|
||||
<div class="loading-content">
|
||||
<el-icon class="is-loading" :size="50" color="#409eff"><Loading /></el-icon>
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { authService } from '@/services/oidc'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = ref('正在处理登录...')
|
||||
|
||||
onMounted(() => {
|
||||
const code = route.query.code as string
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const authUser = await authService.handleCallback()
|
||||
if (authUser && !authUser.expired && authUser.access_token) {
|
||||
localStorage.setItem('access_token', authUser.access_token)
|
||||
if (authUser.refresh_token) {
|
||||
localStorage.setItem('refresh_token', authUser.refresh_token)
|
||||
}
|
||||
if (authUser.profile) {
|
||||
localStorage.setItem('user_info', JSON.stringify(authUser.profile))
|
||||
}
|
||||
message.value = '登录成功,正在跳转...'
|
||||
|
||||
if (code) {
|
||||
ElMessage.success('OAuth登录成功')
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'Login' })
|
||||
}, 2000)
|
||||
} else {
|
||||
ElMessage.error('无效的OAuth回调')
|
||||
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(() => {
|
||||
router.push({ name: 'Login' })
|
||||
}, 2000)
|
||||
@ -40,16 +55,34 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
.callback-card {
|
||||
width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #409eff;
|
||||
.loading-content {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@ -4,72 +4,31 @@
|
||||
<template #header>
|
||||
<h2>风铃运管中心</h2>
|
||||
</template>
|
||||
<el-form :model="loginForm" :rules="rules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="loginForm.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
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>
|
||||
<div class="login-content">
|
||||
<p>点击下方按钮跳转到身份认证服务器进行登录</p>
|
||||
<el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%; margin-top: 20px;">
|
||||
去登录
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { authService } from '@/api/auth'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
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 valid = await formRef.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await authService.login(loginForm.username, loginForm.password)
|
||||
|
||||
authStore.setTokens(response.access_token, response.refresh_token)
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
router.push({ name: 'Dashboard' })
|
||||
await authStore.login()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.error_description || '登录失败,请检查用户名和密码')
|
||||
console.error('Login error:', error)
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
} finally {
|
||||
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>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-layout">
|
||||
<el-container>
|
||||
<el-aside width="200px">
|
||||
<el-aside width="220px">
|
||||
<div class="logo">
|
||||
<h2>风铃认证中心</h2>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="el-menu-vertical"
|
||||
@ -11,26 +14,34 @@
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="TenantList">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>租户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="ClusterInstances">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>集群实例</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="GlobalRoutes">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>全局路由</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="OAuthClients">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>OAuth应用</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="UserList">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="user">
|
||||
<template #title>
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</template>
|
||||
<el-menu-item index="UserList">用户列表</el-menu-item>
|
||||
<el-menu-item index="RoleList">角色管理</el-menu-item>
|
||||
<el-menu-item index="TenantList">租户管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="oauth">
|
||||
<template #title>
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>OAuth管理</span>
|
||||
</template>
|
||||
<el-menu-item index="OAuthClients">应用列表</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<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-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
@ -39,23 +50,24 @@
|
||||
</el-aside>
|
||||
|
||||
<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>{{ currentBreadcrumb }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<router-view />
|
||||
<div class="content-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Monitor, OfficeBuilding, Connection, Share, Key, User, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { authService } from '@/api/auth'
|
||||
import { Monitor, User, Key, Document, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
@ -66,12 +78,12 @@ const activeMenu = ref('Dashboard')
|
||||
const currentBreadcrumb = computed(() => {
|
||||
const breadcrumbMap: Record<string, string> = {
|
||||
Dashboard: '仪表盘',
|
||||
UserList: '用户列表',
|
||||
RoleList: '角色管理',
|
||||
TenantList: '租户管理',
|
||||
TenantRoutes: '租户路由',
|
||||
ClusterInstances: '集群实例',
|
||||
GlobalRoutes: '全局路由',
|
||||
OAuthClients: 'OAuth应用',
|
||||
UserList: '用户管理',
|
||||
OAuthClients: '应用列表',
|
||||
AccessLog: '访问日志',
|
||||
AuditLog: '审计日志',
|
||||
}
|
||||
return breadcrumbMap[route.name as string] || ''
|
||||
})
|
||||
@ -81,13 +93,13 @@ const handleMenuSelect = (index: string) => {
|
||||
handleLogout()
|
||||
} else {
|
||||
activeMenu.value = index
|
||||
router.push({ name: index })
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authService.logout()
|
||||
authStore.clearAuth()
|
||||
await authStore.logout()
|
||||
ElMessage.success('退出成功')
|
||||
router.push({ name: 'Login' })
|
||||
} catch (error) {
|
||||
@ -95,10 +107,18 @@ const handleLogout = async () => {
|
||||
ElMessage.error('退出失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.name, (newName) => {
|
||||
activeMenu.value = newName as string
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
activeMenu.value = route.name as string
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
.dashboard-layout {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@ -107,20 +127,66 @@ const handleLogout = async () => {
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
background-color: #545c64;
|
||||
background-color: #304156;
|
||||
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 {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.el-menu-vertical:not(.el-menu--collapse) {
|
||||
width: 200px;
|
||||
.el-menu-item {
|
||||
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 {
|
||||
background-color: #f0f2f5;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 20px;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb) {
|
||||
padding: 12px 20px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -12,20 +12,59 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="clients" style="width: 100%">
|
||||
<el-table-column prop="displayName" label="应用名称" />
|
||||
<el-table-column prop="clientId" label="Client ID" />
|
||||
<el-table-column prop="clientType" label="类型" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<el-form :inline="true" :model="searchForm" style="margin-bottom: 20px">
|
||||
<el-form-item label="应用名称">
|
||||
<el-input v-model="searchForm.displayName" placeholder="请输入应用名称" clearable />
|
||||
</el-form-item>
|
||||
<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 }">
|
||||
<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' ? '活跃' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</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 }">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -36,38 +75,75 @@
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="fetchClients"
|
||||
layout="total, prev, pager, next"
|
||||
@size-change="fetchClients"
|
||||
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-dialog v-model="dialogVisible" :title="dialogTitle" width="700px">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="140px">
|
||||
<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 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 label="Client Secret" prop="clientSecret">
|
||||
<el-input v-model="form.clientSecret" type="password" show-password />
|
||||
<el-form-item label="Client Secret" prop="clientSecret" v-if="!isEdit">
|
||||
<el-input v-model="form.clientSecret" type="password" show-password placeholder="至少16位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="重定向URI" prop="redirectUris">
|
||||
<el-input v-model="form.redirectUrisString" placeholder="多个URI用逗号分隔" />
|
||||
<el-form-item label="重定向URI" prop="redirectUrisString">
|
||||
<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 label="授权类型" prop="grantTypes">
|
||||
<el-select v-model="form.grantTypes" multiple>
|
||||
<el-option label="授权码" value="authorization_code" />
|
||||
<el-option label="密码" value="password" />
|
||||
<el-option label="刷新令牌" value="refresh_token" />
|
||||
<el-checkbox-group v-model="form.grantTypes">
|
||||
<el-checkbox label="authorization_code">授权码模式</el-checkbox>
|
||||
<el-checkbox label="password">密码模式</el-checkbox>
|
||||
<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-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="活跃" value="active" />
|
||||
<el-option label="禁用" value="inactive" />
|
||||
</el-select>
|
||||
<el-switch v-model="form.status" active-value="active" inactive-value="inactive" active-text="活跃" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@ -75,23 +151,67 @@
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { 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 pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const secretDialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加OAuth应用')
|
||||
const formRef = ref<FormInstance>()
|
||||
const isEdit = ref(false)
|
||||
const currentClientSecret = ref('')
|
||||
|
||||
const searchForm = reactive({
|
||||
displayName: '',
|
||||
clientId: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
@ -99,27 +219,87 @@ const form = reactive({
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
redirectUrisString: '',
|
||||
postLogoutRedirectUrisString: '',
|
||||
scopesString: 'api\noffline_access',
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
clientType: 'confidential',
|
||||
consentType: 'implicit',
|
||||
status: 'active',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
displayName: [
|
||||
{ required: true, message: '请输入应用名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '应用名称长度2-50个字符', trigger: 'blur' },
|
||||
],
|
||||
clientId: [
|
||||
{ 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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await authService.getClients()
|
||||
clients.value = Array.isArray(response) ? response : []
|
||||
total.value = clients.value.length
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.value.toString(),
|
||||
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) {
|
||||
ElMessage.error('获取OAuth应用列表失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,35 +312,85 @@ const handleAdd = () => {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
redirectUrisString: '',
|
||||
postLogoutRedirectUrisString: '',
|
||||
scopesString: 'api\noffline_access',
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
clientType: 'confidential',
|
||||
consentType: 'implicit',
|
||||
status: 'active',
|
||||
description: '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
const handleEdit = (row: OAuthClient) => {
|
||||
dialogTitle.value = '编辑OAuth应用'
|
||||
isEdit.value = true
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
displayName: row.displayName,
|
||||
clientId: row.clientId,
|
||||
clientSecret: row.clientSecret || '',
|
||||
redirectUrisString: Array.isArray(row.redirectUris) ? row.redirectUris.join(',') : '',
|
||||
grantTypes: row.grantTypes,
|
||||
clientSecret: '',
|
||||
redirectUrisString: Array.isArray(row.redirectUris) ? row.redirectUris.join('\n') : '',
|
||||
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,
|
||||
description: row.description || '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
const handleViewSecret = async (row: OAuthClient) => {
|
||||
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('删除成功')
|
||||
await fetchClients()
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败')
|
||||
console.error(error)
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,27 +398,80 @@ const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate()
|
||||
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 = {
|
||||
...form,
|
||||
redirectUris: form.redirectUrisString.split(',').map(uri => uri.trim()),
|
||||
displayName: form.displayName,
|
||||
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 {
|
||||
let url = '/api/auth/oauthclients'
|
||||
let method = 'POST'
|
||||
|
||||
const payload = { ...formData }
|
||||
if (isEdit.value) {
|
||||
await authService.updateClient(formData.id, formData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await authService.createClient(formData)
|
||||
ElMessage.success('创建成功')
|
||||
url = `/api/auth/oauthclients/${form.id}`
|
||||
method = 'PUT'
|
||||
const { clientSecret, ...updateData } = payload
|
||||
Object.assign(payload, updateData)
|
||||
}
|
||||
|
||||
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
|
||||
await fetchClients()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
Object.assign(searchForm, {
|
||||
displayName: '',
|
||||
clientId: '',
|
||||
status: '',
|
||||
})
|
||||
currentPage.value = 1
|
||||
fetchClients()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
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>
|
||||
<div class="header-content">
|
||||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加用户
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="users" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column prop="realName" label="姓名" />
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<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="邮箱">
|
||||
<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 label="角色">
|
||||
<el-table-column label="角色" width="150">
|
||||
<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 }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@ -28,6 +49,25 @@
|
||||
</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-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-pagination
|
||||
@ -35,37 +75,399 @@
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="fetchUsers"
|
||||
layout="total, prev, pager, next"
|
||||
@size-change="fetchUsers"
|
||||
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="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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
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 pageSize = ref(10)
|
||||
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 () => {
|
||||
loading.value = true
|
||||
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()
|
||||
users.value = Array.isArray(data) ? data : []
|
||||
total.value = users.value.length
|
||||
users.value = Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []
|
||||
total.value = data.totalCount || users.value.length
|
||||
} catch (error) {
|
||||
ElMessage.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(() => {
|
||||
fetchUsers()
|
||||
fetchRoles()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -74,7 +476,13 @@ onMounted(() => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-content h2 {
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
export default defineConfig(() => ({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
@ -12,16 +12,18 @@ export default defineConfig(({ mode }) => ({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api/auth': {
|
||||
target: 'http://localhost:5000',
|
||||
'/.well-known': {
|
||||
target: 'http://localhost:5132',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/auth/, '')
|
||||
},
|
||||
'/api/gateway': {
|
||||
target: 'http://localhost:5001',
|
||||
'/connect': {
|
||||
target: 'http://localhost:5132',
|
||||
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