ci / 质量门禁 (push) Canceled after 0s
背景: - 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、 权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。 改动: - packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role) 与 user:view 权限点;写接口 strict 校验。 - apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/ user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh 旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。 - apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403; usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。 - e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。 - openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。 影响面: - 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。 - 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。 - 平台导航「用户与组织」由 /platform/users 调整为 /users。 回滚方式: - 代码整体回滚本提交;迁移为 expand,回滚代码不需删表; - 本地如需彻底回退按实施报告 §6 处理。 验证证据: - pnpm format:check / lint / typecheck 通过; - pnpm test:shared 95 / api 60 / web 55; - pnpm test:int:7 个(真实 PG/Redis); - A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6; - 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
193 lines
5.6 KiB
TypeScript
193 lines
5.6 KiB
TypeScript
import { type MeResponse, PERMISSION_CODES } from '@lingxi/shared';
|
|
import { create } from 'zustand';
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
name: string;
|
|
account: string;
|
|
roleName?: string;
|
|
}
|
|
|
|
export interface AuthSession {
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
user: AuthUser | null;
|
|
permissions: string[];
|
|
mustChangePassword: boolean;
|
|
}
|
|
|
|
export interface SetSessionInput {
|
|
accessToken: string;
|
|
refreshToken?: string | null;
|
|
user: AuthUser;
|
|
permissions?: readonly string[];
|
|
mustChangePassword?: boolean;
|
|
}
|
|
|
|
export interface AuthState extends AuthSession {
|
|
setSession: (input: SetSessionInput) => void;
|
|
setTokens: (tokens: { accessToken: string; refreshToken?: string | null }) => void;
|
|
/** /me 拉取成功后刷新用户与权限(token 不变) */
|
|
syncFromMe: (me: MeResponse) => void;
|
|
clearSession: () => void;
|
|
/** 首次登录强制改密成功后清除标记 */
|
|
markPasswordChanged: () => void;
|
|
signInDemo: () => void;
|
|
hasPermission: (code: string) => boolean;
|
|
}
|
|
|
|
const STORAGE_KEY = 'lingxi.auth.session';
|
|
|
|
/** 演示会话令牌:仅当 `VITE_PUBLIC_DEMO_SESSION === 'true'` 时写入,守卫据此跳过 /me 请求 */
|
|
export const DEMO_ACCESS_TOKEN = 'demo-access-token';
|
|
|
|
const EMPTY_SESSION: AuthSession = {
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
permissions: [],
|
|
mustChangePassword: false,
|
|
};
|
|
|
|
export const DEMO_USER: AuthUser = {
|
|
id: '01J0DEMO000000000000000000',
|
|
name: '灵犀管理员',
|
|
account: 'admin@lingxi.local',
|
|
roleName: '平台管理员',
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function isAuthUser(value: unknown): value is AuthUser {
|
|
return (
|
|
isRecord(value) &&
|
|
typeof value.id === 'string' &&
|
|
typeof value.name === 'string' &&
|
|
typeof value.account === 'string'
|
|
);
|
|
}
|
|
|
|
function isAuthSession(value: unknown): value is AuthSession {
|
|
return (
|
|
isRecord(value) &&
|
|
(typeof value.accessToken === 'string' || value.accessToken === null) &&
|
|
(typeof value.refreshToken === 'string' || value.refreshToken === null) &&
|
|
(isAuthUser(value.user) || value.user === null) &&
|
|
Array.isArray(value.permissions) &&
|
|
value.permissions.every((permission) => typeof permission === 'string') &&
|
|
(value.mustChangePassword === undefined || typeof value.mustChangePassword === 'boolean')
|
|
);
|
|
}
|
|
|
|
/** 兼容旧版本地会话(无 mustChangePassword 字段) */
|
|
function normalizeSession(session: AuthSession): AuthSession {
|
|
return { ...session, mustChangePassword: session.mustChangePassword };
|
|
}
|
|
|
|
function readPersistedSession(): AuthSession {
|
|
if (typeof window === 'undefined') {
|
|
return EMPTY_SESSION;
|
|
}
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (raw === null) {
|
|
return EMPTY_SESSION;
|
|
}
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return isAuthSession(parsed) ? normalizeSession(parsed) : EMPTY_SESSION;
|
|
} catch {
|
|
return EMPTY_SESSION;
|
|
}
|
|
}
|
|
|
|
function persistSession(session: AuthSession): void {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
try {
|
|
if (session.accessToken === null) {
|
|
window.localStorage.removeItem(STORAGE_KEY);
|
|
return;
|
|
}
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
|
} catch {
|
|
// 本地存储不可用(隐私模式等)时退化为内存会话,不影响登录流程
|
|
}
|
|
}
|
|
|
|
/** /me 响应 → 顶栏展示所需的轻量用户 */
|
|
export function authUserFromMe(me: MeResponse): AuthUser {
|
|
return {
|
|
id: me.user.id,
|
|
name: me.user.name,
|
|
account: me.user.account,
|
|
roleName: me.roles[0]?.name,
|
|
};
|
|
}
|
|
|
|
/** 是否为演示会话(无后端联调时仍可浏览页面) */
|
|
export function isDemoSession(accessToken: string | null): boolean {
|
|
return accessToken === DEMO_ACCESS_TOKEN;
|
|
}
|
|
|
|
/* eslint-disable-next-line max-lines-per-function -- 会话状态机集中定义,便于统一审阅持久化与权限映射 */
|
|
export const useAuthStore = create<AuthState>()((set, get) => ({
|
|
...readPersistedSession(),
|
|
setSession: (input) => {
|
|
const session: AuthSession = {
|
|
accessToken: input.accessToken,
|
|
refreshToken: input.refreshToken ?? null,
|
|
user: input.user,
|
|
permissions: [...(input.permissions ?? PERMISSION_CODES)],
|
|
mustChangePassword: input.mustChangePassword === true,
|
|
};
|
|
persistSession(session);
|
|
set(session);
|
|
},
|
|
setTokens: (tokens) => {
|
|
const current = get();
|
|
const session: AuthSession = {
|
|
...current,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken ?? current.refreshToken,
|
|
};
|
|
persistSession(session);
|
|
set({ accessToken: session.accessToken, refreshToken: session.refreshToken });
|
|
},
|
|
syncFromMe: (me) => {
|
|
const current = get();
|
|
const session: AuthSession = {
|
|
...current,
|
|
user: authUserFromMe(me),
|
|
permissions: [...me.permissions],
|
|
mustChangePassword: me.mustChangePassword,
|
|
};
|
|
persistSession(session);
|
|
set(session);
|
|
},
|
|
clearSession: () => {
|
|
persistSession(EMPTY_SESSION);
|
|
set({ ...EMPTY_SESSION, permissions: [] });
|
|
},
|
|
markPasswordChanged: () => {
|
|
const current = get();
|
|
const session: AuthSession = { ...current, mustChangePassword: false };
|
|
persistSession(session);
|
|
set({ mustChangePassword: false });
|
|
},
|
|
signInDemo: () => {
|
|
const session: AuthSession = {
|
|
accessToken: DEMO_ACCESS_TOKEN,
|
|
refreshToken: 'demo-refresh-token',
|
|
user: DEMO_USER,
|
|
permissions: [...PERMISSION_CODES],
|
|
mustChangePassword: false,
|
|
};
|
|
persistSession(session);
|
|
set(session);
|
|
},
|
|
hasPermission: (code) => get().permissions.includes(code),
|
|
}));
|