@liujitcn/kratos-admin-system 0.0.28 → 0.0.31

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 (135) hide show
  1. package/README.md +19 -2
  2. package/dist/package/declarations/src/api/base/notification.d.ts +24 -0
  3. package/dist/package/declarations/src/api/system/base_audit_log.d.ts +55 -0
  4. package/dist/package/declarations/src/api/system/base_dashboard.d.ts +14 -0
  5. package/dist/package/declarations/src/api/system/base_file.d.ts +13 -0
  6. package/dist/package/declarations/src/api/system/base_i18n.d.ts +5 -5
  7. package/dist/package/declarations/src/api/system/base_login_policy.d.ts +10 -0
  8. package/dist/package/declarations/src/api/system/base_message.d.ts +24 -0
  9. package/dist/package/declarations/src/api/system/base_message_category.d.ts +21 -0
  10. package/dist/package/declarations/src/api/system/base_session.d.ts +11 -0
  11. package/dist/package/declarations/src/api/system/oauth_client.d.ts +24 -0
  12. package/dist/package/declarations/src/components/AuditLogTable.vue.d.ts +45 -0
  13. package/dist/package/declarations/src/components/DynamicI18nCell.vue.d.ts +16 -0
  14. package/dist/package/declarations/src/components/DynamicI18nEditor.vue.d.ts +20 -0
  15. package/dist/package/declarations/src/components/auditLog.d.ts +9 -0
  16. package/dist/package/declarations/src/components/dynamicI18n.d.ts +38 -0
  17. package/dist/package/declarations/src/locales/generated.d.ts +1364 -68
  18. package/dist/package/declarations/src/rpc/base/v1/login.d.ts +149 -0
  19. package/dist/package/declarations/src/rpc/base/v1/notification.d.ts +281 -0
  20. package/dist/package/declarations/src/rpc/base/v1/oauth.d.ts +25 -0
  21. package/dist/package/declarations/src/rpc/base/v1/oauth_client.d.ts +23 -0
  22. package/dist/package/declarations/src/rpc/system/admin/v1/base_audit_log.d.ts +640 -0
  23. package/dist/package/declarations/src/rpc/system/admin/v1/base_config.d.ts +3 -3
  24. package/dist/package/declarations/src/rpc/system/admin/v1/base_dashboard.d.ts +60 -0
  25. package/dist/package/declarations/src/rpc/system/admin/v1/base_dict.d.ts +4 -4
  26. package/dist/package/declarations/src/rpc/system/admin/v1/base_file.d.ts +73 -0
  27. package/dist/package/declarations/src/rpc/system/admin/v1/base_i18n.d.ts +27 -23
  28. package/dist/package/declarations/src/rpc/system/admin/v1/base_job.d.ts +5 -0
  29. package/dist/package/declarations/src/rpc/system/admin/v1/base_login_policy.d.ts +51 -0
  30. package/dist/package/declarations/src/rpc/system/admin/v1/base_menu.d.ts +2 -2
  31. package/dist/package/declarations/src/rpc/system/admin/v1/base_message.d.ts +193 -0
  32. package/dist/package/declarations/src/rpc/system/admin/v1/base_message_category.d.ts +125 -0
  33. package/dist/package/declarations/src/rpc/system/admin/v1/base_session.d.ts +33 -0
  34. package/dist/package/declarations/src/rpc/system/admin/v1/base_user.d.ts +3 -3
  35. package/dist/package/declarations/src/rpc/system/admin/v1/code_gen.d.ts +1 -1
  36. package/dist/package/declarations/src/rpc/system/admin/v1/oauth_client.d.ts +145 -0
  37. package/dist/package/declarations/src/views/base/api-log/index.vue.d.ts +3 -0
  38. package/dist/package/declarations/src/views/base/dashboard/index.vue.d.ts +3 -0
  39. package/dist/package/declarations/src/views/base/data-access-log/index.vue.d.ts +3 -0
  40. package/dist/package/declarations/src/views/base/file/index.vue.d.ts +3 -0
  41. package/dist/package/declarations/src/views/base/login-log/index.vue.d.ts +3 -0
  42. package/dist/package/declarations/src/views/base/login-policy/index.vue.d.ts +3 -0
  43. package/dist/package/declarations/src/views/base/message/index.vue.d.ts +3 -0
  44. package/dist/package/declarations/src/views/base/message-category/index.vue.d.ts +3 -0
  45. package/dist/package/declarations/src/views/base/oauth-client/index.vue.d.ts +3 -0
  46. package/dist/package/declarations/src/views/base/operation-log/index.vue.d.ts +3 -0
  47. package/dist/package/declarations/src/views/base/permission-log/index.vue.d.ts +3 -0
  48. package/dist/package/declarations/src/views/base/policy-evaluation-log/index.vue.d.ts +3 -0
  49. package/dist/package/declarations/src/views/message/inbox/index.vue.d.ts +3 -0
  50. package/dist/package/declarations/src/views/profile/components/session.vue.d.ts +3 -0
  51. package/dist/package/src/api/base/notification.ts +69 -0
  52. package/dist/package/src/api/base/sse.ts +4 -4
  53. package/dist/package/src/api/system/auth.ts +1 -0
  54. package/dist/package/src/api/system/base_audit_log.ts +168 -0
  55. package/dist/package/src/api/system/base_dashboard.ts +55 -0
  56. package/dist/package/src/api/system/base_file.ts +43 -0
  57. package/dist/package/src/api/system/base_i18n.ts +12 -12
  58. package/dist/package/src/api/system/base_login_policy.ts +32 -0
  59. package/dist/package/src/api/system/base_message.ts +70 -0
  60. package/dist/package/src/api/system/base_message_category.ts +57 -0
  61. package/dist/package/src/api/system/base_session.ts +33 -0
  62. package/dist/package/src/api/system/oauth_client.ts +102 -0
  63. package/dist/package/src/components/AuditLogTable.vue +105 -0
  64. package/dist/package/src/components/{DynamicTranslationCell.vue → DynamicI18nCell.vue} +50 -50
  65. package/dist/package/src/components/{DynamicTranslationEditor.vue → DynamicI18nEditor.vue} +45 -45
  66. package/dist/package/src/components/Notification.vue +196 -0
  67. package/dist/package/src/components/auditLog.ts +46 -0
  68. package/dist/package/src/components/{dynamicTranslation.ts → dynamicI18n.ts} +15 -15
  69. package/dist/package/src/locales/README.md +5 -3
  70. package/dist/package/src/locales/en-US.json +345 -21
  71. package/dist/package/src/locales/ja-JP.json +347 -23
  72. package/dist/package/src/locales/zh-CN.json +343 -19
  73. package/dist/package/src/locales/zh-TW.json +346 -22
  74. package/dist/package/src/module.ts +5 -2
  75. package/dist/package/src/rpc/base/v1/login.ts +172 -0
  76. package/dist/package/src/rpc/base/v1/notification.ts +315 -0
  77. package/dist/package/src/rpc/base/v1/oauth.ts +25 -0
  78. package/dist/package/src/rpc/base/v1/oauth_client.ts +33 -0
  79. package/dist/package/src/rpc/system/admin/v1/base_audit_log.ts +737 -0
  80. package/dist/package/src/rpc/system/admin/v1/base_config.ts +3 -3
  81. package/dist/package/src/rpc/system/admin/v1/base_dashboard.ts +81 -0
  82. package/dist/package/src/rpc/system/admin/v1/base_dict.ts +4 -4
  83. package/dist/package/src/rpc/system/admin/v1/base_file.ts +88 -0
  84. package/dist/package/src/rpc/system/admin/v1/base_i18n.ts +27 -23
  85. package/dist/package/src/rpc/system/admin/v1/base_job.ts +5 -0
  86. package/dist/package/src/rpc/system/admin/v1/base_login_policy.ts +63 -0
  87. package/dist/package/src/rpc/system/admin/v1/base_menu.ts +2 -2
  88. package/dist/package/src/rpc/system/admin/v1/base_message.ts +236 -0
  89. package/dist/package/src/rpc/system/admin/v1/base_message_category.ts +149 -0
  90. package/dist/package/src/rpc/system/admin/v1/base_session.ts +44 -0
  91. package/dist/package/src/rpc/system/admin/v1/base_user.ts +3 -3
  92. package/dist/package/src/rpc/system/admin/v1/code_gen.ts +1 -1
  93. package/dist/package/src/rpc/system/admin/v1/oauth_client.ts +174 -0
  94. package/dist/package/src/views/ai/chat/components/ChatPanel.vue +2 -2
  95. package/dist/package/src/views/ai/chat/message.ts +7 -3
  96. package/dist/package/src/views/base/api-log/index.vue +83 -0
  97. package/dist/package/src/views/base/config/index.vue +41 -41
  98. package/dist/package/src/views/base/dashboard/index.vue +200 -0
  99. package/dist/package/src/views/base/data-access-log/index.vue +84 -0
  100. package/dist/package/src/views/base/dict/index.vue +23 -23
  101. package/dist/package/src/views/base/dict/item.vue +25 -25
  102. package/dist/package/src/views/base/file/index.vue +119 -0
  103. package/dist/package/src/views/base/job/index.vue +62 -7
  104. package/dist/package/src/views/base/language/index.vue +2 -1
  105. package/dist/package/src/views/base/login-log/index.vue +92 -0
  106. package/dist/package/src/views/base/login-policy/index.vue +219 -0
  107. package/dist/package/src/views/base/menu/index.vue +23 -23
  108. package/dist/package/src/views/base/message/index.vue +582 -0
  109. package/dist/package/src/views/base/message-category/index.vue +356 -0
  110. package/dist/package/src/views/base/oauth-client/index.vue +411 -0
  111. package/dist/package/src/views/base/operation-log/index.vue +82 -0
  112. package/dist/package/src/views/base/permission-log/index.vue +85 -0
  113. package/dist/package/src/views/base/policy-evaluation-log/index.vue +82 -0
  114. package/dist/package/src/views/message/inbox/index.vue +249 -0
  115. package/dist/package/src/views/profile/components/base.vue +6 -5
  116. package/dist/package/src/views/profile/components/password.vue +2 -2
  117. package/dist/package/src/views/profile/components/security.vue +475 -62
  118. package/dist/package/src/views/profile/components/session.vue +153 -0
  119. package/dist/package/src/views/profile/index.vue +35 -22
  120. package/dist/package/src/views/tool/code-gen/code-preview/index.vue +9 -9
  121. package/dist/package/src/views/tool/code-gen/columns/index.vue +1 -1
  122. package/dist/package/src/views/tool/code-gen/components/CodeGenLocaleEditor.vue +8 -8
  123. package/dist/package/src/views/tool/code-gen/config.ts +1 -0
  124. package/dist/package/src/views/tool/code-gen/table/index.vue +7 -7
  125. package/dist/package/src/views/tool/ops-monitoring/index.vue +25 -21
  126. package/package.json +2 -2
  127. package/dist/package/declarations/src/api/system/base_log.d.ts +0 -9
  128. package/dist/package/declarations/src/components/DynamicTranslationCell.vue.d.ts +0 -16
  129. package/dist/package/declarations/src/components/DynamicTranslationEditor.vue.d.ts +0 -20
  130. package/dist/package/declarations/src/components/dynamicTranslation.d.ts +0 -38
  131. package/dist/package/declarations/src/rpc/system/admin/v1/base_log.d.ts +0 -87
  132. package/dist/package/src/api/system/base_log.ts +0 -32
  133. package/dist/package/src/rpc/system/admin/v1/base_log.ts +0 -101
  134. package/dist/package/src/views/base/log/index.vue +0 -339
  135. /package/dist/package/declarations/src/{views/base/log/index.vue.d.ts → components/Notification.vue.d.ts} +0 -0
@@ -27,6 +27,34 @@
27
27
  {{ profile.phone ? t("system.profile.security.action.change_phone") : t("system.profile.security.action.bind_now") }}
28
28
  </el-button>
29
29
  </div>
30
+ <div class="security-item">
31
+ <div class="security-item__content">
32
+ <strong>{{ t("system.profile.security.field.mfa") }}</strong>
33
+ <p>
34
+ {{
35
+ mfaEnabled
36
+ ? t("system.profile.security.message.mfa_enabled_method", { method: mfaMethodLabel })
37
+ : t("system.profile.security.message.mfa_disabled")
38
+ }}
39
+ </p>
40
+ <p v-if="mfaEnabled && mfaMethod === 'webauthn'" class="security-item__hint">
41
+ {{ t("system.profile.security.message.mfa_webauthn_manage_hint") }}
42
+ </p>
43
+ </div>
44
+ <div class="security-item__actions">
45
+ <el-button v-if="!mfaEnabled" plain @click="openMfaSetup">{{
46
+ t("system.profile.security.action.mfa_enable")
47
+ }}</el-button>
48
+ <template v-else>
49
+ <el-button v-if="mfaMethod === 'totp'" plain @click="openMfaRecovery">
50
+ {{ t("system.profile.security.action.mfa_recovery") }}
51
+ </el-button>
52
+ <el-button plain type="danger" @click="openMfaDisable">{{
53
+ t("system.profile.security.action.mfa_disable")
54
+ }}</el-button>
55
+ </template>
56
+ </div>
57
+ </div>
30
58
  <div v-for="item in oauthBindings" :key="item.provider" class="security-item">
31
59
  <div class="security-item__content security-item__content--oauth">
32
60
  <el-tooltip :content="oauthName(item)" placement="top" :trigger="['hover', 'focus']">
@@ -38,7 +66,9 @@
38
66
  <strong>{{ oauthName(item) }}</strong>
39
67
  <p>
40
68
  {{
41
- item.bound ? t("system.profile.security.message.oauth_bound") : t("system.profile.security.message.oauth_unbound")
69
+ item.bound
70
+ ? t("system.profile.security.message.oauth_bound")
71
+ : t("system.profile.security.message.oauth_unbound")
42
72
  }}
43
73
  </p>
44
74
  </div>
@@ -59,27 +89,6 @@
59
89
  </div>
60
90
  </el-card>
61
91
 
62
- <el-card class="status-card" shadow="never">
63
- <template #header>
64
- <div class="status-header">
65
- <div>
66
- <h3>{{ t("system.profile.security.status.title") }}</h3>
67
- <p>{{ t("system.profile.security.status.description") }}</p>
68
- </div>
69
- </div>
70
- </template>
71
- <div class="status-grid">
72
- <div class="status-item">
73
- <span>{{ t("system.profile.security.field.phone_verification") }}</span>
74
- <strong>{{ profile.phone ? t("common.status.enabled") : t("common.status.disabled") }}</strong>
75
- </div>
76
- <div class="status-item">
77
- <span>{{ t("system.profile.security.field.profile_completion") }}</span>
78
- <strong>{{ profileCompletion }}</strong>
79
- </div>
80
- </div>
81
- </el-card>
82
-
83
92
  <ProDialog
84
93
  v-model="phoneDialogVisible"
85
94
  :title="t('system.profile.security.dialog.bind_phone')"
@@ -110,6 +119,58 @@
110
119
  </div>
111
120
  </template>
112
121
  </ProDialog>
122
+ <ProDialog
123
+ v-model="mfaDialogVisible"
124
+ :title="mfaDialogTitle"
125
+ :width="'min(560px, calc(100vw - 32px))'"
126
+ @closed="resetMfaDialog"
127
+ >
128
+ <div class="mfa-dialog-content">
129
+ <MfaSetupPanel v-if="mfaSetupUri" :uri="mfaSetupUri" />
130
+ <ProForm
131
+ ref="mfaFormRef"
132
+ class="mfa-dialog-form"
133
+ :model="mfaForm"
134
+ :fields="mfaDialogFields"
135
+ :rules="mfaDialogRules"
136
+ label-width="auto"
137
+ autocomplete="off"
138
+ size="default"
139
+ />
140
+ <div v-if="recoveryCodes.length" class="mfa-dialog-recovery">
141
+ <div class="mfa-copyable-value">
142
+ <span class="mfa-copyable-value__text">{{ recoveryCodesText }}</span>
143
+ <el-tooltip :content="t('system.profile.security.action.mfa_copy_recovery_codes')" placement="top">
144
+ <el-button
145
+ class="mfa-copy-button"
146
+ text
147
+ circle
148
+ :aria-label="t('system.profile.security.action.mfa_copy_recovery_codes')"
149
+ @click="copyRecoveryCodes"
150
+ >
151
+ <el-icon class="mfa-copy-button__icon"><CopyDocument /></el-icon>
152
+ </el-button>
153
+ </el-tooltip>
154
+ </div>
155
+ </div>
156
+ <p v-if="recoveryCodes.length" class="mfa-recovery-hint">
157
+ {{ t("system.profile.security.message.mfa_recovery_codes_warning") }}
158
+ </p>
159
+ </div>
160
+ <template #footer>
161
+ <div class="dialog-footer">
162
+ <el-button @click="mfaDialogVisible = false">{{ t("common.action.close") }}</el-button>
163
+ <el-button :type="mfaDialogActionType" :loading="mfaLoading" @click="handleMfaAction">
164
+ {{ mfaDialogActionText }}
165
+ </el-button>
166
+ </div>
167
+ </template>
168
+ </ProDialog>
169
+ <MfaRecoveryCodesDialog
170
+ v-model="recoveryCodesDialogVisible"
171
+ :codes="recoveryCodes"
172
+ @confirm="handleRecoveryCodesClosed"
173
+ />
113
174
  </div>
114
175
  </template>
115
176
 
@@ -118,6 +179,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
118
179
  import { useRoute, useRouter } from "vue-router";
119
180
  import { t } from "@liujitcn/kratos-admin-core";
120
181
  import { defProfileAuthService } from "@liujitcn/kratos-admin-system/api/system/auth";
182
+ import { defMfaService } from "@liujitcn/kratos-admin-core/api/base/mfa";
121
183
  import { defProfileOauthService } from "@liujitcn/kratos-admin-system/api/base/oauth";
122
184
  import type { OauthBinding } from "@liujitcn/kratos-admin-system/rpc/base/v1/oauth";
123
185
  import type {
@@ -127,9 +189,22 @@ import type {
127
189
  } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/auth";
128
190
  import ProDialog from "@liujitcn/kratos-admin-core/components/Dialog/ProDialog.vue";
129
191
  import ProForm from "@liujitcn/kratos-admin-core/components/ProForm/index.vue";
192
+ import MfaSetupPanel from "@liujitcn/kratos-admin-core/components/Mfa/MfaSetupPanel.vue";
193
+ import MfaRecoveryCodesDialog from "@liujitcn/kratos-admin-core/components/Mfa/MfaRecoveryCodesDialog.vue";
130
194
  import type { ProFormField, ProFormInstance } from "@liujitcn/kratos-admin-core/components/ProForm/interface";
131
- import { getOauthProviderIcon, withOauthProviderDisplay, type OauthProviderDisplay } from "@liujitcn/kratos-admin-core/security";
195
+ import type { PasswordCrypto } from "@liujitcn/kratos-admin-core/rpc/common/v1/types";
196
+ import {
197
+ getOauthProviderIcon,
198
+ withOauthProviderDisplay,
199
+ type OauthProviderDisplay,
200
+ PASSWORD_CRYPTO_SCENE,
201
+ encryptPassword,
202
+ copyText,
203
+ createWebAuthnCredential,
204
+ getWebAuthnAssertion
205
+ } from "@liujitcn/kratos-admin-core/security";
132
206
  import { resolveFrontendRouteURL } from "@liujitcn/kratos-admin-core/navigation";
207
+ import { useUserStore } from "@liujitcn/kratos-admin-core/stores/runtime";
133
208
 
134
209
  /** 安全中心组件属性。 */
135
210
  interface ProfileSecurityProps {
@@ -146,7 +221,9 @@ const emit = defineEmits<{
146
221
 
147
222
  const route = useRoute();
148
223
  const router = useRouter();
224
+ const userStore = useUserStore();
149
225
  const phoneFormRef = ref<ProFormInstance>();
226
+ const mfaFormRef = ref<ProFormInstance>();
150
227
  const phoneDialogVisible = ref(false);
151
228
  const submitLoading = ref(false);
152
229
  /** 安全中心三方账号绑定展示项。 */
@@ -163,6 +240,139 @@ const phoneForm = reactive<UserPhoneForm>({
163
240
  const sendPhoneCodeForm = reactive<SendPhoneCodeRequest>({
164
241
  phone: ""
165
242
  });
243
+ const mfaEnabled = ref(false);
244
+ const mfaPolicy = ref("");
245
+ const mfaMethod = ref("totp");
246
+ const mfaDialogVisible = ref(false);
247
+ const recoveryCodesDialogVisible = ref(false);
248
+ const mfaDialogMode = ref<"setup" | "disable" | "recovery">("setup");
249
+ const mfaLoading = ref(false);
250
+ /** MFA 操作弹窗表单。 */
251
+ interface MfaDialogForm {
252
+ /** 当前密码,仅用于生成一次性加密请求。 */
253
+ password: string;
254
+ /** 动态口令。 */
255
+ code: string;
256
+ /** 一次性恢复码。 */
257
+ recoveryCode: string;
258
+ }
259
+
260
+ const mfaForm = reactive<MfaDialogForm>({
261
+ password: "",
262
+ code: "",
263
+ recoveryCode: ""
264
+ });
265
+ const mfaSetupTicket = ref("");
266
+ const mfaSetupUri = ref("");
267
+ const mfaSetupMethod = ref("totp");
268
+ const mfaSetupWebAuthnOptionsJson = ref("");
269
+ const recoveryCodes = ref<string[]>([]);
270
+ const recoveryCodesText = computed(() => recoveryCodes.value.join("\n"));
271
+ /** 禁用 MFA 时是否需要同时校验已绑定的 MFA 因子。 */
272
+ const mfaFactorEnabled = computed(() => mfaDialogMode.value === "disable" && mfaEnabled.value);
273
+ const mfaDialogTitle = computed(() => {
274
+ if (mfaDialogMode.value === "disable") return t("system.profile.security.dialog.mfa_disable");
275
+ if (mfaDialogMode.value === "recovery") return t("system.profile.security.dialog.mfa_recovery");
276
+ if (!mfaSetupTicket.value) return t("core.password.verify_title");
277
+ return t("system.profile.security.dialog.mfa_setup");
278
+ });
279
+ const mfaDialogActionText = computed(() => {
280
+ if (mfaDialogMode.value === "disable") return t("system.profile.security.action.mfa_disable");
281
+ if (mfaDialogMode.value === "recovery") return t("common.action.confirm");
282
+ if (mfaSetupUri.value || (mfaSetupMethod.value === "webauthn" && mfaSetupTicket.value)) {
283
+ return mfaSetupMethod.value === "webauthn"
284
+ ? t("core.login.mfa_webauthn_action")
285
+ : t("system.profile.security.action.mfa_confirm");
286
+ }
287
+ return t("system.profile.security.action.mfa_next");
288
+ });
289
+ const mfaDialogActionType = computed(() => (mfaDialogMode.value === "disable" ? "danger" : "primary"));
290
+ const mfaMethodLabel = computed(() =>
291
+ mfaMethod.value === "webauthn"
292
+ ? t("system.profile.security.message.mfa_method_webauthn")
293
+ : t("system.profile.security.message.mfa_method_totp")
294
+ );
295
+
296
+ const mfaDialogFields = computed<ProFormField[]>(() => {
297
+ const passwordField: ProFormField = {
298
+ prop: "password",
299
+ label: t("system.profile.security.field.login_password"),
300
+ component: "password",
301
+ props: {
302
+ autocomplete: "current-password",
303
+ name: "mfa-current-password",
304
+ placeholder: t("system.profile.security.placeholder.current_password")
305
+ }
306
+ };
307
+ const codeField: ProFormField = {
308
+ prop: "code",
309
+ label: t("system.profile.security.field.code"),
310
+ component: "input",
311
+ itemProps: { required: mfaDialogMode.value !== "disable" },
312
+ props: {
313
+ autocomplete: "one-time-code",
314
+ inputmode: "numeric",
315
+ maxlength: 8,
316
+ name: "mfa-verification-code",
317
+ placeholder: t("system.profile.security.placeholder.mfa_code")
318
+ }
319
+ };
320
+ const recoveryCodeField: ProFormField = {
321
+ prop: "recoveryCode",
322
+ label: t("system.profile.security.field.mfa_recovery_code"),
323
+ component: "input",
324
+ props: {
325
+ autocomplete: "off",
326
+ name: "mfa-recovery-code",
327
+ placeholder: t("system.profile.security.placeholder.mfa_recovery_code")
328
+ }
329
+ };
330
+ if (mfaDialogMode.value === "setup" && !mfaSetupUri.value && !mfaSetupTicket.value) return [passwordField];
331
+ if (mfaDialogMode.value === "setup" && mfaSetupMethod.value === "webauthn") return [];
332
+ if (mfaDialogMode.value === "disable" && !mfaFactorEnabled.value) return [passwordField];
333
+ if (mfaDialogMode.value === "disable" && mfaMethod.value === "webauthn") return [passwordField, recoveryCodeField];
334
+ if (mfaDialogMode.value === "disable") return [passwordField, codeField, recoveryCodeField];
335
+ return [codeField];
336
+ });
337
+
338
+ const mfaDialogRules = computed(() => {
339
+ if (mfaDialogMode.value === "disable") {
340
+ if (!mfaFactorEnabled.value) {
341
+ return {
342
+ password: [{ required: true, message: t("system.profile.security.placeholder.current_password"), trigger: "blur" }]
343
+ };
344
+ }
345
+ if (mfaMethod.value === "webauthn") {
346
+ return {
347
+ password: [{ required: true, message: t("system.profile.security.placeholder.current_password"), trigger: "blur" }]
348
+ };
349
+ }
350
+ return {
351
+ password: [{ required: true, message: t("system.profile.security.placeholder.current_password"), trigger: "blur" }],
352
+ code: [
353
+ {
354
+ validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
355
+ if (value || mfaForm.recoveryCode) {
356
+ callback();
357
+ return;
358
+ }
359
+ callback(new Error(t("system.profile.security.message.mfa_disable_factor_required")));
360
+ },
361
+ trigger: "blur"
362
+ }
363
+ ]
364
+ };
365
+ }
366
+ if (mfaDialogMode.value === "setup" && !mfaSetupUri.value && !mfaSetupTicket.value) {
367
+ return {
368
+ password: [{ required: true, message: t("system.profile.security.placeholder.current_password"), trigger: "blur" }]
369
+ };
370
+ }
371
+ if (mfaDialogMode.value === "setup" && mfaSetupMethod.value === "webauthn") return {};
372
+ return {
373
+ code: [{ required: true, message: t("system.profile.security.placeholder.mfa_code"), trigger: "blur" }]
374
+ };
375
+ });
166
376
 
167
377
  const phoneFormFields = computed<ProFormField[]>(() => [
168
378
  {
@@ -194,13 +404,6 @@ const mobileTip = computed(() => {
194
404
  : t("system.profile.security.message.phone_unbound");
195
405
  });
196
406
 
197
- /** 根据关键资料估算当前资料完成度。 */
198
- const profileCompletion = computed(() => {
199
- const fieldList = [props.profile.nick_name, props.profile.phone, props.profile.role_name, props.profile.dept_name];
200
- const completedCount = fieldList.filter(item => Boolean(item)).length;
201
- return `${Math.round((completedCount / fieldList.length) * 100)}%`;
202
- });
203
-
204
407
  /** 获取当前安全设置页绝对地址,供 OAuth 绑定完成后回跳到前端页面。 */
205
408
  function getCurrentSecurityPath() {
206
409
  const query = { ...route.query };
@@ -216,6 +419,185 @@ async function loadOauthBindings() {
216
419
  oauthBindings.value = result.bindings.map(withOauthProviderDisplay);
217
420
  }
218
421
 
422
+ /** 拉取当前用户 MFA 状态。 */
423
+ async function loadMfaStatus() {
424
+ const result = await defMfaService.GetMfaStatus({});
425
+ mfaEnabled.value = result.enabled;
426
+ mfaPolicy.value = result.policy;
427
+ mfaMethod.value = result.method || "totp";
428
+ }
429
+
430
+ /** 打开 MFA 绑定弹窗。 */
431
+ function openMfaSetup() {
432
+ resetMfaDialog();
433
+ mfaDialogMode.value = "setup";
434
+ mfaDialogVisible.value = true;
435
+ }
436
+
437
+ /** 打开 MFA 禁用弹窗。 */
438
+ function openMfaDisable() {
439
+ resetMfaDialog();
440
+ mfaDialogMode.value = "disable";
441
+ mfaDialogVisible.value = true;
442
+ }
443
+
444
+ /** 打开恢复码重新生成弹窗。 */
445
+ function openMfaRecovery() {
446
+ resetMfaDialog();
447
+ mfaDialogMode.value = "recovery";
448
+ mfaDialogVisible.value = true;
449
+ }
450
+
451
+ /** 开始绑定 MFA。 */
452
+ async function beginMfaSetup(password: PasswordCrypto) {
453
+ mfaLoading.value = true;
454
+ try {
455
+ const result = await defMfaService.BeginMfaSetup({ password, setup_ticket: "" });
456
+ mfaSetupTicket.value = result.setup_ticket;
457
+ mfaSetupUri.value = result.otpauth_uri;
458
+ mfaSetupMethod.value = result.method || mfaMethod.value || "totp";
459
+ mfaSetupWebAuthnOptionsJson.value = result.webauthn_options_json || "";
460
+ } finally {
461
+ mfaLoading.value = false;
462
+ }
463
+ }
464
+
465
+ /** 确认绑定 MFA。 */
466
+ async function confirmMfaSetup() {
467
+ if (!mfaSetupTicket.value || (mfaSetupMethod.value !== "webauthn" && !mfaForm.code)) return;
468
+ mfaLoading.value = true;
469
+ try {
470
+ const webauthnResponseJson =
471
+ mfaSetupMethod.value === "webauthn" ? await createWebAuthnCredential(mfaSetupWebAuthnOptionsJson.value) : "";
472
+ const result = await defMfaService.ConfirmMfaSetup({
473
+ setup_ticket: mfaSetupTicket.value,
474
+ code: mfaForm.code,
475
+ webauthn_response_json: webauthnResponseJson
476
+ });
477
+ recoveryCodes.value = result.recovery_codes;
478
+ mfaEnabled.value = true;
479
+ mfaDialogVisible.value = false;
480
+ recoveryCodesDialogVisible.value = true;
481
+ } catch (error) {
482
+ ElMessage.error(error instanceof Error ? error.message : t("common.message.request_error"));
483
+ } finally {
484
+ mfaLoading.value = false;
485
+ }
486
+ }
487
+
488
+ /** 禁用 MFA。 */
489
+ async function disableMfa() {
490
+ if (!mfaForm.password) return;
491
+ if (mfaFactorEnabled.value && mfaMethod.value === "totp" && !mfaForm.code && !mfaForm.recoveryCode) {
492
+ ElMessage.error(t("system.profile.security.message.mfa_disable_factor_required"));
493
+ return;
494
+ }
495
+ mfaLoading.value = true;
496
+ try {
497
+ const password = await encryptPassword(mfaForm.password, PASSWORD_CRYPTO_SCENE.PASSWORD_CRYPTO_SCENE_MFA);
498
+ if (mfaForm.recoveryCode) {
499
+ await defMfaService.DisableMfa({
500
+ password,
501
+ code: "",
502
+ webauthn_challenge_id: "",
503
+ webauthn_response_json: "",
504
+ recovery_code: mfaForm.recoveryCode
505
+ });
506
+ } else if (mfaMethod.value === "webauthn") {
507
+ const challenge = await defMfaService.BeginMfaDisable({});
508
+ const webauthnResponseJson = await getWebAuthnAssertion(challenge.webauthn_options_json);
509
+ await defMfaService.DisableMfa({
510
+ password,
511
+ code: "",
512
+ webauthn_challenge_id: challenge.challenge_id,
513
+ webauthn_response_json: webauthnResponseJson,
514
+ recovery_code: ""
515
+ });
516
+ } else {
517
+ await defMfaService.DisableMfa({
518
+ password,
519
+ code: mfaForm.code,
520
+ webauthn_challenge_id: "",
521
+ webauthn_response_json: "",
522
+ recovery_code: ""
523
+ });
524
+ }
525
+ mfaEnabled.value = false;
526
+ ElMessage.success(t("system.profile.security.message.mfa_disabled"));
527
+ await userStore.clearAuthData();
528
+ await router.replace({ path: "/login" });
529
+ } finally {
530
+ mfaLoading.value = false;
531
+ }
532
+ }
533
+
534
+ /** 重新生成恢复码。 */
535
+ async function regenerateRecoveryCodes() {
536
+ if (!mfaForm.code) return;
537
+ mfaLoading.value = true;
538
+ try {
539
+ const result = await defMfaService.RegenerateMfaRecoveryCodes({ code: mfaForm.code });
540
+ recoveryCodes.value = result.recovery_codes;
541
+ } finally {
542
+ mfaLoading.value = false;
543
+ }
544
+ }
545
+
546
+ /** 复制当前显示的 MFA 恢复码。 */
547
+ async function copyRecoveryCodes() {
548
+ await copyText(recoveryCodesText.value);
549
+ ElMessage.success(t("system.profile.security.message.mfa_recovery_codes_copied"));
550
+ }
551
+
552
+ /** 关闭绑定成功后的恢复码弹窗并重新登录。 */
553
+ async function handleRecoveryCodesClosed() {
554
+ if (!recoveryCodes.value.length) return;
555
+ ElMessage.success(t("system.profile.security.message.mfa_enabled"));
556
+ recoveryCodes.value = [];
557
+ await userStore.clearAuthData();
558
+ await router.replace({ path: "/login" });
559
+ }
560
+
561
+ /** 校验当前 MFA 表单并执行对应步骤。 */
562
+ async function handleMfaAction() {
563
+ if (!(await mfaFormRef.value?.validate())) return;
564
+ if (mfaDialogMode.value === "disable") {
565
+ await disableMfa();
566
+ return;
567
+ }
568
+ if (mfaDialogMode.value === "recovery") {
569
+ await regenerateRecoveryCodes();
570
+ return;
571
+ }
572
+ if (mfaSetupUri.value || (mfaSetupMethod.value === "webauthn" && mfaSetupTicket.value)) {
573
+ await confirmMfaSetup();
574
+ return;
575
+ }
576
+ try {
577
+ const password = await encryptPassword(mfaForm.password, PASSWORD_CRYPTO_SCENE.PASSWORD_CRYPTO_SCENE_MFA);
578
+ await beginMfaSetup(password);
579
+ mfaForm.password = "";
580
+ } catch (error) {
581
+ ElMessage.error(error instanceof Error ? error.message : t("common.message.request_error"));
582
+ }
583
+ }
584
+
585
+ /** 重置 MFA 弹窗状态。 */
586
+ function resetMfaDialog() {
587
+ mfaForm.password = "";
588
+ mfaForm.code = "";
589
+ mfaForm.recoveryCode = "";
590
+ mfaFormRef.value?.clearValidate();
591
+ mfaSetupTicket.value = "";
592
+ mfaSetupUri.value = "";
593
+ mfaSetupMethod.value = "totp";
594
+ mfaSetupWebAuthnOptionsJson.value = "";
595
+ // 绑定弹窗关闭与恢复码弹窗打开同时进行时,保留本次绑定返回的恢复码。
596
+ if (!recoveryCodesDialogVisible.value) {
597
+ recoveryCodes.value = [];
598
+ }
599
+ }
600
+
219
601
  /** 根据当前语言返回三方登录方式名称。 */
220
602
  function oauthName(binding: SecurityOauthBinding) {
221
603
  return binding.nameKey.includes(".") ? t(binding.nameKey) : binding.nameKey;
@@ -357,6 +739,7 @@ onBeforeUnmount(() => {
357
739
  onMounted(async () => {
358
740
  await consumeOauthBindingResult();
359
741
  await loadOauthBindings();
742
+ await loadMfaStatus();
360
743
  });
361
744
  </script>
362
745
 
@@ -366,34 +749,25 @@ onMounted(async () => {
366
749
  flex-direction: column;
367
750
  gap: 20px;
368
751
  }
369
- .security-card,
370
- .status-card {
752
+ .security-card {
371
753
  border: 1px solid #ebeef5;
372
- border-radius: 12px;
754
+ border-radius: var(--admin-page-radius);
373
755
  }
374
- :deep(.security-card .el-card__body),
375
- :deep(.status-card .el-card__body) {
756
+ :deep(.security-card .el-card__body) {
376
757
  padding: 20px;
377
758
  }
378
- :deep(.status-card .el-card__header) {
379
- padding: 18px 20px;
380
- border-bottom: 1px solid #f0f2f5;
381
- }
382
- .security-intro,
383
- .status-header {
759
+ .security-intro {
384
760
  display: flex;
385
761
  gap: 16px;
386
762
  align-items: flex-start;
387
763
  justify-content: space-between;
388
764
  }
389
- .security-intro h3,
390
- .status-header h3 {
765
+ .security-intro h3 {
391
766
  margin: 0;
392
767
  font-size: 18px;
393
768
  color: #303133;
394
769
  }
395
- .security-intro p,
396
- .status-header p {
770
+ .security-intro p {
397
771
  margin: 6px 0 0;
398
772
  font-size: 13px;
399
773
  color: #909399;
@@ -404,8 +778,7 @@ onMounted(async () => {
404
778
  gap: 12px;
405
779
  margin-top: 16px;
406
780
  }
407
- .security-item,
408
- .status-item {
781
+ .security-item {
409
782
  display: flex;
410
783
  gap: 18px;
411
784
  align-items: center;
@@ -413,7 +786,7 @@ onMounted(async () => {
413
786
  padding: 16px;
414
787
  background: #ffffff;
415
788
  border: 1px solid #f0f2f5;
416
- border-radius: 10px;
789
+ border-radius: var(--admin-page-radius);
417
790
  }
418
791
  .security-item__content {
419
792
  min-width: 0;
@@ -440,40 +813,80 @@ onMounted(async () => {
440
813
  object-fit: contain;
441
814
  }
442
815
  }
443
- .security-item strong,
444
- .status-item strong {
816
+ .security-item strong {
445
817
  display: block;
446
818
  margin-bottom: 6px;
447
819
  font-size: 15px;
448
820
  color: #303133;
449
821
  }
450
- .security-item p,
451
- .status-item span {
822
+ .security-item p {
452
823
  margin: 0;
453
824
  font-size: 13px;
454
825
  line-height: 1.6;
455
826
  color: #909399;
456
827
  }
457
- .status-grid {
458
- display: grid;
459
- grid-template-columns: repeat(2, minmax(0, 1fr));
460
- gap: 12px;
828
+ .security-item__hint {
829
+ margin-top: 4px !important;
830
+ color: var(--el-color-warning) !important;
461
831
  }
462
832
  .dialog-footer {
463
833
  display: flex;
464
834
  justify-content: flex-end;
465
835
  }
836
+ .security-item__actions {
837
+ display: flex;
838
+ flex-wrap: wrap;
839
+ gap: 8px;
840
+ justify-content: flex-end;
841
+ }
842
+ .mfa-dialog-content {
843
+ min-width: 0;
844
+ }
845
+ .mfa-dialog-recovery {
846
+ margin-top: 12px;
847
+ }
848
+ .mfa-copyable-value {
849
+ display: flex;
850
+ gap: 8px;
851
+ align-items: flex-start;
852
+ line-height: 1.6;
853
+ color: var(--el-text-color-regular);
854
+ overflow-wrap: anywhere;
855
+ }
856
+ .mfa-copyable-value__text {
857
+ flex: 1;
858
+ min-width: 0;
859
+ white-space: pre-wrap;
860
+ }
861
+ .mfa-copy-button {
862
+ flex: 0 0 auto;
863
+ margin-top: -4px;
864
+ }
865
+ .mfa-copy-button__icon {
866
+ color: var(--el-color-primary);
867
+ font-size: 18px;
868
+ }
869
+ .mfa-recovery-hint {
870
+ line-height: 1.6;
871
+ color: var(--el-text-color-regular);
872
+ }
873
+ .mfa-recovery-hint {
874
+ margin: 8px 0 0;
875
+ font-size: 13px;
876
+ color: var(--el-color-warning);
877
+ }
878
+ .mfa-dialog-content :deep(.pro-form) {
879
+ margin: 0;
880
+ }
881
+ .mfa-dialog-content :deep(.mfa-dialog-form .el-form-item) {
882
+ margin-bottom: 16px;
883
+ }
466
884
 
467
885
  @media screen and (width <= 768px) {
468
886
  .security-intro,
469
- .status-header,
470
- .security-item,
471
- .status-item {
887
+ .security-item {
472
888
  flex-direction: column;
473
889
  align-items: flex-start;
474
890
  }
475
- .status-grid {
476
- grid-template-columns: 1fr;
477
- }
478
891
  }
479
892
  </style>