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