|
|
@@ -2,9 +2,16 @@
|
|
|
* login.ts —— 登录态 store(Pinia setup 风格;README §3.2 状态最小化)。
|
|
|
*
|
|
|
* 职责边界:
|
|
|
- * - 持有**响应式视图**(token/角色/权限码),供组件 `useLogin()` 读取渲染菜单/按钮;
|
|
|
+ * - 持有**响应式视图**(token/角色/权限码/菜单树),供组件 `useLogin()` 读取渲染菜单/按钮;
|
|
|
* - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage);
|
|
|
- * - 登录/登出动作封装在此(调 api/auth/login.ts → 写 token-store → 同步视图)。
|
|
|
+ * - 登录/登出动作封装在此(调 api/auth/login.ts 两步 → 写 token-store → 同步视图 + scope)。
|
|
|
+ *
|
|
|
+ * 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
|
|
|
+ * ① 验账密成功 → applyStep1():暂存一次性票据 + 作用域列表 + 用户名(**不落 token**);
|
|
|
+ * 单条作用域:登录页自动 applyLogin(②);多条:进作用域选择页由用户挑选。
|
|
|
+ * ② 选定作用域签发 → applyLogin(TokenPair):写 token + 同步 roles/permissions/menuTree + 设 scope。
|
|
|
+ * pendingTicket/pendingMemberships 为 ①→② 之间的**内存过渡态**(票据 5min 有效、单次消费;
|
|
|
+ * 不落 localStorage——泄露的 ticket 可被换 token,落盘徒增风险面;刷新即丢,需重走①)。
|
|
|
*
|
|
|
* 典型使用(组件内):
|
|
|
*
|
|
|
@@ -12,8 +19,9 @@
|
|
|
* v-perm="'academic.fee.refund'" 指令读 login.hasPerm;
|
|
|
* <template>{{ login.realName }}</template>
|
|
|
*/
|
|
|
-import { logout as apiLogout, type LoginResult } from '@/api/auth/login';
|
|
|
+import { logout as apiLogout, type LoginStep1, type MembershipView, type TokenPair } from '@/api/auth/login';
|
|
|
import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '@/identity/token-store';
|
|
|
+import { useScope } from '@/store/scope';
|
|
|
import type { ComputedRef, Ref } from 'vue';
|
|
|
import type { MenuNode } from '@/types/api';
|
|
|
|
|
|
@@ -27,20 +35,26 @@ export interface LoginStore {
|
|
|
accessToken: Ref<string | null>;
|
|
|
/** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */
|
|
|
expiresAt: Ref<number | null>;
|
|
|
- /** 当前用户真实姓名(未登录为 '') */
|
|
|
+ /** 当前用户真实姓名(未登录为 '';① 验账密成功后即有值) */
|
|
|
realName: Ref<string>;
|
|
|
- /** 角色码数组(7 角色字典;未登录为 []) */
|
|
|
- roles: Ref<number[]>;
|
|
|
+ /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
|
|
|
+ roles: Ref<string[]>;
|
|
|
/** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据,README §3.1) */
|
|
|
permissions: Ref<Set<string>>;
|
|
|
- /** 可见导航菜单树(登录响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
|
|
|
+ /** 可见导航菜单树(登录② 响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
|
|
|
menuTree: Ref<MenuNode[]>;
|
|
|
+ /** 两步登录① 暂存:一次性票据(null = 未处于 ①→② 过渡态) */
|
|
|
+ pendingTicket: Ref<string | null>;
|
|
|
+ /** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
|
|
|
+ pendingMemberships: Ref<MembershipView[]>;
|
|
|
/** 是否已登录(有 access token 且未过期;守卫链第一步) */
|
|
|
isLoggedIn: ComputedRef<boolean>;
|
|
|
/** 判断是否持有指定权限码(v-perm 指令用) */
|
|
|
hasPerm: (perm: string) => boolean;
|
|
|
- /** 登录成功后的凭证落盘(http 拦截器/组件不得自行写) */
|
|
|
- applyLogin: (data: LoginResult) => void;
|
|
|
+ /** 登录① 成功后的过渡态落盘(暂存票据 + 作用域列表 + 用户名;不写 token) */
|
|
|
+ applyStep1: (data: LoginStep1) => void;
|
|
|
+ /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图 + scope) */
|
|
|
+ applyLogin: (data: TokenPair) => void;
|
|
|
/** 执行登出(调后端吊销 → 清本地态) */
|
|
|
doLogout: () => Promise<void>;
|
|
|
/** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
|
|
|
@@ -51,11 +65,14 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
const accessToken = ref<string | null>(null);
|
|
|
const expiresAt = ref<number | null>(null);
|
|
|
const realName = ref('');
|
|
|
- const roles = ref<number[]>([]);
|
|
|
+ const roles = ref<string[]>([]);
|
|
|
const permissions = ref(new Set<string>());
|
|
|
// 可见导航菜单树(仅内存态,不落 localStorage——menuTree 是响应式视图非凭证,
|
|
|
// 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
|
|
|
const menuTree = ref<MenuNode[]>([]);
|
|
|
+ // 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
|
|
|
+ const pendingTicket = ref<string | null>(null);
|
|
|
+ const pendingMemberships = ref<MembershipView[]>([]);
|
|
|
|
|
|
/** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
|
|
|
const isLoggedIn = computed(() => {
|
|
|
@@ -78,24 +95,51 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 登录成功凭证落盘(token-store 物理写入 + 响应式视图同步)。
|
|
|
+ * 登录① 成功过渡态落盘(暂存票据 + 作用域列表 + 用户名;**不写 token**)。
|
|
|
*
|
|
|
* 入参:
|
|
|
- * - data LoginResult:api/auth/login.ts 返回的 data(code=200 时)。
|
|
|
+ * - data LoginStep1:api/auth/login.ts ① 返回的 data(code=200 时)。
|
|
|
+ *
|
|
|
+ * 出参:无。
|
|
|
+ * 异常场景:
|
|
|
+ * - data 缺 loginTicket:视为后端契约违约,过渡态不落盘(pendingTicket 保持 null)。
|
|
|
+ */
|
|
|
+ function applyStep1(data: LoginStep1): void {
|
|
|
+ if (!data.loginTicket) return;
|
|
|
+ pendingTicket.value = data.loginTicket;
|
|
|
+ pendingMemberships.value = data.memberships ?? [];
|
|
|
+ realName.value = data.user?.realName ?? '';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步 + scope 设置)。
|
|
|
+ *
|
|
|
+ * 入参:
|
|
|
+ * - data TokenPair:api/auth/login.ts ② 返回的 data(code=200 时)。
|
|
|
*
|
|
|
* 出参:无。
|
|
|
* 异常场景:
|
|
|
* - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。
|
|
|
+ * - institutionId ≤ 0(平台级超管):scope.set 拒绝(机构必选),作用域保持未就绪——
|
|
|
+ * 平台级角色的作用域语义为独立待办,本处不特殊处理(对齐现状)。
|
|
|
*/
|
|
|
- function applyLogin(data: LoginResult): void {
|
|
|
+ function applyLogin(data: TokenPair): void {
|
|
|
if (!data.accessToken) return;
|
|
|
+ const scope = useScope();
|
|
|
setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt });
|
|
|
accessToken.value = data.accessToken;
|
|
|
expiresAt.value = data.expiresAt;
|
|
|
- realName.value = data.realName;
|
|
|
+ realName.value = data.user?.realName ?? realName.value;
|
|
|
roles.value = data.roles ?? [];
|
|
|
permissions.value = new Set(data.permissions ?? []);
|
|
|
menuTree.value = data.menuTree ?? [];
|
|
|
+ // 选定作用域写入 scope store(守卫链第四步据此放行;institutionId>0 才合法)
|
|
|
+ if (data.institutionId > 0) {
|
|
|
+ scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
|
|
|
+ }
|
|
|
+ // ② 完成:清过渡态(票据已消费,留着反成泄露面)
|
|
|
+ pendingTicket.value = null;
|
|
|
+ pendingMemberships.value = [];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -113,6 +157,8 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
} catch {
|
|
|
// 网络故障/后端错误:不阻塞本地登出(jti 黑名单到 access 30min 后自然失效)
|
|
|
}
|
|
|
+ const scope = useScope();
|
|
|
+ scope.reset();
|
|
|
clearTokens();
|
|
|
accessToken.value = null;
|
|
|
expiresAt.value = null;
|
|
|
@@ -120,6 +166,8 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
roles.value = [];
|
|
|
permissions.value = new Set();
|
|
|
menuTree.value = [];
|
|
|
+ pendingTicket.value = null;
|
|
|
+ pendingMemberships.value = [];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -130,10 +178,14 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
* 出参:无。
|
|
|
* 异常场景:
|
|
|
* - 无 token(未登录):视图置空,守卫链引导到登录页。
|
|
|
+ * - 有 token(刷新后):roles/permissions/menuTree 未知(② 过渡态不落盘,刷新即丢),
|
|
|
+ * 完整恢复依赖后续 /api/auth/me 端点(规划项);过渡态恒置空。
|
|
|
*/
|
|
|
function hydrate(): void {
|
|
|
accessToken.value = getAccessToken();
|
|
|
expiresAt.value = getExpiresAt();
|
|
|
+ pendingTicket.value = null;
|
|
|
+ pendingMemberships.value = [];
|
|
|
// 刷新后 roles/permissions 未知(后端未提供"凭 access 取用户中心资料"端点前),
|
|
|
// 首次请求 401 触发 refresh 流程;完整恢复依赖后续 /api/auth/me 端点(规划项)。
|
|
|
if (!accessToken.value) {
|
|
|
@@ -151,8 +203,11 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
roles,
|
|
|
permissions,
|
|
|
menuTree,
|
|
|
+ pendingTicket,
|
|
|
+ pendingMemberships,
|
|
|
isLoggedIn,
|
|
|
hasPerm,
|
|
|
+ applyStep1,
|
|
|
applyLogin,
|
|
|
doLogout,
|
|
|
hydrate,
|