@liujitcn/kratos-uni-app-core 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.
@@ -0,0 +1,469 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import MfaRecoveryCodesDialog from './MfaRecoveryCodesDialog.vue'
4
+ import MfaSetupPanel from './MfaSetupPanel.vue'
5
+ import type {
6
+ BeginMfaSetupRequest,
7
+ ConfirmMfaSetupRequest,
8
+ DisableMfaRequest,
9
+ } from '../rpc/base/v1/mfa'
10
+ import { createWebAuthnCredential, getWebAuthnAssertion } from '../utils/webauthn'
11
+ import { PASSWORD_CRYPTO_SCENE, encryptPassword } from '../utils/passwordCrypto'
12
+ import { useI18n } from '../locales'
13
+ import { defMfaService } from '../api/base/mfa'
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ /** 弹窗显示状态。 */
18
+ modelValue: boolean
19
+ /** MFA 操作模式,默认执行绑定。 */
20
+ mode?: 'setup' | 'disable'
21
+ /** 当前已启用的 MFA 方式,禁用模式使用。 */
22
+ method?: string
23
+ }>(),
24
+ { modelValue: false, mode: 'setup', method: 'totp' },
25
+ )
26
+
27
+ defineOptions({ name: 'MfaManageDialog' })
28
+
29
+ const emit = defineEmits<{
30
+ 'update:modelValue': [value: boolean]
31
+ success: [method: string]
32
+ disabled: []
33
+ }>()
34
+
35
+ const { t } = useI18n()
36
+ const setupDialogVisible = ref(false)
37
+ const recoveryDialogVisible = ref(false)
38
+ const disableDialogVisible = ref(false)
39
+ const loading = ref(false)
40
+ const setupTicket = ref('')
41
+ const setupMethod = ref('totp')
42
+ const setupUri = ref('')
43
+ const setupWebAuthnOptionsJson = ref('')
44
+ const setupPasswordInput = ref('')
45
+ const setupCode = ref('')
46
+ const recoveryCodes = ref<string[]>([])
47
+ const disablePasswordInput = ref('')
48
+ const disableCode = ref('')
49
+ const disableRecoveryCode = ref('')
50
+ const errorMessage = ref('')
51
+
52
+ /** 根据操作模式打开 MFA 流程。 */
53
+ function openDialog() {
54
+ errorMessage.value = ''
55
+ if (props.mode === 'disable') {
56
+ setupDialogVisible.value = false
57
+ recoveryDialogVisible.value = false
58
+ disableDialogVisible.value = true
59
+ return
60
+ }
61
+ setupDialogVisible.value = true
62
+ recoveryDialogVisible.value = false
63
+ disableDialogVisible.value = false
64
+ }
65
+
66
+ /** 关闭所有 MFA 弹窗并清理临时状态。 */
67
+ function closeDialog() {
68
+ if (loading.value) return
69
+ setupDialogVisible.value = false
70
+ recoveryDialogVisible.value = false
71
+ disableDialogVisible.value = false
72
+ setupTicket.value = ''
73
+ setupMethod.value = 'totp'
74
+ setupUri.value = ''
75
+ setupWebAuthnOptionsJson.value = ''
76
+ setupPasswordInput.value = ''
77
+ setupCode.value = ''
78
+ recoveryCodes.value = []
79
+ disablePasswordInput.value = ''
80
+ disableCode.value = ''
81
+ disableRecoveryCode.value = ''
82
+ errorMessage.value = ''
83
+ emit('update:modelValue', false)
84
+ }
85
+
86
+ /** 在同一弹窗内校验密码并开始 MFA 绑定。 */
87
+ async function beginMfaSetup() {
88
+ if (!setupPasswordInput.value.trim()) {
89
+ errorMessage.value = t('core.crypto.password_required')
90
+ return
91
+ }
92
+ loading.value = true
93
+ errorMessage.value = ''
94
+ try {
95
+ const password = await encryptPassword(
96
+ setupPasswordInput.value,
97
+ PASSWORD_CRYPTO_SCENE.PASSWORD_CRYPTO_SCENE_MFA,
98
+ )
99
+ const request: BeginMfaSetupRequest = { password, setup_ticket: '' }
100
+ const result = await defMfaService.BeginMfaSetup(request)
101
+ setupTicket.value = result.setup_ticket
102
+ setupMethod.value = result.method || 'totp'
103
+ setupUri.value = result.otpauth_uri || ''
104
+ setupWebAuthnOptionsJson.value = result.webauthn_options_json || ''
105
+ setupCode.value = ''
106
+ setupPasswordInput.value = ''
107
+ } catch (error) {
108
+ errorMessage.value = error instanceof Error ? error.message : t('common.message.request_error')
109
+ } finally {
110
+ loading.value = false
111
+ }
112
+ }
113
+
114
+ /** 根据绑定阶段处理密码校验或 MFA 确认。 */
115
+ async function handleSetupAction() {
116
+ if (loading.value) return
117
+ if (!setupTicket.value) {
118
+ await beginMfaSetup()
119
+ return
120
+ }
121
+ await handleSetupConfirm()
122
+ }
123
+
124
+ /** 校验 MFA 因素并禁用当前用户的多因素认证。 */
125
+ async function handleDisableConfirm() {
126
+ if (loading.value) return
127
+ if (!disablePasswordInput.value.trim()) {
128
+ errorMessage.value = t('core.crypto.password_required')
129
+ return
130
+ }
131
+ const method = props.method === 'webauthn' ? 'webauthn' : 'totp'
132
+ if (method === 'totp' && !disableCode.value.trim() && !disableRecoveryCode.value.trim()) {
133
+ errorMessage.value = t('core.settings.mfa_disable_factor_required')
134
+ return
135
+ }
136
+ loading.value = true
137
+ errorMessage.value = ''
138
+ try {
139
+ const password = await encryptPassword(
140
+ disablePasswordInput.value,
141
+ PASSWORD_CRYPTO_SCENE.PASSWORD_CRYPTO_SCENE_MFA,
142
+ )
143
+ let request: DisableMfaRequest
144
+ if (disableRecoveryCode.value.trim()) {
145
+ request = {
146
+ password,
147
+ code: '',
148
+ webauthn_challenge_id: '',
149
+ webauthn_response_json: '',
150
+ recovery_code: disableRecoveryCode.value,
151
+ }
152
+ } else if (method === 'webauthn') {
153
+ const challenge = await defMfaService.BeginMfaDisable({})
154
+ const webauthnResponseJson = await getWebAuthnAssertion(challenge.webauthn_options_json)
155
+ request = {
156
+ password,
157
+ code: '',
158
+ webauthn_challenge_id: challenge.challenge_id,
159
+ webauthn_response_json: webauthnResponseJson,
160
+ recovery_code: '',
161
+ }
162
+ } else {
163
+ request = {
164
+ password,
165
+ code: disableCode.value,
166
+ webauthn_challenge_id: '',
167
+ webauthn_response_json: '',
168
+ recovery_code: '',
169
+ }
170
+ }
171
+ await defMfaService.DisableMfa(request)
172
+ loading.value = false
173
+ closeDialog()
174
+ emit('disabled')
175
+ } catch (error) {
176
+ errorMessage.value = error instanceof Error ? error.message : t('common.message.request_error')
177
+ } finally {
178
+ loading.value = false
179
+ }
180
+ }
181
+
182
+ /** 确认 MFA 绑定并展示一次性恢复码。 */
183
+ async function handleSetupConfirm() {
184
+ if (loading.value) return
185
+ if (setupMethod.value !== 'webauthn' && !setupCode.value.trim()) {
186
+ errorMessage.value = t('core.login.mfa_code')
187
+ return
188
+ }
189
+ loading.value = true
190
+ errorMessage.value = ''
191
+ try {
192
+ const webauthnResponseJson =
193
+ setupMethod.value === 'webauthn'
194
+ ? await createWebAuthnCredential(setupWebAuthnOptionsJson.value)
195
+ : ''
196
+ const request: ConfirmMfaSetupRequest = {
197
+ setup_ticket: setupTicket.value,
198
+ code: setupCode.value,
199
+ webauthn_response_json: webauthnResponseJson,
200
+ }
201
+ const result = await defMfaService.ConfirmMfaSetup(request)
202
+ recoveryCodes.value = result.recovery_codes || []
203
+ setupDialogVisible.value = false
204
+ recoveryDialogVisible.value = true
205
+ } catch (error) {
206
+ errorMessage.value = error instanceof Error ? error.message : t('common.message.request_error')
207
+ } finally {
208
+ loading.value = false
209
+ }
210
+ }
211
+
212
+ /** 确认已保存恢复码并通知业务方刷新状态。 */
213
+ function finishBinding() {
214
+ const method = setupMethod.value
215
+ closeDialog()
216
+ emit('success', method)
217
+ }
218
+
219
+ watch(
220
+ () => props.modelValue,
221
+ (value) => {
222
+ if (value) {
223
+ openDialog()
224
+ return
225
+ }
226
+ closeDialog()
227
+ },
228
+ )
229
+ </script>
230
+
231
+ <template>
232
+ <view v-if="setupDialogVisible" class="mfa-bind-dialog" @tap="closeDialog">
233
+ <view class="mfa-bind-dialog__panel" @tap.stop>
234
+ <view class="mfa-bind-dialog__header">
235
+ <text class="mfa-bind-dialog__title">
236
+ {{ setupTicket ? t('core.login.mfa_setup_title') : t('core.password.verify_title') }}
237
+ </text>
238
+ <text class="mfa-bind-dialog__close" @tap="closeDialog">×</text>
239
+ </view>
240
+ <view class="mfa-bind-dialog__body">
241
+ <template v-if="!setupTicket">
242
+ <text class="mfa-bind-dialog__label">{{ t('core.password.verify_label') }}</text>
243
+ <input
244
+ v-model="setupPasswordInput"
245
+ class="mfa-bind-dialog__input"
246
+ password
247
+ autocomplete="current-password"
248
+ :placeholder="t('core.crypto.password_required')"
249
+ @input="errorMessage = ''"
250
+ @confirm="handleSetupAction"
251
+ />
252
+ </template>
253
+ <template v-else>
254
+ <MfaSetupPanel :uri="setupUri" />
255
+ <input
256
+ v-if="setupMethod !== 'webauthn'"
257
+ v-model="setupCode"
258
+ class="mfa-bind-dialog__input"
259
+ type="number"
260
+ maxlength="8"
261
+ :placeholder="t('core.login.mfa_code')"
262
+ @confirm="handleSetupAction"
263
+ />
264
+ </template>
265
+ <text v-if="errorMessage" class="mfa-bind-dialog__error">{{ errorMessage }}</text>
266
+ </view>
267
+ <view class="mfa-bind-dialog__footer">
268
+ <button
269
+ class="mfa-bind-dialog__button mfa-bind-dialog__button--cancel"
270
+ :disabled="loading"
271
+ @tap="closeDialog"
272
+ >
273
+ {{ t('common.action.cancel') }}
274
+ </button>
275
+ <button
276
+ class="mfa-bind-dialog__button mfa-bind-dialog__button--confirm"
277
+ :loading="loading"
278
+ @tap="handleSetupAction"
279
+ >
280
+ {{
281
+ !setupTicket
282
+ ? t('common.action.confirm')
283
+ : setupMethod === 'webauthn'
284
+ ? t('core.login.mfa_webauthn_action')
285
+ : t('common.action.confirm')
286
+ }}
287
+ </button>
288
+ </view>
289
+ </view>
290
+ </view>
291
+
292
+ <view v-if="disableDialogVisible" class="mfa-bind-dialog" @tap="closeDialog">
293
+ <view class="mfa-bind-dialog__panel" @tap.stop>
294
+ <view class="mfa-bind-dialog__header">
295
+ <text class="mfa-bind-dialog__title">{{ t('core.settings.mfa_disable_title') }}</text>
296
+ <text class="mfa-bind-dialog__close" @tap="closeDialog">×</text>
297
+ </view>
298
+ <view class="mfa-bind-dialog__body">
299
+ <text class="mfa-bind-dialog__label">{{ t('core.password.verify_label') }}</text>
300
+ <input
301
+ v-model="disablePasswordInput"
302
+ class="mfa-bind-dialog__input"
303
+ password
304
+ :placeholder="t('core.crypto.password_required')"
305
+ autocomplete="current-password"
306
+ @input="errorMessage = ''"
307
+ @confirm="handleDisableConfirm"
308
+ />
309
+ <input
310
+ v-if="props.method !== 'webauthn'"
311
+ v-model="disableCode"
312
+ class="mfa-bind-dialog__input"
313
+ type="number"
314
+ maxlength="8"
315
+ :placeholder="t('core.login.mfa_code')"
316
+ @input="errorMessage = ''"
317
+ @confirm="handleDisableConfirm"
318
+ />
319
+ <input
320
+ v-model="disableRecoveryCode"
321
+ class="mfa-bind-dialog__input"
322
+ :placeholder="t('core.login.mfa_recovery_code')"
323
+ @input="errorMessage = ''"
324
+ @confirm="handleDisableConfirm"
325
+ />
326
+ <text v-if="errorMessage" class="mfa-bind-dialog__error">{{ errorMessage }}</text>
327
+ </view>
328
+ <view class="mfa-bind-dialog__footer">
329
+ <button
330
+ class="mfa-bind-dialog__button mfa-bind-dialog__button--cancel"
331
+ :disabled="loading"
332
+ @tap="closeDialog"
333
+ >
334
+ {{ t('common.action.cancel') }}
335
+ </button>
336
+ <button
337
+ class="mfa-bind-dialog__button mfa-bind-dialog__button--confirm mfa-bind-dialog__button--danger"
338
+ :loading="loading"
339
+ @tap="handleDisableConfirm"
340
+ >
341
+ {{ t('core.settings.mfa_disable_action') }}
342
+ </button>
343
+ </view>
344
+ </view>
345
+ </view>
346
+
347
+ <MfaRecoveryCodesDialog
348
+ v-model="recoveryDialogVisible"
349
+ :codes="recoveryCodes"
350
+ @confirm="finishBinding"
351
+ />
352
+ </template>
353
+
354
+ <style scoped lang="scss">
355
+ .mfa-bind-dialog {
356
+ position: fixed;
357
+ z-index: 1000;
358
+ top: 0;
359
+ right: 0;
360
+ bottom: 0;
361
+ left: 0;
362
+ display: flex;
363
+ align-items: center;
364
+ justify-content: center;
365
+ padding: 32rpx;
366
+ background-color: rgb(0 0 0 / 45%);
367
+ }
368
+
369
+ .mfa-bind-dialog__panel {
370
+ width: min(100%, 680rpx);
371
+ overflow: hidden;
372
+ background-color: #fff;
373
+ border-radius: 16rpx;
374
+ box-shadow: 0 20rpx 64rpx rgb(0 0 0 / 20%);
375
+ }
376
+
377
+ .mfa-bind-dialog__header {
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: space-between;
381
+ padding: 28rpx 32rpx;
382
+ border-bottom: 1rpx solid #eee;
383
+ }
384
+
385
+ .mfa-bind-dialog__title {
386
+ color: #222;
387
+ font-size: 34rpx;
388
+ font-weight: 600;
389
+ }
390
+
391
+ .mfa-bind-dialog__close {
392
+ color: #999;
393
+ font-size: 42rpx;
394
+ line-height: 1;
395
+ }
396
+
397
+ .mfa-bind-dialog__body {
398
+ padding: 32rpx;
399
+ }
400
+
401
+ .mfa-bind-dialog__label {
402
+ display: block;
403
+ margin-bottom: 12rpx;
404
+ color: #333;
405
+ font-size: 28rpx;
406
+ }
407
+
408
+ .mfa-bind-dialog__input {
409
+ box-sizing: border-box;
410
+ width: 100%;
411
+ padding: 20rpx 24rpx;
412
+ color: #222;
413
+ font-size: 30rpx;
414
+ border: 1rpx solid #ddd;
415
+ border-radius: 8rpx;
416
+ }
417
+
418
+ .mfa-bind-dialog__input {
419
+ height: 84rpx;
420
+ margin-bottom: 16rpx;
421
+ }
422
+
423
+ .mfa-bind-dialog__error {
424
+ display: block;
425
+ margin-top: 12rpx;
426
+ font-size: 24rpx;
427
+ line-height: 1.5;
428
+ }
429
+
430
+ .mfa-bind-dialog__error {
431
+ color: #e64340;
432
+ }
433
+
434
+ .mfa-bind-dialog__footer {
435
+ display: flex;
436
+ justify-content: flex-end;
437
+ gap: 20rpx;
438
+ padding: 0 32rpx 32rpx;
439
+ }
440
+
441
+ .mfa-bind-dialog__button {
442
+ min-width: 160rpx;
443
+ height: 72rpx;
444
+ margin: 0;
445
+ padding: 0 24rpx;
446
+ font-size: 28rpx;
447
+ line-height: 72rpx;
448
+ border: 0;
449
+ border-radius: 8rpx;
450
+ }
451
+
452
+ .mfa-bind-dialog__button::after {
453
+ border: 0;
454
+ }
455
+
456
+ .mfa-bind-dialog__button--cancel {
457
+ color: #555;
458
+ background-color: #f3f3f3;
459
+ }
460
+
461
+ .mfa-bind-dialog__button--confirm {
462
+ color: #fff;
463
+ background-color: #27ba9b;
464
+ }
465
+
466
+ .mfa-bind-dialog__button--danger {
467
+ background-color: #e64340;
468
+ }
469
+ </style>
@@ -0,0 +1,192 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref } from 'vue'
3
+ import { useI18n } from '../locales'
4
+ import { copyText } from '../utils/clipboard'
5
+
6
+ /** MFA 恢复码弹窗属性。 */
7
+ interface MfaRecoveryCodesDialogProps {
8
+ /** 弹窗显示状态。 */
9
+ modelValue: boolean
10
+ /** 本次生成的一次性恢复码。 */
11
+ codes: string[]
12
+ }
13
+
14
+ const props = withDefaults(defineProps<MfaRecoveryCodesDialogProps>(), {
15
+ modelValue: false,
16
+ codes: () => [],
17
+ })
18
+
19
+ const emit = defineEmits<{
20
+ 'update:modelValue': [value: boolean]
21
+ confirm: []
22
+ }>()
23
+
24
+ const { t } = useI18n()
25
+ const codesText = computed(() => props.codes.join('\n'))
26
+ const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
27
+ const copyTextLabel = computed(() => {
28
+ if (copyState.value === 'copied') return t('core.login.mfa_recovery_codes_copied')
29
+ if (copyState.value === 'failed') return t('common.message.request_error')
30
+ return t('core.login.mfa_copy_recovery_codes')
31
+ })
32
+ let resetCopyStateTimer: ReturnType<typeof setTimeout> | undefined
33
+
34
+ /** 复制当前显示的恢复码。 */
35
+ async function copyRecoveryCodes() {
36
+ if (!codesText.value) return
37
+ try {
38
+ await copyText(codesText.value)
39
+ copyState.value = 'copied'
40
+ } catch {
41
+ copyState.value = 'failed'
42
+ }
43
+ if (resetCopyStateTimer) clearTimeout(resetCopyStateTimer)
44
+ resetCopyStateTimer = setTimeout(() => {
45
+ copyState.value = 'idle'
46
+ }, 2000)
47
+ }
48
+
49
+ /** 关闭弹窗并通知调用方继续完成绑定流程。 */
50
+ function handleConfirm() {
51
+ emit('update:modelValue', false)
52
+ emit('confirm')
53
+ }
54
+
55
+ onBeforeUnmount(() => {
56
+ if (resetCopyStateTimer) clearTimeout(resetCopyStateTimer)
57
+ })
58
+ </script>
59
+
60
+ <template>
61
+ <view v-if="modelValue" class="mfa-recovery-dialog">
62
+ <view class="mfa-recovery-dialog__panel" @tap.stop>
63
+ <view class="mfa-recovery-dialog__header">
64
+ <text class="mfa-recovery-dialog__title">{{
65
+ t('core.login.mfa_recovery_codes_title')
66
+ }}</text>
67
+ </view>
68
+ <view class="mfa-recovery-dialog__body">
69
+ <view class="mfa-recovery-dialog__codes">
70
+ <text>{{ codesText }}</text>
71
+ </view>
72
+ <button
73
+ class="mfa-recovery-dialog__copy"
74
+ :class="{ 'is-failed': copyState === 'failed' }"
75
+ @tap.stop="copyRecoveryCodes"
76
+ >
77
+ {{ copyTextLabel }}
78
+ </button>
79
+ <text class="mfa-recovery-dialog__warning">{{
80
+ t('core.login.mfa_recovery_codes_warning')
81
+ }}</text>
82
+ </view>
83
+ <view class="mfa-recovery-dialog__footer">
84
+ <button class="mfa-recovery-dialog__confirm" @tap="handleConfirm">
85
+ {{ t('core.login.mfa_recovery_codes_confirm') }}
86
+ </button>
87
+ </view>
88
+ </view>
89
+ </view>
90
+ </template>
91
+
92
+ <style scoped lang="scss">
93
+ .mfa-recovery-dialog {
94
+ position: fixed;
95
+ z-index: 1001;
96
+ inset: 0;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ padding: 32rpx;
101
+ background-color: rgb(0 0 0 / 45%);
102
+ }
103
+
104
+ .mfa-recovery-dialog__panel {
105
+ width: min(100%, 640rpx);
106
+ overflow: hidden;
107
+ background-color: #fff;
108
+ border-radius: 16rpx;
109
+ box-shadow: 0 20rpx 64rpx rgb(0 0 0 / 20%);
110
+ }
111
+
112
+ .mfa-recovery-dialog__header {
113
+ padding: 28rpx 32rpx;
114
+ border-bottom: 1rpx solid #eee;
115
+ }
116
+
117
+ .mfa-recovery-dialog__title {
118
+ color: #222;
119
+ font-size: 34rpx;
120
+ font-weight: 600;
121
+ }
122
+
123
+ .mfa-recovery-dialog__body {
124
+ padding: 32rpx;
125
+ }
126
+
127
+ .mfa-recovery-dialog__codes {
128
+ box-sizing: border-box;
129
+ width: 100%;
130
+ min-height: 220rpx;
131
+ margin-bottom: 16rpx;
132
+ padding: 20rpx 24rpx;
133
+ color: #222;
134
+ font-size: 30rpx;
135
+ line-height: 1.6;
136
+ border: 1rpx solid #ddd;
137
+ border-radius: 8rpx;
138
+ overflow-wrap: anywhere;
139
+ white-space: pre-wrap;
140
+ }
141
+
142
+ .mfa-recovery-dialog__copy {
143
+ width: auto;
144
+ min-width: 140rpx;
145
+ height: 58rpx;
146
+ margin: 0;
147
+ padding: 0 18rpx;
148
+ color: #27ba9b;
149
+ font-size: 24rpx;
150
+ line-height: 58rpx;
151
+ background-color: #f0fbf8;
152
+ border: 0;
153
+ border-radius: 8rpx;
154
+ }
155
+
156
+ .mfa-recovery-dialog__copy::after,
157
+ .mfa-recovery-dialog__confirm::after {
158
+ border: 0;
159
+ }
160
+
161
+ .mfa-recovery-dialog__copy.is-failed {
162
+ color: #e64340;
163
+ background-color: #fff1f0;
164
+ }
165
+
166
+ .mfa-recovery-dialog__warning {
167
+ display: block;
168
+ margin-top: 12rpx;
169
+ color: #c58b00;
170
+ font-size: 24rpx;
171
+ line-height: 1.5;
172
+ }
173
+
174
+ .mfa-recovery-dialog__footer {
175
+ display: flex;
176
+ justify-content: flex-end;
177
+ padding: 0 32rpx 32rpx;
178
+ }
179
+
180
+ .mfa-recovery-dialog__confirm {
181
+ min-width: 160rpx;
182
+ height: 72rpx;
183
+ margin: 0;
184
+ padding: 0 24rpx;
185
+ color: #fff;
186
+ font-size: 28rpx;
187
+ line-height: 72rpx;
188
+ background-color: #27ba9b;
189
+ border: 0;
190
+ border-radius: 8rpx;
191
+ }
192
+ </style>