Features - User Management (Phase 4.1): - Database: Add user_modules table for fine-grained module permissions - Database: Add 4 user permissions (view/create/edit/delete) to role_permissions - Backend: UserService (780 lines) - CRUD with tenant isolation - Backend: UserController + UserRoutes (648 lines) - 13 API endpoints - Backend: Batch import users from Excel - Frontend: UserListPage (412 lines) - list/filter/search/pagination - Frontend: UserFormPage (341 lines) - create/edit with module config - Frontend: UserDetailPage (393 lines) - details/tenant/module management - Frontend: 3 modal components (592 lines) - import/assign/configure - API: GET/POST/PUT/DELETE /api/admin/users/* endpoints Architecture Upgrade - Module Permission System: - Backend: Add getUserModules() method in auth.service - Backend: Login API returns modules array in user object - Frontend: AuthContext adds hasModule() method - Frontend: Navigation filters modules based on user.modules - Frontend: RouteGuard checks requiredModule instead of requiredVersion - Frontend: Remove deprecated version-based permission system - UX: Only show accessible modules in navigation (clean UI) - UX: Smart redirect after login (avoid 403 for regular users) Fixes: - Fix UTF-8 encoding corruption in ~100 docs files - Fix pageSize type conversion in userService (String to Number) - Fix authUser undefined error in TopNavigation - Fix login redirect logic with role-based access check - Update Git commit guidelines v1.2 with UTF-8 safety rules Database Changes: - CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled) - ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code) - INSERT 4 permissions + role assignments - UPDATE PUBLIC tenant with 8 module subscriptions Technical: - Backend: 5 new files (~2400 lines) - Frontend: 10 new files (~2500 lines) - Docs: 1 development record + 2 status updates + 1 guideline update - Total: ~4900 lines of code Status: User management 100% complete, module permission system operational
195 lines
4.2 KiB
TypeScript
195 lines
4.2 KiB
TypeScript
/**
|
||
* JWT Service
|
||
*
|
||
* JWT令牌的生成、验证和刷新
|
||
*
|
||
* 设计原则:
|
||
* - 使用 jsonwebtoken 库
|
||
* - Token payload 包含用户ID、角色、租户ID
|
||
* - 支持 Access Token 和 Refresh Token
|
||
*/
|
||
|
||
import jwt, { SignOptions, JwtPayload } from 'jsonwebtoken';
|
||
import { config } from '../../config/env.js';
|
||
|
||
/**
|
||
* JWT Payload 接口
|
||
*/
|
||
export interface JWTPayload {
|
||
/** 用户ID */
|
||
userId: string;
|
||
/** 用户手机号 */
|
||
phone: string;
|
||
/** 用户角色 */
|
||
role: string;
|
||
/** 租户ID */
|
||
tenantId: string;
|
||
/** 租户Code(用于URL路由) */
|
||
tenantCode?: string;
|
||
/** 是否为默认密码 */
|
||
isDefaultPassword?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Token 响应接口
|
||
*/
|
||
export interface TokenResponse {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresIn: number; // 秒
|
||
tokenType: 'Bearer';
|
||
}
|
||
|
||
/**
|
||
* 解码后的 Token 接口
|
||
*/
|
||
export interface DecodedToken extends JWTPayload {
|
||
iat: number; // 签发时间
|
||
exp: number; // 过期时间
|
||
}
|
||
|
||
// Token 配置
|
||
const ACCESS_TOKEN_EXPIRES_IN = '2h'; // Access Token 2小时过期
|
||
const REFRESH_TOKEN_EXPIRES_IN = '7d'; // Refresh Token 7天过期
|
||
const ACCESS_TOKEN_EXPIRES_SECONDS = 2 * 60 * 60; // 7200秒
|
||
|
||
/**
|
||
* JWT Service 类
|
||
*/
|
||
export class JWTService {
|
||
private readonly secret: string;
|
||
|
||
constructor() {
|
||
this.secret = config.jwtSecret;
|
||
if (this.secret === 'your-secret-key-change-in-production' && config.nodeEnv === 'production') {
|
||
throw new Error('JWT_SECRET must be configured in production environment');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成 Access Token
|
||
*/
|
||
generateAccessToken(payload: JWTPayload): string {
|
||
const options: SignOptions = {
|
||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||
issuer: 'aiclinical',
|
||
subject: payload.userId,
|
||
};
|
||
|
||
return jwt.sign(payload, this.secret, options);
|
||
}
|
||
|
||
/**
|
||
* 生成 Refresh Token
|
||
*/
|
||
generateRefreshToken(payload: JWTPayload): string {
|
||
// Refresh Token 只包含必要信息
|
||
const refreshPayload = {
|
||
userId: payload.userId,
|
||
type: 'refresh',
|
||
};
|
||
|
||
const options: SignOptions = {
|
||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||
issuer: 'aiclinical',
|
||
subject: payload.userId,
|
||
};
|
||
|
||
return jwt.sign(refreshPayload, this.secret, options);
|
||
}
|
||
|
||
/**
|
||
* 生成完整的 Token 响应
|
||
*/
|
||
generateTokens(payload: JWTPayload): TokenResponse {
|
||
return {
|
||
accessToken: this.generateAccessToken(payload),
|
||
refreshToken: this.generateRefreshToken(payload),
|
||
expiresIn: ACCESS_TOKEN_EXPIRES_SECONDS,
|
||
tokenType: 'Bearer',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 验证 Token
|
||
*/
|
||
verifyToken(token: string): DecodedToken {
|
||
try {
|
||
const decoded = jwt.verify(token, this.secret, {
|
||
issuer: 'aiclinical',
|
||
}) as DecodedToken;
|
||
|
||
return decoded;
|
||
} catch (error) {
|
||
if (error instanceof jwt.TokenExpiredError) {
|
||
throw new Error('Token已过期');
|
||
}
|
||
if (error instanceof jwt.JsonWebTokenError) {
|
||
throw new Error('Token无效');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 刷新 Token
|
||
*
|
||
* 使用 Refresh Token 获取新的 Access Token
|
||
*/
|
||
async refreshToken(refreshToken: string, getUserById: (id: string) => Promise<JWTPayload | null>): Promise<TokenResponse> {
|
||
// 验证 Refresh Token
|
||
const decoded = this.verifyToken(refreshToken);
|
||
|
||
if ((decoded as any).type !== 'refresh') {
|
||
throw new Error('无效的Refresh Token');
|
||
}
|
||
|
||
// 获取用户最新信息
|
||
const user = await getUserById(decoded.userId);
|
||
if (!user) {
|
||
throw new Error('用户不存在');
|
||
}
|
||
|
||
// 生成新的 Tokens
|
||
return this.generateTokens(user);
|
||
}
|
||
|
||
/**
|
||
* 从 Authorization Header 中提取 Token
|
||
*/
|
||
extractTokenFromHeader(authHeader: string | undefined): string | null {
|
||
if (!authHeader) {
|
||
return null;
|
||
}
|
||
|
||
const [type, token] = authHeader.split(' ');
|
||
if (type !== 'Bearer' || !token) {
|
||
return null;
|
||
}
|
||
|
||
return token;
|
||
}
|
||
|
||
/**
|
||
* 解码 Token(不验证签名)
|
||
*
|
||
* 用于调试或获取过期token的payload
|
||
*/
|
||
decodeToken(token: string): DecodedToken | null {
|
||
const decoded = jwt.decode(token);
|
||
return decoded as DecodedToken | null;
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const jwtService = new JWTService();
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|