@liujitcn/kratos-admin-core 0.0.29 → 0.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +15 -8
  2. package/build/auto-imports.json +73 -0
  3. package/build/plugins.ts +2 -58
  4. package/dist/package/declarations/src/api/base/{config.d.ts → v1/config.d.ts} +1 -1
  5. package/dist/package/declarations/src/api/base/{file.d.ts → v1/file.d.ts} +3 -1
  6. package/dist/package/declarations/src/api/base/{language.d.ts → v1/language.d.ts} +1 -1
  7. package/dist/package/declarations/src/api/base/{login.d.ts → v1/login.d.ts} +2 -2
  8. package/dist/package/declarations/src/api/base/v1/mfa.d.ts +26 -0
  9. package/dist/package/declarations/src/api/base/{oauth.d.ts → v1/oauth.d.ts} +1 -1
  10. package/dist/package/declarations/src/api/system/{auth.d.ts → admin/v1/auth.d.ts} +2 -2
  11. package/dist/package/declarations/src/api/system/{base_dict.d.ts → admin/v1/base_dict.d.ts} +1 -1
  12. package/dist/package/declarations/src/api/system/{base_tenant.d.ts → admin/v1/base_tenant.d.ts} +2 -2
  13. package/dist/package/declarations/src/components/Dialog/FormDialog.vue.d.ts +3 -3
  14. package/dist/package/declarations/src/components/Dialog/PasswordVerifyDialog.vue.d.ts +65 -0
  15. package/dist/package/declarations/src/components/Dialog/ProDialog.vue.d.ts +1 -1
  16. package/dist/package/declarations/src/components/Grid/components/GridItem.vue.d.ts +1 -1
  17. package/dist/package/declarations/src/components/Mfa/MfaRecoveryCodesDialog.vue.d.ts +19 -0
  18. package/dist/package/declarations/src/components/Mfa/MfaSetupPanel.vue.d.ts +8 -0
  19. package/dist/package/declarations/src/components/ProForm/components/ProFormItem.vue.d.ts +4 -4
  20. package/dist/package/declarations/src/components/ProForm/index.vue.d.ts +1 -1
  21. package/dist/package/declarations/src/components/ProForm/interface/index.d.ts +4 -1
  22. package/dist/package/declarations/src/components/ProTable/index.vue.d.ts +114 -0
  23. package/dist/package/declarations/src/components/RichTextPreview/index.vue.d.ts +8 -0
  24. package/dist/package/declarations/src/components/TreeFilter/index.vue.d.ts +84 -76
  25. package/dist/package/declarations/src/components/Upload/File.vue.d.ts +1 -1
  26. package/dist/package/declarations/src/components/Upload/Files.vue.d.ts +1 -1
  27. package/dist/package/declarations/src/components/Upload/Img.vue.d.ts +2 -2
  28. package/dist/package/declarations/src/components/Upload/Imgs.vue.d.ts +2 -2
  29. package/dist/package/declarations/src/components/WangEditor/index.vue.d.ts +2 -0
  30. package/dist/package/declarations/src/documentTitle.d.ts +2 -0
  31. package/dist/package/declarations/src/format.d.ts +1 -1
  32. package/dist/package/declarations/src/index.d.ts +2 -1
  33. package/dist/package/declarations/src/layouts/components/LockScreen.vue.d.ts +3 -0
  34. package/dist/package/declarations/src/locales/generated.d.ts +208 -0
  35. package/dist/package/declarations/src/locales/index.d.ts +2 -0
  36. package/dist/package/declarations/src/modules/index.d.ts +1 -3
  37. package/dist/package/declarations/src/request.d.ts +1 -1
  38. package/dist/package/declarations/src/routers/index.d.ts +1 -1
  39. package/dist/package/declarations/src/rpc/base/v1/login.d.ts +37 -5
  40. package/dist/package/declarations/src/rpc/base/v1/mfa.d.ts +152 -0
  41. package/dist/package/declarations/src/rpc/base/v1/oauth.d.ts +25 -0
  42. package/dist/package/declarations/src/rpc/base/v1/oauth_client.d.ts +23 -0
  43. package/dist/package/declarations/src/rpc/system/admin/v1/auth.d.ts +13 -0
  44. package/dist/package/declarations/src/rpc/system/admin/v1/base_dict.d.ts +0 -101
  45. package/dist/package/declarations/src/rpc/system/admin/v1/base_i18n.d.ts +10 -6
  46. package/dist/package/declarations/src/rpc/system/common/v1/common.d.ts +26 -0
  47. package/dist/package/declarations/src/security.d.ts +2 -0
  48. package/dist/package/declarations/src/stores/interface/index.d.ts +19 -1
  49. package/dist/package/declarations/src/stores/modules/lockScreen.d.ts +20 -0
  50. package/dist/package/declarations/src/stores/modules/user.d.ts +7 -6
  51. package/dist/package/declarations/src/stores/runtime.d.ts +1 -0
  52. package/dist/package/declarations/src/utils/clipboard.d.ts +2 -0
  53. package/dist/package/declarations/src/utils/passwordStrength.d.ts +1 -12
  54. package/dist/package/declarations/src/utils/request.d.ts +13 -2
  55. package/dist/package/declarations/src/utils/utils.d.ts +4 -0
  56. package/dist/package/declarations/src/utils/webauthn.d.ts +4 -0
  57. package/dist/package/declarations/vite.config.d.ts +2 -0
  58. package/dist/package/src/api/base/{config.ts → v1/config.ts} +2 -2
  59. package/dist/package/src/api/base/{file.ts → v1/file.ts} +20 -24
  60. package/dist/package/src/api/base/{language.ts → v1/language.ts} +2 -2
  61. package/dist/package/src/api/base/{login.ts → v1/login.ts} +3 -3
  62. package/dist/package/src/api/base/v1/mfa.ts +114 -0
  63. package/dist/package/src/api/base/{oauth.ts → v1/oauth.ts} +2 -2
  64. package/dist/package/src/api/system/{auth.ts → admin/v1/auth.ts} +3 -3
  65. package/dist/package/src/api/system/{base_dict.ts → admin/v1/base_dict.ts} +2 -2
  66. package/dist/package/src/api/system/{base_tenant.ts → admin/v1/base_tenant.ts} +3 -3
  67. package/dist/package/src/bootstrap.ts +6 -5
  68. package/dist/package/src/components/CronExpression/index.vue +4 -3
  69. package/dist/package/src/components/Dialog/PasswordVerifyDialog.vue +176 -0
  70. package/dist/package/src/components/Dialog/ProDialog.vue +1 -0
  71. package/dist/package/src/components/ImportExcel/index.vue +4 -2
  72. package/dist/package/src/components/Loading/fullScreen.ts +2 -1
  73. package/dist/package/src/components/MarkdownPreview/index.vue +22 -2
  74. package/dist/package/src/components/Mfa/MfaRecoveryCodesDialog.vue +91 -0
  75. package/dist/package/src/components/Mfa/MfaSetupPanel.vue +81 -0
  76. package/dist/package/src/components/PasswordStrength/index.vue +1 -1
  77. package/dist/package/src/components/ProForm/components/ProFormItem.vue +16 -1
  78. package/dist/package/src/components/ProForm/interface/index.ts +4 -0
  79. package/dist/package/src/components/ProTable/components/ColSetting.vue +1 -1
  80. package/dist/package/src/components/ProTable/components/TableColumn.vue +14 -6
  81. package/dist/package/src/components/ProTable/index.vue +9 -5
  82. package/dist/package/src/components/RichTextPreview/index.vue +56 -0
  83. package/dist/package/src/components/SelectIcon/index.vue +3 -2
  84. package/dist/package/src/components/Upload/File.vue +2 -2
  85. package/dist/package/src/components/Upload/Files.vue +2 -2
  86. package/dist/package/src/components/Upload/Img.vue +7 -5
  87. package/dist/package/src/components/Upload/Imgs.vue +6 -5
  88. package/dist/package/src/components/WangEditor/index.vue +10 -7
  89. package/dist/package/src/config/index.ts +1 -1
  90. package/dist/package/src/documentTitle.ts +8 -0
  91. package/dist/package/src/format.ts +1 -1
  92. package/dist/package/src/index.ts +2 -1
  93. package/dist/package/src/layouts/LayoutClassic/index.vue +3 -1
  94. package/dist/package/src/layouts/LayoutColumns/index.vue +3 -1
  95. package/dist/package/src/layouts/LayoutTransverse/index.vue +3 -1
  96. package/dist/package/src/layouts/LayoutVertical/index.vue +9 -1
  97. package/dist/package/src/layouts/components/Header/components/Avatar.vue +26 -6
  98. package/dist/package/src/layouts/components/Header/components/InfoDialog.vue +4 -3
  99. package/dist/package/src/layouts/components/Header/components/PasswordDialog.vue +4 -3
  100. package/dist/package/src/layouts/components/Header/components/SearchMenu.vue +16 -8
  101. package/dist/package/src/layouts/components/LockScreen.vue +573 -0
  102. package/dist/package/src/layouts/components/Tabs/index.vue +12 -3
  103. package/dist/package/src/layouts/components/ThemeDrawer/index.scss +4 -4
  104. package/dist/package/src/layouts/index.vue +2 -0
  105. package/dist/package/src/layouts/indexAsync.vue +2 -0
  106. package/dist/package/src/locales/README.md +2 -1
  107. package/dist/package/src/locales/en-US.json +51 -3
  108. package/dist/package/src/locales/index.ts +16 -12
  109. package/dist/package/src/locales/ja-JP.json +51 -3
  110. package/dist/package/src/locales/zh-CN.json +51 -3
  111. package/dist/package/src/locales/zh-TW.json +51 -3
  112. package/dist/package/src/modules/index.ts +2 -4
  113. package/dist/package/src/request.ts +3 -0
  114. package/dist/package/src/routers/index.ts +15 -14
  115. package/dist/package/src/rpc/base/v1/login.ts +37 -4
  116. package/dist/package/src/rpc/base/v1/mfa.ts +180 -0
  117. package/dist/package/src/rpc/base/v1/oauth.ts +25 -0
  118. package/dist/package/src/rpc/base/v1/oauth_client.ts +33 -0
  119. package/dist/package/src/rpc/system/admin/v1/auth.ts +13 -0
  120. package/dist/package/src/rpc/system/admin/v1/base_dict.ts +0 -112
  121. package/dist/package/src/rpc/system/admin/v1/base_i18n.ts +10 -6
  122. package/dist/package/src/rpc/system/common/v1/common.ts +35 -0
  123. package/dist/package/src/security.ts +2 -0
  124. package/dist/package/src/stores/interface/index.ts +20 -1
  125. package/dist/package/src/stores/modules/auth.ts +1 -1
  126. package/dist/package/src/stores/modules/config.ts +2 -2
  127. package/dist/package/src/stores/modules/dict.ts +1 -1
  128. package/dist/package/src/stores/modules/lockScreen.ts +78 -0
  129. package/dist/package/src/stores/modules/user.ts +34 -22
  130. package/dist/package/src/stores/runtime.ts +1 -0
  131. package/dist/package/src/styles/element.scss +35 -4
  132. package/dist/package/src/utils/clipboard.ts +22 -0
  133. package/dist/package/src/utils/passwordCrypto.ts +1 -1
  134. package/dist/package/src/utils/passwordStrength.ts +7 -24
  135. package/dist/package/src/utils/request.ts +175 -40
  136. package/dist/package/src/utils/tenant.ts +1 -1
  137. package/dist/package/src/utils/utils.ts +25 -6
  138. package/dist/package/src/utils/webauthn.ts +90 -0
  139. package/dist/package/src/views/login/components/LoginForm.vue +257 -24
  140. package/dist/package/src/views/login/index.scss +2 -2
  141. package/dist/package/src/views/login/index.vue +3 -1
  142. package/dist/package/types/generated/auto-imports.d.ts +132 -129
  143. package/dist/package/types/generated/components.d.ts +4 -72
  144. package/package.json +22 -1
  145. package/types/generated/auto-imports.d.ts +132 -129
  146. package/types/generated/components.d.ts +4 -72
  147. package/vite.config.ts +4 -1
@@ -1,6 +1,6 @@
1
- import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError } from "axios";
1
+ import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError, type AxiosRequestConfig } from "axios";
2
2
  import qs from "qs";
3
- import { ElMessage, ElMessageBox } from "element-plus";
3
+ import { reactive } from "vue";
4
4
  import router from "../routers";
5
5
  import { LOGIN_URL } from "../config";
6
6
  import pinia from "../stores";
@@ -12,6 +12,11 @@ const apiTargetUrl = import.meta.env.VITE_API_URL || import.meta.env.VITE_APP_AP
12
12
  export const requestBaseURL = `${apiTargetUrl}${apiBasePath}`;
13
13
  const SESSION_URL = "/v1/base/session";
14
14
  const TOKEN_URL = "/v1/base/token";
15
+ const REFRESH_EXPIRY_COOKIE = "kratos_refresh_exp";
16
+ const REFRESH_TOKEN_TRANSPORT_HEADER = "X-Refresh-Token-Transport";
17
+ const REFRESH_TOKEN_TRANSPORT_COOKIE = "cookie";
18
+ const PASSWORD_CHANGE_REQUIRED_METADATA_KEY = "password_change_required";
19
+ const PASSWORD_CHANGE_STATE_STORAGE_KEY = "admin-password-change-required";
15
20
  const CAPTCHA_URL = "/v1/base/captcha";
16
21
  const CONFIG_URL = "/v1/base/config";
17
22
  const LANGUAGE_URL = "/v1/base/language";
@@ -19,10 +24,10 @@ const PASSWORD_PUBLIC_KEY_URL = "/v1/base/password-public-key";
19
24
  const OAUTH_PROVIDER_URL = "/v1/base/oauth/provider";
20
25
  const OAUTH_AUTHORIZATION_URL = "/v1/base/oauth/authorization";
21
26
  const OAUTH_TICKET_URL = "/v1/base/oauth/ticket";
22
- const LEGACY_AUTH_URL = "/auth";
23
- const LEGACY_REFRESH_TOKEN_URL = `${LEGACY_AUTH_URL}/token`;
24
- const LEGACY_CAPTCHA_URL = "/login/captcha";
25
- // 认证公共接口不携带旧 token,同时保留旧路径兼容迁移期间的灰度访问。
27
+ const MFA_VERIFY_URL = "/v1/base/mfa/verify";
28
+ const MFA_ENROLLMENT_URL = "/v1/base/mfa/enrollment";
29
+ const MFA_ENROLLMENT_CONFIRM_URL = "/v1/base/mfa/enrollment/confirm";
30
+ // 认证公共接口不携带访问令牌。
26
31
  const NO_AUTH_URL_SET = new Set([
27
32
  SESSION_URL,
28
33
  TOKEN_URL,
@@ -33,9 +38,9 @@ const NO_AUTH_URL_SET = new Set([
33
38
  OAUTH_PROVIDER_URL,
34
39
  OAUTH_AUTHORIZATION_URL,
35
40
  OAUTH_TICKET_URL,
36
- LEGACY_AUTH_URL,
37
- LEGACY_CAPTCHA_URL,
38
- LEGACY_REFRESH_TOKEN_URL
41
+ MFA_VERIFY_URL,
42
+ MFA_ENROLLMENT_URL,
43
+ MFA_ENROLLMENT_CONFIRM_URL
39
44
  ]);
40
45
  const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
41
46
  SESSION_URL,
@@ -47,9 +52,9 @@ const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
47
52
  OAUTH_PROVIDER_URL,
48
53
  OAUTH_AUTHORIZATION_URL,
49
54
  OAUTH_TICKET_URL,
50
- LEGACY_AUTH_URL,
51
- LEGACY_REFRESH_TOKEN_URL,
52
- LEGACY_CAPTCHA_URL
55
+ MFA_VERIFY_URL,
56
+ MFA_ENROLLMENT_URL,
57
+ MFA_ENROLLMENT_CONFIRM_URL
53
58
  ]);
54
59
 
55
60
  /** 支持自动重试的 Axios 请求配置。 */
@@ -58,19 +63,60 @@ type RetryableRequestConfig = InternalAxiosRequestConfig & {
58
63
  _authRetried?: boolean;
59
64
  };
60
65
 
66
+ /** 统一的结构化错误响应。 */
67
+ type ErrorResponseData = {
68
+ code?: string | number;
69
+ message?: string;
70
+ reason?: string;
71
+ metadata?: Record<string, string>;
72
+ };
73
+
74
+ /** 本地持久化的强制改密状态,仅包含非敏感的显示信息。 */
75
+ interface PersistedPasswordChangeState {
76
+ /** 是否需要强制修改密码。 */
77
+ required: boolean;
78
+ /** 后端返回的提示文案。 */
79
+ message: string;
80
+ }
81
+
82
+ /** 读取刷新前保存的强制改密状态。 */
83
+ function getPersistedPasswordChangeState(): PersistedPasswordChangeState {
84
+ const emptyState = { required: false, message: "" };
85
+ if (typeof window === "undefined") return emptyState;
86
+
87
+ try {
88
+ const value = window.localStorage.getItem(PASSWORD_CHANGE_STATE_STORAGE_KEY);
89
+ if (!value) return emptyState;
90
+ const parsed = JSON.parse(value) as unknown;
91
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyState;
92
+ const persisted = parsed as { message?: unknown };
93
+ return {
94
+ required: true,
95
+ message: typeof persisted.message === "string" ? persisted.message : ""
96
+ };
97
+ } catch {
98
+ return emptyState;
99
+ }
100
+ }
101
+
102
+ /** 强制改密弹窗状态。 */
103
+ export const passwordChangeState = reactive(getPersistedPasswordChangeState());
104
+
61
105
  // 创建 axios 实例
62
106
  const service = axios.create({
63
107
  baseURL: requestBaseURL,
64
108
  timeout: 50000,
65
109
  headers: { "Content-Type": "application/json;charset=utf-8" },
66
- paramsSerializer: params => qs.stringify(params)
110
+ paramsSerializer: params => qs.stringify(params),
111
+ withCredentials: true
67
112
  });
68
113
 
69
114
  // 刷新令牌请求使用独立实例,避免与主请求拦截器互相递归。
70
115
  const refreshService = axios.create({
71
116
  baseURL: requestBaseURL,
72
117
  timeout: 50000,
73
- headers: { "Content-Type": "application/json;charset=utf-8" }
118
+ headers: { "Content-Type": "application/json;charset=utf-8" },
119
+ withCredentials: true
74
120
  });
75
121
 
76
122
  /** 获取用户状态仓库 */
@@ -83,6 +129,9 @@ function shouldSkipAuth(config: InternalAxiosRequestConfig) {
83
129
  if (config.headers.Authorization === "no-auth") return true;
84
130
 
85
131
  const requestUrl = config.url ?? "";
132
+ const requestMethod = String(config.method ?? "").toLowerCase();
133
+ // 登录请求会显式声明 no-auth,登出请求则必须带访问令牌让服务端清理会话和刷新令牌。
134
+ if (requestUrl === SESSION_URL && requestMethod === "delete") return false;
86
135
  return NO_AUTH_URL_SET.has(requestUrl);
87
136
  }
88
137
 
@@ -100,7 +149,57 @@ function shouldSkipErrorMessage(config?: InternalAxiosRequestConfig) {
100
149
 
101
150
  const requestUrl = config.url ?? "";
102
151
  const requestMethod = String(config.method ?? "").toLowerCase();
103
- return (requestUrl === SESSION_URL || requestUrl === LEGACY_AUTH_URL) && requestMethod === "delete";
152
+ return requestUrl === SESSION_URL && requestMethod === "delete";
153
+ }
154
+
155
+ /** 判断响应是否要求用户先修改密码。 */
156
+ function isPasswordChangeRequired(data?: ErrorResponseData) {
157
+ return data?.metadata?.[PASSWORD_CHANGE_REQUIRED_METADATA_KEY] === "true";
158
+ }
159
+
160
+ /** 判断最近是否已经处理过强制改密响应。 */
161
+ function isPasswordChangeRequiredPromptActive() {
162
+ return passwordChangePromptUntil > Date.now();
163
+ }
164
+
165
+ /** 处理强制改密响应,避免并发请求重复提示并打开全局改密弹窗。 */
166
+ export function handlePasswordChangeRequired(message: string) {
167
+ if (isPasswordChangeRequiredPromptActive()) return;
168
+ passwordChangePromptUntil = Date.now() + 10 * 1000;
169
+ passwordChangeState.required = true;
170
+ passwordChangeState.message = message;
171
+ persistPasswordChangeState();
172
+ }
173
+
174
+ /** 清理强制改密弹窗状态。 */
175
+ export function clearPasswordChangeRequired() {
176
+ passwordChangeState.required = false;
177
+ passwordChangeState.message = "";
178
+ passwordChangePromptUntil = 0;
179
+ persistPasswordChangeState();
180
+ }
181
+
182
+ /** 持久化或删除强制改密状态,兼容浏览器禁用本地存储的场景。 */
183
+ function persistPasswordChangeState() {
184
+ if (typeof window === "undefined") return;
185
+
186
+ try {
187
+ if (passwordChangeState.required) {
188
+ window.localStorage.setItem(
189
+ PASSWORD_CHANGE_STATE_STORAGE_KEY,
190
+ JSON.stringify({ message: passwordChangeState.message })
191
+ );
192
+ } else {
193
+ window.localStorage.removeItem(PASSWORD_CHANGE_STATE_STORAGE_KEY);
194
+ }
195
+ } catch {
196
+ // 本地存储不可用时仍保留当前页面内的响应式状态。
197
+ }
198
+ }
199
+
200
+ /** 统一展示请求错误,合并并发请求产生的相同提示。 */
201
+ function showRequestError(message: string) {
202
+ ElMessage.error({ message, grouping: true });
104
203
  }
105
204
 
106
205
  /** 读取访问令牌过期时间 */
@@ -108,6 +207,15 @@ function getTokenExpiresAt() {
108
207
  return getUserStore().tokenExpiresAt;
109
208
  }
110
209
 
210
+ /** 判断浏览器是否存在非敏感的刷新令牌过期提示。 */
211
+ function hasRefreshCookieHint() {
212
+ if (typeof document === "undefined") return false;
213
+ const item = document.cookie.split(";").find(value => value.trim().startsWith(`${REFRESH_EXPIRY_COOKIE}=`));
214
+ if (!item) return false;
215
+ const expiresAt = Number(item.split("=")[1]);
216
+ return Number.isFinite(expiresAt) && expiresAt * 1000 > Date.now();
217
+ }
218
+
111
219
  /** 判断当前访问令牌是否仍在有效期内。 */
112
220
  export function hasValidAccessToken() {
113
221
  const userStore = getUserStore();
@@ -117,7 +225,13 @@ export function hasValidAccessToken() {
117
225
  /** 读取最新可用访问令牌,必要时先串行刷新,供 axios、fetch 与 SSE 共用。 */
118
226
  export async function getRequestAccessToken(): Promise<string> {
119
227
  const userStore = getUserStore();
120
- if (!userStore.token && userStore.refreshToken) {
228
+ if (userStore.isLoggingOut) {
229
+ return userStore.token.trim();
230
+ }
231
+ if (userStore.authInvalidated) {
232
+ return "";
233
+ }
234
+ if (!userStore.token && hasRefreshCookieHint()) {
121
235
  await handleTokenRefresh(false);
122
236
  }
123
237
 
@@ -132,12 +246,15 @@ export async function getRequestAccessToken(): Promise<string> {
132
246
 
133
247
  /** 尝试通过刷新令牌恢复访问令牌,供路由守卫进入页面前调用。 */
134
248
  export async function ensureAccessToken() {
249
+ const userStore = getUserStore();
250
+ if (userStore.isLoggingOut || userStore.authInvalidated) {
251
+ return false;
252
+ }
135
253
  if (hasValidAccessToken()) {
136
254
  return true;
137
255
  }
138
256
 
139
- const userStore = getUserStore();
140
- if (!userStore.refreshToken) {
257
+ if (!hasRefreshCookieHint()) {
141
258
  return false;
142
259
  }
143
260
 
@@ -152,6 +269,7 @@ export async function ensureAccessToken() {
152
269
 
153
270
  // 防止并发 401 重复弹出认证失效确认框。
154
271
  let isHandlingAuthExpired = false;
272
+ let passwordChangePromptUntil = 0;
155
273
 
156
274
  /** 统一处理认证失效 */
157
275
  export function handleAuthExpired() {
@@ -171,6 +289,7 @@ export function handleAuthExpired() {
171
289
  const userStore = getUserStore();
172
290
  const currentRoute = router.currentRoute.value;
173
291
  const redirect = currentRoute.path === LOGIN_URL ? undefined : currentRoute.fullPath;
292
+ clearPasswordChangeRequired();
174
293
  userStore.clearAuthData();
175
294
  return router.replace({
176
295
  path: LOGIN_URL,
@@ -188,7 +307,8 @@ service.interceptors.request.use(
188
307
  const skipAuth = shouldSkipAuth(config);
189
308
  const accessToken = skipAuth ? "" : await getRequestAccessToken();
190
309
  Object.assign(config.headers, getLocaleRequestHeaders());
191
- // 登录、验证码、刷新令牌接口不携带旧 token,避免请求头污染。
310
+ config.headers[REFRESH_TOKEN_TRANSPORT_HEADER] = REFRESH_TOKEN_TRANSPORT_COOKIE;
311
+ // 登录、验证码、刷新令牌接口不携带访问令牌,避免请求头污染。
192
312
  if (!skipAuth && accessToken) {
193
313
  config.headers.Authorization = accessToken;
194
314
  } else {
@@ -207,24 +327,30 @@ service.interceptors.response.use(
207
327
  return response;
208
328
  }
209
329
 
210
- const { code, message, reason, metadata } = response.data;
330
+ const responseData = response.data as ErrorResponseData;
331
+ const { code, message, reason, metadata } = responseData;
211
332
  if (code === undefined || message === undefined || reason === undefined || metadata === undefined) {
212
333
  return response.data;
213
334
  }
214
335
 
215
- ElMessage.error(message || t("common.message.system_error"));
216
- return Promise.reject(new Error(message || "Error"));
336
+ if (isPasswordChangeRequired(responseData)) {
337
+ handlePasswordChangeRequired(message || t("common.message.system_error"));
338
+ } else {
339
+ showRequestError(message || t("common.message.system_error"));
340
+ }
341
+ return Promise.reject(new Error(message || t("common.message.system_error")));
217
342
  },
218
343
  async (error: AxiosError) => {
219
344
  const status = error.response?.status;
220
- const data = error.response?.data as { code?: string | number; message?: string } | undefined;
345
+ const data = error.response?.data as ErrorResponseData | undefined;
221
346
  const code = data?.code;
222
347
  const message = data?.message;
223
348
  const requestConfig = error.config as RetryableRequestConfig | undefined;
224
349
 
225
350
  // 业务请求仅在 401 时尝试刷新并重放,403 直接展示后端权限错误。
226
351
  if ((status === 401 || code === 401) && !shouldSkipAuthExpiredPrompt(requestConfig)) {
227
- if (requestConfig && !requestConfig._authRetried && getUserStore().refreshToken) {
352
+ const userStore = getUserStore();
353
+ if (requestConfig && !requestConfig._authRetried && !userStore.isLoggingOut && !userStore.authInvalidated && hasRefreshCookieHint()) {
228
354
  requestConfig._authRetried = true;
229
355
  try {
230
356
  await handleTokenRefresh(false);
@@ -237,20 +363,27 @@ service.interceptors.response.use(
237
363
  handleAuthExpired();
238
364
  } else if (data) {
239
365
  if (!shouldSkipErrorMessage(requestConfig)) {
240
- if (message) {
241
- ElMessage.error(message);
366
+ if (isPasswordChangeRequired(data)) {
367
+ handlePasswordChangeRequired(message || t("common.message.system_error"));
368
+ } else if (message) {
369
+ showRequestError(message);
242
370
  } else {
243
- ElMessage.error(t("common.message.system_error"));
371
+ showRequestError(t("common.message.system_error"));
244
372
  }
245
373
  }
246
374
  } else if (!shouldSkipErrorMessage(requestConfig)) {
247
- ElMessage.error(error.message || t("common.message.system_error"));
375
+ showRequestError(error.message || t("common.message.system_error"));
248
376
  }
249
377
  return Promise.reject(error.message);
250
378
  }
251
379
  );
252
380
 
253
- export default service;
381
+ /** 以请求体、响应体顺序提供带类型约束的 HTTP 请求函数。 */
382
+ function request<Request = unknown, Response = unknown>(config: AxiosRequestConfig<Request>): Promise<Response> {
383
+ return service<Response, Response, Request>(config) as Promise<Response>;
384
+ }
385
+
386
+ export default request;
254
387
 
255
388
  // 刷新 Token 的锁
256
389
  let isRefreshing = false;
@@ -282,20 +415,22 @@ async function handleTokenRefresh(promptOnFailure = true) {
282
415
  /** 调用刷新令牌接口并回写最新认证信息 */
283
416
  async function refreshAccessToken() {
284
417
  const userStore = getUserStore();
285
- if (!userStore.refreshToken) {
286
- return Promise.reject(new Error(t("core.auth.refresh_token_missing")));
287
- }
418
+ const authVersion = userStore.authVersion;
288
419
 
289
420
  const response = await refreshService.post(
290
421
  TOKEN_URL,
291
- { refresh_token: userStore.refreshToken },
292
- { headers: { Authorization: "no-auth", ...getLocaleRequestHeaders() } }
422
+ {},
423
+ {
424
+ headers: {
425
+ Authorization: "no-auth",
426
+ [REFRESH_TOKEN_TRANSPORT_HEADER]: REFRESH_TOKEN_TRANSPORT_COOKIE,
427
+ ...getLocaleRequestHeaders()
428
+ }
429
+ }
293
430
  );
294
431
  const data = response.data;
295
- userStore.updateTokenAuth(
296
- data.access_token,
297
- data.refresh_token ?? userStore.refreshToken,
298
- data.token_type ?? "",
299
- data.expires_in
300
- );
432
+ if (userStore.authVersion !== authVersion || userStore.isLoggingOut || userStore.authInvalidated) {
433
+ return;
434
+ }
435
+ userStore.updateTokenAuth(data.access_token, data.token_type ?? "", data.expires_in);
301
436
  }
@@ -1,4 +1,4 @@
1
- import { defBaseTenantService } from "../api/system/base_tenant";
1
+ import { defBaseTenantService } from "../api/system/admin/v1/base_tenant";
2
2
 
3
3
  /** 默认租户编码。 */
4
4
  export const DEFAULT_TENANT_CODE = "0000";
@@ -28,13 +28,32 @@ export function formatSrc(src: string) {
28
28
  }
29
29
 
30
30
  const configuredBase = String(import.meta.env.VITE_APP_STATIC_URL ?? "").trim();
31
- if (value.startsWith("/admin/")) {
32
- return new URL(value, `${window.location.origin}/`).toString();
33
- }
34
-
35
31
  const staticBase = configuredBase
36
32
  ? new URL(`${configuredBase.replace(/\/$/, "")}/`, `${window.location.origin}/`).toString()
37
- : `${window.location.origin}/admin/`;
38
- const normalizedPath = value.replace(/^\/+/, "").replace(/^admin\/+/, "");
33
+ : `${window.location.origin}/`;
34
+ const normalizedPath = normalizeStaticAssetPath(value).replace(/^\/+/, "");
39
35
  return new URL(normalizedPath, staticBase).toString();
40
36
  }
37
+
38
+ /** 将对象路径转换为统一的数据访问路径。 */
39
+ export function normalizeStaticAssetPath(src: string) {
40
+ const value = String(src ?? "");
41
+ if (value.startsWith("http://") || value.startsWith("https://")) {
42
+ return value;
43
+ }
44
+ if (value.startsWith("/data/") || value.startsWith("/admin/")) return value;
45
+ if (value.startsWith("data/")) return `/${value}`;
46
+ return value;
47
+ }
48
+
49
+ /** 将富文本中的媒体路径转换为统一的数据访问路径。 */
50
+ export function normalizeRichTextMediaPaths(html: string) {
51
+ if (typeof DOMParser === "undefined") return html;
52
+
53
+ const document = new DOMParser().parseFromString(html, "text/html");
54
+ document.body.querySelectorAll<HTMLElement>("img[src], video[src]").forEach(element => {
55
+ const src = element.getAttribute("src");
56
+ if (src) element.setAttribute("src", normalizeStaticAssetPath(src));
57
+ });
58
+ return document.body.innerHTML;
59
+ }
@@ -0,0 +1,90 @@
1
+ import { t } from "../locales";
2
+
3
+ /** WebAuthn 选项使用的 Base64URL 编解码。 */
4
+ function decodeBase64Url(value: string): ArrayBuffer {
5
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
6
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
7
+ const binary = atob(padded);
8
+ const bytes = new Uint8Array(binary.length);
9
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
10
+ return bytes.buffer;
11
+ }
12
+
13
+ /** 将 WebAuthn 二进制响应编码为服务端协议使用的 Base64URL。 */
14
+ function encodeBase64Url(value: ArrayBuffer | Uint8Array): string {
15
+ const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
16
+ let binary = "";
17
+ bytes.forEach(byte => {
18
+ binary += String.fromCharCode(byte);
19
+ });
20
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
21
+ }
22
+
23
+ /** 将服务端返回的 WebAuthn 创建选项转换为浏览器类型。 */
24
+ function parseCreationOptions(optionsJson: string): PublicKeyCredentialCreationOptions {
25
+ const options = JSON.parse(optionsJson) as { publicKey: PublicKeyCredentialCreationOptions };
26
+ const publicKey = options.publicKey;
27
+ publicKey.challenge = decodeBase64Url(publicKey.challenge as unknown as string);
28
+ publicKey.user.id = decodeBase64Url(publicKey.user.id as unknown as string);
29
+ publicKey.excludeCredentials = publicKey.excludeCredentials?.map(item => ({
30
+ ...item,
31
+ id: decodeBase64Url(item.id as unknown as string)
32
+ }));
33
+ return publicKey;
34
+ }
35
+
36
+ /** 将服务端返回的 WebAuthn 断言选项转换为浏览器类型。 */
37
+ function parseRequestOptions(optionsJson: string): PublicKeyCredentialRequestOptions {
38
+ const options = JSON.parse(optionsJson) as { publicKey: PublicKeyCredentialRequestOptions };
39
+ const publicKey = options.publicKey;
40
+ publicKey.challenge = decodeBase64Url(publicKey.challenge as unknown as string);
41
+ publicKey.allowCredentials = publicKey.allowCredentials?.map(item => ({
42
+ ...item,
43
+ id: decodeBase64Url(item.id as unknown as string)
44
+ }));
45
+ return publicKey;
46
+ }
47
+
48
+ /** 将 PublicKeyCredential 序列化为可提交给服务端的 JSON。 */
49
+ function serializeCredential(credential: PublicKeyCredential): string {
50
+ const response = credential.response;
51
+ const serializedResponse: Record<string, unknown> = {
52
+ clientDataJSON: encodeBase64Url(response.clientDataJSON)
53
+ };
54
+ const payload: Record<string, unknown> = {
55
+ id: credential.id,
56
+ rawId: encodeBase64Url(credential.rawId),
57
+ type: credential.type,
58
+ response: serializedResponse,
59
+ clientExtensionResults: credential.getClientExtensionResults()
60
+ };
61
+ if ("attestationObject" in response) {
62
+ const attestation = response as AuthenticatorAttestationResponse;
63
+ serializedResponse.attestationObject = encodeBase64Url(attestation.attestationObject);
64
+ } else {
65
+ const assertion = response as AuthenticatorAssertionResponse;
66
+ Object.assign(serializedResponse, {
67
+ authenticatorData: encodeBase64Url(assertion.authenticatorData),
68
+ signature: encodeBase64Url(assertion.signature),
69
+ userHandle: assertion.userHandle ? encodeBase64Url(assertion.userHandle) : null
70
+ });
71
+ }
72
+ if (credential.authenticatorAttachment) payload.authenticatorAttachment = credential.authenticatorAttachment;
73
+ return JSON.stringify(payload);
74
+ }
75
+
76
+ /** 执行 WebAuthn 注册 ceremony。 */
77
+ export async function createWebAuthnCredential(optionsJson: string): Promise<string> {
78
+ if (!navigator.credentials?.create) throw new Error(t("core.login.mfa_webauthn_setup_unsupported"));
79
+ const credential = await navigator.credentials.create({ publicKey: parseCreationOptions(optionsJson) });
80
+ if (!(credential instanceof PublicKeyCredential)) throw new Error(t("core.login.mfa_webauthn_setup_invalid"));
81
+ return serializeCredential(credential);
82
+ }
83
+
84
+ /** 执行 WebAuthn 登录 ceremony。 */
85
+ export async function getWebAuthnAssertion(optionsJson: string): Promise<string> {
86
+ if (!navigator.credentials?.get) throw new Error(t("core.login.mfa_webauthn_login_unsupported"));
87
+ const credential = await navigator.credentials.get({ publicKey: parseRequestOptions(optionsJson) });
88
+ if (!(credential instanceof PublicKeyCredential)) throw new Error(t("core.login.mfa_webauthn_login_invalid"));
89
+ return serializeCredential(credential);
90
+ }