@liujitcn/kratos-uni-app-core 0.0.29 → 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.
- package/README.md +28 -5
- package/dist/module.mjs +164 -4
- package/package.json +3 -1
- package/src/api/base/mfa.ts +127 -0
- package/src/components/KratosSettingsPage.vue +280 -0
- package/src/components/KratosTabBar.vue +50 -1
- package/src/components/MfaManageDialog.vue +469 -0
- package/src/components/MfaRecoveryCodesDialog.vue +192 -0
- package/src/components/MfaSetupPanel.vue +160 -0
- package/src/components/PasswordVerifyDialog.vue +277 -0
- package/src/index.ts +2 -0
- package/src/locales/README.md +2 -1
- package/src/locales/en-US.json +41 -1
- package/src/locales/index.ts +10 -1
- package/src/locales/ja-JP.json +41 -1
- package/src/locales/zh-CN.json +41 -1
- package/src/locales/zh-TW.json +41 -1
- package/src/navigation.ts +47 -1
- package/src/rpc/base/v1/login.ts +33 -2
- package/src/rpc/base/v1/mfa.ts +180 -0
- package/src/rpc/base/v1/oauth.ts +25 -0
- package/src/stores/modules/user.ts +26 -7
- package/src/utils/clipboard.ts +33 -0
- package/src/utils/http.ts +8 -3
- package/src/utils/webauthn.ts +101 -0
- package/src/views/pages/login/login.vue +293 -12
|
@@ -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>
|