mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-10 17:42:50 +08:00
feat: 优化通行密钥错误提示与代码结构
- feat(login): 优化通行密钥(Passkey)登录逻辑,支持 Conditional UI 自动填充,并改进错误提示。 - feat(userProfile): 优化双重验证弹窗样式。 - feat(qrcode): 优化二维码生成逻辑与显示。 - feat(passkey): 优化通行密钥错误提示,添加最后使用时间显示。
This commit is contained in:
@@ -57,7 +57,7 @@
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"vue": "^3.5.13",
|
||||
@@ -84,6 +84,7 @@
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
|
||||
@@ -861,6 +861,16 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
aaguid?: string
|
||||
transports?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
export interface Storage {
|
||||
// 总空间
|
||||
@@ -1429,3 +1439,10 @@ export interface SubscribeShareStatistics {
|
||||
// 总复用人次
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
231
src/components/dialog/OTPAuthDialog.vue
Normal file
231
src/components/dialog/OTPAuthDialog.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import QRCode from 'qrcode'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
passkeyList?: PassKey[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
passkeyList: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (props.isOtp) {
|
||||
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
|
||||
qrCodeImage.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用OTP,生成新的二维码
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||
uri: string
|
||||
secret: string
|
||||
}>
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
} else {
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error(t('profile.otpCodeRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/verify', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
show.value = false
|
||||
emit('update:isOtp', true)
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
function disableOtp() {
|
||||
// 如果已绑定PassKey,不允许关闭OTP
|
||||
if (props.passkeyList && props.passkeyList.length > 0) {
|
||||
$toast.error(t('profile.disableOtpWithPasskeyError'))
|
||||
return
|
||||
}
|
||||
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.disableTwoFactor'),
|
||||
text: t('profile.confirmToDisableOtp'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/disable', {
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
emit('update:isOtp', false)
|
||||
$toast.success(t('profile.otpDisableSuccess'))
|
||||
show.value = false
|
||||
} else {
|
||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动获取 URI
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
getOtpUri()
|
||||
otpPassword.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
qrCodeImage.value = ''
|
||||
qrCode.value = ''
|
||||
otpUri.value = ''
|
||||
secret.value = ''
|
||||
otpPassword.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-cellphone-key" class="me-2" />
|
||||
{{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.authenticatorAppDescription') }}
|
||||
</p>
|
||||
<!-- 如果已启用OTP,显示清除界面 -->
|
||||
<template v-if="props.isOtp && !qrCode">
|
||||
<VAlert type="success" variant="tonal" class="mb-4">
|
||||
{{ t('profile.authenticatorEnabled') }}
|
||||
</VAlert>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.clearAuthenticatorTip') }}
|
||||
</p>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" @click="disableOtp">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
{{ t('profile.clearAuthenticator') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设置新的OTP -->
|
||||
<template v-else>
|
||||
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
:label="t('profile.enterVerificationCode')"
|
||||
class="mb-8"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
312
src/components/dialog/PasskeyDialog.vue
Normal file
312
src/components/dialog/PasskeyDialog.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script lang="ts" setup>
|
||||
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
}
|
||||
|
||||
// WebAuthn 相关接口定义
|
||||
interface PublicKeyCredentialDescriptorJSON {
|
||||
id: string
|
||||
type: 'public-key'
|
||||
transports?: AuthenticatorTransport[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// PassKey注册loading
|
||||
const passkeyRegistering = ref(false)
|
||||
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
// PassKey challenge
|
||||
const passkeyChallenge = ref('')
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString(locale.value)
|
||||
}
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
emit('update:passkeyList', passkeyList.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册PassKey
|
||||
async function registerPassKey() {
|
||||
if (!passkeyName.value) {
|
||||
$toast.error(t('profile.passkeyNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查浏览器环境
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (!window.isSecureContext) {
|
||||
$toast.error(t('login.passkeySecureContextRequired'))
|
||||
return
|
||||
}
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
return
|
||||
}
|
||||
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; challenge: string }>
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const { options, challenge } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyChallenge.value = challenge
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: {
|
||||
...publicKeyOptions,
|
||||
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||
user: {
|
||||
...publicKeyOptions.user,
|
||||
id: base64UrlToUint8Array(publicKeyOptions.user.id),
|
||||
},
|
||||
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: PublicKeyCredentialDescriptorJSON) => ({
|
||||
...cred,
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
})) as PublicKeyCredential
|
||||
|
||||
if (!credential) {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
|
||||
},
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (finishResult.success) {
|
||||
$toast.success(t('profile.passkeyRegisterSuccess'))
|
||||
passkeyName.value = ''
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PassKey注册失败:', error)
|
||||
if (error.name === 'NotAllowedError') {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
} else if (error.response) {
|
||||
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
} else {
|
||||
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} finally {
|
||||
passkeyRegistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除PassKey
|
||||
async function deletePassKey(passkeyId: number) {
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.deletePasskey'),
|
||||
text: t('profile.confirmToDeletePasskey'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/passkey/delete', {
|
||||
passkey_id: passkeyId,
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动加载列表
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
fetchPassKeyList()
|
||||
passkeyName.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyName.value = ''
|
||||
passkeyChallenge.value = ''
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="material-symbols:passkey" class="me-2" />
|
||||
{{ t('profile.passkeyManagement') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.passkeyAppDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- 安全警告 -->
|
||||
<VAlert type="warning" variant="tonal" class="mb-6" icon="mdi-alert">
|
||||
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
|
||||
<template #domain>
|
||||
<b>{{ t('profile.accessDomain') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 注册新通行密钥 -->
|
||||
<VCard v-if="props.isOtp" variant="tonal" class="mb-6">
|
||||
<VCardText>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
|
||||
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
|
||||
<VForm @submit.prevent="registerPassKey">
|
||||
<VTextField
|
||||
v-model="passkeyName"
|
||||
:label="t('profile.passkeyName')"
|
||||
:placeholder="t('profile.passkeyNamePlaceholder')"
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-form-textbox"
|
||||
/>
|
||||
<VBtn color="primary" type="submit" :loading="passkeyRegistering" prepend-icon="mdi-plus">
|
||||
{{ t('profile.registerPasskey') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 未启用 OTP 提示 -->
|
||||
<VAlert v-else type="error" variant="tonal" class="mb-6" icon="mdi-shield-lock">
|
||||
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
|
||||
<template #otp>
|
||||
<b>{{ t('profile.otpAuthenticator') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<div
|
||||
v-for="passkey in passkeyList"
|
||||
:key="passkey.id"
|
||||
class="py-4 d-flex align-center justify-space-between border-b last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-bold mb-1">{{ passkey.name }}</div>
|
||||
<div class="text-caption text-disabled d-flex flex-wrap gap-x-3">
|
||||
<span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>
|
||||
<span v-if="passkey.last_used_at">
|
||||
{{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn
|
||||
variant="flat"
|
||||
color="error"
|
||||
size="small"
|
||||
class="rounded delete-btn"
|
||||
@click="deletePassKey(passkey.id)"
|
||||
>
|
||||
<VIcon icon="mdi-trash-can-outline" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-else type="info" variant="tonal" class="mt-6">
|
||||
{{ t('profile.noPasskeys') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-btn.delete-btn {
|
||||
min-width: 45px;
|
||||
padding: 0;
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
color: rgb(var(--v-theme-error));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-btn.delete-btn:hover {
|
||||
background-color: rgba(var(--v-theme-error), 0.2);
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -24,6 +24,9 @@ const emit = defineEmits(['done', 'close'])
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref(t('dialog.u115Auth.scanQrCode'))
|
||||
|
||||
@@ -61,6 +64,11 @@ async function getQrcode() {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.codeContent, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
@@ -129,7 +137,13 @@ onUnmounted(() => {
|
||||
<VDivider />
|
||||
<VCardText class="pt-2 flex flex-col items-center justify-center">
|
||||
<div class="mt-6 rounded text-center p-3 border">
|
||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
|
||||
@@ -244,17 +244,16 @@ export default {
|
||||
wallpapers: 'Wallpapers',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
otpCode: 'Two-Factor Code',
|
||||
otpCode: 'Verification Code',
|
||||
stayLoggedIn: 'Stay Logged In',
|
||||
login: 'Login',
|
||||
networkError: 'Login failed, please check your network connection!',
|
||||
authFailure: 'Login failed, please check your username, password or two-factor authentication!',
|
||||
authFailure: 'Login failed, please check your username, password or secondary verification!',
|
||||
permissionDenied: 'Login failed, you do not have permission to access!',
|
||||
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
|
||||
serverError: 'Login failed, server error!',
|
||||
loginFailed: 'Login Failed',
|
||||
checkCredentials: 'Please check your username, password or two-factor authentication code!',
|
||||
twoFactorAuth: 'Two-Factor Authentication',
|
||||
secondaryVerification: 'Secondary Verification',
|
||||
loginWithPasskey: 'Login with Passkey',
|
||||
loginWithOtp: 'Login with OTP',
|
||||
orUsePasskey: 'Or use Passkey for verification',
|
||||
@@ -264,7 +263,8 @@ export default {
|
||||
passkeyNotSelected: 'No Passkey selected',
|
||||
passkeyLoginFailed: 'Passkey login failed',
|
||||
passkeyAuthCanceled: 'Passkey authentication canceled',
|
||||
passkeyLoginRetry: 'Passkey login failed, please try again',
|
||||
passkeyNotSupported: 'Current browser does not support Passkeys',
|
||||
passkeySecureContextRequired: 'Passkey requires HTTPS secure connection',
|
||||
passkeyVerifyFailed: 'Passkey verification failed',
|
||||
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
|
||||
mfa: {
|
||||
@@ -2561,6 +2561,7 @@ export default {
|
||||
noRecentPlugins: 'None',
|
||||
},
|
||||
profile: {
|
||||
disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',
|
||||
personalInfo: 'Personal Information',
|
||||
uploadNewAvatar: 'Upload New Avatar',
|
||||
avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',
|
||||
@@ -2600,18 +2601,20 @@ export default {
|
||||
passkeyManagement: 'Passkey Management',
|
||||
registerNewPasskey: 'Register New Passkey',
|
||||
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
|
||||
passkeyAppDescription: 'Passkeys are a simpler, more secure alternative to passwords. You can sign in with just your fingerprint, face recognition, or screen lock. Use passkey-supported apps like iCloud Keychain, Bitwarden, or a hardware key to authenticate.',
|
||||
passkeyName: 'Passkey Name',
|
||||
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
|
||||
registerPasskey: 'Register Passkey',
|
||||
registeredPasskeys: 'Registered Passkeys',
|
||||
createdAt: 'Created At',
|
||||
noPasskeys: 'You have not registered any passkeys yet',
|
||||
createdAt: 'Created',
|
||||
lastUsedAt: 'Last used',
|
||||
noPasskeys: 'You haven’t registered any passkeys yet',
|
||||
passkeyNameRequired: 'Please enter a passkey name',
|
||||
passkeyRegisterSuccess: 'Passkey registered successfully',
|
||||
passkeyRegisterFailed: 'Registration failed',
|
||||
passkeyRegisterCancelled: 'Registration cancelled',
|
||||
passkeyDeleteSuccess: 'Passkey deleted',
|
||||
passkeyDeleteFailed: 'Delete failed',
|
||||
deletePasskey: 'Delete Passkey',
|
||||
passkeyDomainWarning: 'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
|
||||
otpRequiredForPasskey: 'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
|
||||
accessDomain: 'access domain name',
|
||||
@@ -2625,9 +2628,8 @@ export default {
|
||||
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
|
||||
confirmToDisableOtp: 'For security reasons, verifying your login password is required to disable two-factor authentication.',
|
||||
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorAppDescription:
|
||||
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code. It will generate a 6-digit code for you to enter below.',
|
||||
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
|
||||
secretKeyTip:
|
||||
"If you're having trouble with the QR code, select manual entry in your app and enter the code above.",
|
||||
enterVerificationCode: 'Enter verification code to confirm enabling two-factor authentication',
|
||||
|
||||
@@ -243,17 +243,16 @@ export default {
|
||||
wallpapers: '壁纸',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
otpCode: '双重验证码',
|
||||
otpCode: '验证码',
|
||||
stayLoggedIn: '保持登录',
|
||||
login: '登录',
|
||||
networkError: '登录失败,请检查网络连接!',
|
||||
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!',
|
||||
authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!',
|
||||
permissionDenied: '登录失败,您没有权限访问!',
|
||||
noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
|
||||
serverError: '登录失败,服务器错误!',
|
||||
loginFailed: '登录失败',
|
||||
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
|
||||
twoFactorAuth: '双重验证',
|
||||
secondaryVerification: '二次验证',
|
||||
loginWithPasskey: '使用通行密钥登录',
|
||||
loginWithOtp: '使用验证码登录',
|
||||
orUsePasskey: '或使用通行密钥进行验证',
|
||||
@@ -263,7 +262,8 @@ export default {
|
||||
passkeyNotSelected: '未选择通行密钥',
|
||||
passkeyLoginFailed: '通行密钥登录失败',
|
||||
passkeyAuthCanceled: '通行密钥认证被取消',
|
||||
passkeyLoginRetry: '通行密钥登录失败,请重试',
|
||||
passkeyNotSupported: '当前浏览器不支持通行密钥',
|
||||
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
|
||||
passkeyVerifyFailed: '通行密钥验证失败',
|
||||
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
|
||||
mfa: {
|
||||
@@ -2530,6 +2530,7 @@ export default {
|
||||
noRecentPlugins: '无',
|
||||
},
|
||||
profile: {
|
||||
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
|
||||
personalInfo: '个人信息',
|
||||
uploadNewAvatar: '上传新头像',
|
||||
avatarFormatError: '上传的文件不符合要求,请重新选择头像',
|
||||
@@ -2569,11 +2570,12 @@ export default {
|
||||
passkeyManagement: '通行密钥管理',
|
||||
registerNewPasskey: '注册新通行密钥',
|
||||
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
|
||||
passkeyAppDescription: '通行密钥是比密码更简单、更安全的替代方案。您只需通过指纹、面部识别或屏幕锁定即可登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
|
||||
passkeyName: '通行密钥名称',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '注册通行密钥',
|
||||
registeredPasskeys: '已注册的通行密钥',
|
||||
createdAt: '创建时间',
|
||||
createdAt: '创建于',
|
||||
lastUsedAt: '最后使用时间',
|
||||
noPasskeys: '您还没有注册任何通行密钥',
|
||||
passkeyNameRequired: '请输入通行密钥名称',
|
||||
passkeyRegisterSuccess: '通行密钥注册成功',
|
||||
@@ -2581,6 +2583,7 @@ export default {
|
||||
passkeyRegisterCancelled: '注册被取消',
|
||||
passkeyDeleteSuccess: '通行密钥已删除',
|
||||
passkeyDeleteFailed: '删除失败',
|
||||
deletePasskey: '删除通行密钥',
|
||||
passkeyDomainWarning: '通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
|
||||
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
|
||||
accessDomain: '访问域名',
|
||||
@@ -2594,9 +2597,8 @@ export default {
|
||||
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
|
||||
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
|
||||
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
|
||||
authenticatorApp: '身份验证器',
|
||||
authenticatorAppDescription:
|
||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。',
|
||||
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等验证器应用扫描二维码,获取 6 位验证码。',
|
||||
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
|
||||
enterVerificationCode: '输入验证码以确认开启双重验证',
|
||||
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
|
||||
|
||||
@@ -244,17 +244,16 @@ export default {
|
||||
wallpapers: '壁紙',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
otpCode: '雙重驗證碼',
|
||||
otpCode: '驗證碼',
|
||||
stayLoggedIn: '保持登錄',
|
||||
login: '登錄',
|
||||
networkError: '登錄失敗,請檢查網絡連接!',
|
||||
authFailure: '登錄失敗,請檢查用戶名、密碼或雙重驗證是否正確!',
|
||||
authFailure: '登錄失敗,請檢查用戶名、密碼或二次驗證是否正確!',
|
||||
permissionDenied: '登錄失敗,您沒有權限訪問!',
|
||||
serverError: '登錄失敗,服務器錯誤!',
|
||||
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
|
||||
loginFailed: '登錄失敗',
|
||||
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
|
||||
twoFactorAuth: '雙重驗證',
|
||||
secondaryVerification: '二次驗證',
|
||||
loginWithPasskey: '使用通行密鑰登錄',
|
||||
loginWithOtp: '使用驗證碼登錄',
|
||||
orUsePasskey: '或使用通行密鑰進行驗證',
|
||||
@@ -264,7 +263,8 @@ export default {
|
||||
passkeyNotSelected: '未選擇通行密鑰',
|
||||
passkeyLoginFailed: '通行密鑰登錄失敗',
|
||||
passkeyAuthCanceled: '通行密鑰驗證被取消',
|
||||
passkeyLoginRetry: '通行密鑰登錄失敗,請重試',
|
||||
passkeyNotSupported: '當前瀏覽器不支援通行密鑰',
|
||||
passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',
|
||||
passkeyVerifyFailed: '通行密鑰驗证失敗',
|
||||
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
|
||||
mfa: {
|
||||
@@ -2516,6 +2516,7 @@ export default {
|
||||
noRecentPlugins: '無',
|
||||
},
|
||||
profile: {
|
||||
disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器!',
|
||||
personalInfo: '個人信息',
|
||||
uploadNewAvatar: '上傳新頭像',
|
||||
avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像',
|
||||
@@ -2555,11 +2556,12 @@ export default {
|
||||
passkeyManagement: '通行密鑰管理',
|
||||
registerNewPasskey: '註冊新通行密鑰',
|
||||
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
|
||||
passkeyAppDescription: '通行密鑰是比傳統密碼更簡單、更安全的替代方案。您只需透過指紋、臉部辨識或螢幕鎖定即可登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
|
||||
passkeyName: '通行密鑰名稱',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '註冊通行密鑰',
|
||||
registeredPasskeys: '已註冊的通行密鑰',
|
||||
createdAt: '建立時間',
|
||||
createdAt: '建立於',
|
||||
lastUsedAt: '最後使用時間',
|
||||
noPasskeys: '您還沒有註冊任何通行密鑰',
|
||||
passkeyNameRequired: '請輸入通行密鑰名稱',
|
||||
passkeyRegisterSuccess: '通行密鑰註冊成功',
|
||||
@@ -2567,6 +2569,7 @@ export default {
|
||||
passkeyRegisterCancelled: '註冊被取消',
|
||||
passkeyDeleteSuccess: '通行密鑰已刪除',
|
||||
passkeyDeleteFailed: '刪除失敗',
|
||||
deletePasskey: '刪除通行密鑰',
|
||||
passkeyDomainWarning: '通行密鑰(PassKey)的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
|
||||
otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
|
||||
accessDomain: '訪問域名',
|
||||
@@ -2580,9 +2583,8 @@ export default {
|
||||
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
|
||||
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
|
||||
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
|
||||
authenticatorApp: '身份驗證器',
|
||||
authenticatorAppDescription:
|
||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password這樣的身份驗證器應用程序,掃描二維碼。它將為您生成一個6位數的代碼,供您在下方輸入。',
|
||||
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等驗證器應用程式掃描 QR Code,取得 6 位數驗證碼。',
|
||||
secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。',
|
||||
enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證',
|
||||
avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -42,7 +43,7 @@ const errorMessage = ref('')
|
||||
// 是否开启双重验证
|
||||
const isOTP = ref(false)
|
||||
|
||||
// 双重验证对话框
|
||||
// 二次验证对话框
|
||||
const mfaDialog = ref(false)
|
||||
|
||||
// MFA PassKey loading
|
||||
@@ -92,17 +93,11 @@ interface PassKeyAuthOptions {
|
||||
|
||||
// PassKey API 响应类型
|
||||
interface PassKeyStartResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: {
|
||||
options: string // JSON 字符串
|
||||
challenge: string
|
||||
}
|
||||
options: string // JSON 字符串
|
||||
challenge: string
|
||||
}
|
||||
|
||||
interface PassKeyFinishResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
access_token: string
|
||||
super_user: boolean
|
||||
user_id: number
|
||||
@@ -117,10 +112,10 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
|
||||
const { username, isConditional = false, signal } = options
|
||||
|
||||
// 1. 开始认证流程
|
||||
const startResponse = (await api.post<PassKeyStartResponse>(
|
||||
const startResponse = (await api.post(
|
||||
'/mfa/passkey/authenticate/start',
|
||||
username ? { username } : {},
|
||||
)) as any as PassKeyStartResponse
|
||||
)) as ApiResponse<PassKeyStartResponse>
|
||||
|
||||
if (!startResponse.success) {
|
||||
throw new Error(startResponse.message || 'PassKey start failed')
|
||||
@@ -130,7 +125,7 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
|
||||
const publicKeyOptions = JSON.parse(optionsStr)
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credentialRequestOptions: any = {
|
||||
const credentialRequestOptions: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
...publicKeyOptions,
|
||||
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||
@@ -176,18 +171,37 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
|
||||
}
|
||||
|
||||
// 4. 完成认证
|
||||
const finishResponse = (await api.post<PassKeyFinishResponse>('/mfa/passkey/authenticate/finish', {
|
||||
const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: challenge,
|
||||
})) as any as PassKeyFinishResponse
|
||||
})) as PassKeyFinishResponse
|
||||
|
||||
if (!finishResponse || !finishResponse.access_token) {
|
||||
throw new Error('PassKey finish failed: No access token')
|
||||
}
|
||||
|
||||
return finishResponse
|
||||
}
|
||||
|
||||
// 使用PassKey登录 (支持 Conditional UI)
|
||||
async function loginWithPassKey(isConditional = false) {
|
||||
// 统一处理 PassKey 认证流程
|
||||
async function handlePassKeyAuth(
|
||||
authOptions: PassKeyAuthOptions,
|
||||
setLoading: (loading: boolean) => void,
|
||||
onSuccess: (response: PassKeyFinishResponse) => Promise<void>,
|
||||
) {
|
||||
const { isConditional = false } = authOptions
|
||||
errorMessage.value = ''
|
||||
|
||||
// 检查浏览器环境 (仅手动触发时提示)
|
||||
if (!isConditional && !window.PublicKeyCredential) {
|
||||
if (!window.isSecureContext) {
|
||||
errorMessage.value = t('login.passkeySecureContextRequired')
|
||||
} else {
|
||||
errorMessage.value = t('login.passkeyNotSupported')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是手动触发(非 Conditional UI)
|
||||
if (!isConditional) {
|
||||
// 取消之前的 Conditional UI 请求
|
||||
@@ -206,12 +220,12 @@ async function loginWithPassKey(isConditional = false) {
|
||||
|
||||
// 标记手动请求为活跃状态,并立即设置 loading
|
||||
isManualPassKeyActive = true
|
||||
passkeyLoading.value = true
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const finishResponse = await authenticateWithPassKey({
|
||||
isConditional,
|
||||
...authOptions,
|
||||
signal:
|
||||
isConditional && conditionalAbortController
|
||||
? conditionalAbortController.signal
|
||||
@@ -220,10 +234,10 @@ async function loginWithPassKey(isConditional = false) {
|
||||
: undefined,
|
||||
})
|
||||
|
||||
await handleLoginSuccess(finishResponse)
|
||||
await onSuccess(finishResponse)
|
||||
} catch (error: any) {
|
||||
// Conditional UI 模式下:
|
||||
// 1. 如果 passkeyLoading 为 false,说明错误发生在用户选择密钥之前(如初始化失败、用户取消等),此时应静默
|
||||
// 1. 如果 loading 为 false,说明错误发生在用户选择密钥之前(如初始化失败、用户取消等),此时应静默
|
||||
// 2. 如果是 AbortError,始终静默
|
||||
if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) {
|
||||
console.warn('[PassKey] Conditional UI silenced error:', error)
|
||||
@@ -237,19 +251,17 @@ async function loginWithPassKey(isConditional = false) {
|
||||
}
|
||||
|
||||
// 设置错误信息
|
||||
if (error.response) {
|
||||
errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed')
|
||||
} else if (error.name === 'NotAllowedError') {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
errorMessage.value = t('login.passkeyAuthCanceled')
|
||||
} else {
|
||||
errorMessage.value = t('login.passkeyLoginRetry')
|
||||
errorMessage.value = t('login.authFailure')
|
||||
}
|
||||
} finally {
|
||||
// 清除 loading 状态
|
||||
if (!isConditional) {
|
||||
// 手动模式:始终清除,并取消手动活跃标记
|
||||
isManualPassKeyActive = false
|
||||
passkeyLoading.value = false
|
||||
setLoading(false)
|
||||
manualAbortController = null
|
||||
} else {
|
||||
// Conditional UI 模式:只有在没有手动请求活跃时才清除
|
||||
@@ -260,6 +272,17 @@ async function loginWithPassKey(isConditional = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用PassKey登录 (支持 Conditional UI)
|
||||
async function loginWithPassKey(isConditional = false) {
|
||||
await handlePassKeyAuth(
|
||||
{ isConditional },
|
||||
val => (passkeyLoading.value = val),
|
||||
async response => {
|
||||
await handleLoginSuccess(response)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 切换语言
|
||||
async function switchLanguage(locale: SupportedLocale) {
|
||||
await setI18nLanguage(locale)
|
||||
@@ -267,23 +290,6 @@ async function switchLanguage(locale: SupportedLocale) {
|
||||
langMenu.value = false
|
||||
}
|
||||
|
||||
// 查询是否开启双重验证
|
||||
async function fetchOTP(): Promise<boolean> {
|
||||
if (!form.value.username) {
|
||||
isOTP.value = false
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const response: any = await api.get(`/mfa/status/${form.value.username}`)
|
||||
isOTP.value = response.success
|
||||
return response.success
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
isOTP.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅推送通知
|
||||
async function subscribeForPushNotifications() {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
@@ -304,7 +310,7 @@ async function subscribeForPushNotifications() {
|
||||
try {
|
||||
await api.post('/message/webpush/subscribe', subscription)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,26 +400,32 @@ async function login() {
|
||||
// 登录失败,显示错误提示
|
||||
if (!error.response) {
|
||||
errorMessage.value = t('login.networkError')
|
||||
} else if (error.response.status === 401) {
|
||||
// 401错误可能是需要MFA或者认证失败
|
||||
// 检查响应头是否有MFA要求标识
|
||||
const mfaRequired = error.response.headers?.['x-mfa-required'] === 'true'
|
||||
if (mfaRequired && !form.value.otp_password) {
|
||||
// 需要MFA验证,弹出对话框
|
||||
isOTP.value = true
|
||||
mfaDialog.value = true
|
||||
return
|
||||
}
|
||||
// 不需要MFA或已填写OTP但认证失败
|
||||
errorMessage.value = t('login.authFailure')
|
||||
// 认证失败后清空OTP密码,防止下次点击不弹出对话框
|
||||
form.value.otp_password = ''
|
||||
} else if (error.response.status === 403) {
|
||||
errorMessage.value = t('login.permissionDenied')
|
||||
} else if (error.response.status === 500) {
|
||||
errorMessage.value = t('login.serverError')
|
||||
} else {
|
||||
errorMessage.value = `${t('login.loginFailed')} ${error.response.status},${t('login.checkCredentials')}`
|
||||
return
|
||||
}
|
||||
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// 401错误可能是需要MFA或者认证失败
|
||||
// 检查响应头是否有MFA要求标识
|
||||
if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {
|
||||
// 需要MFA验证,弹出对话框
|
||||
isOTP.value = true
|
||||
mfaDialog.value = true
|
||||
return
|
||||
}
|
||||
// 不需要MFA或已填写OTP但认证失败
|
||||
errorMessage.value = t('login.authFailure')
|
||||
// 认证失败后清空OTP密码,防止下次点击不弹出对话框
|
||||
form.value.otp_password = ''
|
||||
break
|
||||
case 403:
|
||||
errorMessage.value = t('login.permissionDenied')
|
||||
break
|
||||
case 500:
|
||||
errorMessage.value = t('login.serverError')
|
||||
break
|
||||
default:
|
||||
errorMessage.value = `${t('login.authFailure')} (Status: ${error.response.status})`
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -430,29 +442,15 @@ function loginWithOTP() {
|
||||
async function verifyWithPassKey() {
|
||||
if (!form.value.username) return
|
||||
|
||||
mfaPasskeyLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const finishResponse = await authenticateWithPassKey({
|
||||
username: form.value.username,
|
||||
})
|
||||
|
||||
// 关闭MFA对话框
|
||||
mfaDialog.value = false
|
||||
|
||||
await handleLoginSuccess(finishResponse)
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
errorMessage.value = error.response.data?.detail || t('login.passkeyVerifyFailed')
|
||||
} else if (error.name === 'NotAllowedError') {
|
||||
errorMessage.value = t('login.passkeyAuthCanceled')
|
||||
} else {
|
||||
errorMessage.value = t('login.passkeyVerifyFailedRetry')
|
||||
}
|
||||
} finally {
|
||||
mfaPasskeyLoading.value = false
|
||||
}
|
||||
await handlePassKeyAuth(
|
||||
{ username: form.value.username },
|
||||
val => (mfaPasskeyLoading.value = val),
|
||||
async response => {
|
||||
// 关闭MFA对话框
|
||||
mfaDialog.value = false
|
||||
await handleLoginSuccess(response)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
@@ -517,7 +515,7 @@ onUnmounted(() => {
|
||||
<!-- 登录页面容器 -->
|
||||
<div class="relative flex min-h-screen flex-col items-center justify-center">
|
||||
<!-- 登录表单 -->
|
||||
<div class="auth-wrapper d-flex align-center justify-center">
|
||||
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
|
||||
<VCard
|
||||
class="auth-card px-7 py-3 w-full h-full"
|
||||
:class="{ 'glass-effect': !isTransparentTheme }"
|
||||
@@ -610,7 +608,7 @@ onUnmounted(() => {
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="mt-3 passkey-btn"
|
||||
prepend-icon="mdi-key-variant"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="passkeyLoading"
|
||||
@click="loginWithPassKey(false)"
|
||||
>
|
||||
@@ -626,11 +624,11 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- MFA双重验证对话框 -->
|
||||
<!-- MFA二次验证对话框 -->
|
||||
<VDialog v-model="mfaDialog" max-width="400" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 text-center mt-4">{{ t('login.twoFactorAuth') }}</VCardTitle>
|
||||
<VCardText>
|
||||
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
|
||||
|
||||
<!-- TOTP验证 -->
|
||||
@@ -665,7 +663,7 @@ onUnmounted(() => {
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="passkey-btn"
|
||||
prepend-icon="mdi-key-variant"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="mfaPasskeyLoading"
|
||||
@click="verifyWithPassKey"
|
||||
>
|
||||
@@ -674,6 +672,11 @@ onUnmounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -23,27 +23,10 @@ cleanupOutdatedCaches()
|
||||
// 预缓存并路由
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// 变量记录是否为更新安装(兼容旧版前端监听逻辑)
|
||||
let isUpdate = false
|
||||
|
||||
// 监听安装事件
|
||||
self.addEventListener('install', () => {
|
||||
// 强制等待中的 Service Worker 立即激活
|
||||
self.skipWaiting()
|
||||
|
||||
// 检查是否是更新(兼容旧版前端监听逻辑)
|
||||
if (self.registration.active) {
|
||||
isUpdate = true
|
||||
// 通知客户端发现新版本
|
||||
self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SW_VERSION_DETECTED',
|
||||
version: CACHE_VERSION,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 监听激活事件
|
||||
@@ -52,19 +35,8 @@ self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
await self.clients.claim()
|
||||
|
||||
// 清理旧版本的运行时缓存
|
||||
await cleanupRuntimeCaches(true)
|
||||
|
||||
// 如果是更新,则通知客户端刷新页面(兼容旧版前端监听逻辑)
|
||||
if (isUpdate) {
|
||||
const clients = await self.clients.matchAll({ type: 'window' })
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SW_RELOAD_PAGE',
|
||||
})
|
||||
})
|
||||
}
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -164,10 +136,13 @@ registerRoute(
|
||||
({ url, request }) =>
|
||||
url.pathname.includes('/api/v1/') &&
|
||||
request.method === 'GET' &&
|
||||
!url.pathname.includes('/api/v1/system/message') && // 排除 SSE 长连接
|
||||
!url.pathname.includes('/api/v1/common/message') && // 排除通用消息
|
||||
!url.pathname.includes('/api/v1/message/') && // 排除所有消息类接口
|
||||
!url.pathname.includes('/api/v1/system/global'), // 排除global接口
|
||||
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
|
||||
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
|
||||
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
|
||||
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
||||
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
||||
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
|
||||
!url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据
|
||||
new NetworkFirst({
|
||||
cacheName: `api-cache-${CACHE_VERSION}`,
|
||||
networkTimeoutSeconds: 5,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { VForm } from 'vuetify/lib/components/index.mjs'
|
||||
import api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
import type { User, PassKey } from '@/api/types'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'
|
||||
import PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'
|
||||
|
||||
// 国际化
|
||||
const { t, locale } = useI18n()
|
||||
@@ -35,15 +35,6 @@ const isSaving = ref(false)
|
||||
// 开启双重验证窗口
|
||||
const otpDialog = ref(false)
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
// 当前头像缓存
|
||||
const currentAvatar = ref(avatar1)
|
||||
|
||||
@@ -65,34 +56,12 @@ const accountInfo = ref<User>({
|
||||
nickname: '',
|
||||
})
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// PassKey类型
|
||||
interface PassKey {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
aaguid?: string
|
||||
transports?: string
|
||||
}
|
||||
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// PassKey对话框
|
||||
const passkeyDialog = ref(false)
|
||||
|
||||
// PassKey注册loading
|
||||
const passkeyRegistering = ref(false)
|
||||
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
// PassKey challenge
|
||||
const passkeyChallenge = ref('')
|
||||
|
||||
// 双重验证菜单
|
||||
const mfaMenu = ref(false)
|
||||
|
||||
@@ -103,7 +72,7 @@ const verifyPasswordDialog = ref(false)
|
||||
const verifyPassword = ref('')
|
||||
|
||||
// 验证后的回调
|
||||
const verifyCallback = ref<(() => void) | null>(null)
|
||||
const verifyCallback = ref<((password: string) => void) | null>(null)
|
||||
|
||||
// 验证对话框标题
|
||||
const verifyTitle = ref('')
|
||||
@@ -246,33 +215,15 @@ async function saveAccountInfo() {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (accountInfo.value.is_otp) {
|
||||
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
|
||||
otpDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用OTP,生成新的二维码
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('mfa/otp/generate')
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
otpDialog.value = true
|
||||
} else {
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
// 验证密码载荷接口
|
||||
interface VerifyPasswordPayload {
|
||||
title: string
|
||||
text: string
|
||||
callback: (password: string) => void
|
||||
}
|
||||
|
||||
// 密码验证并执行回调
|
||||
function withPasswordVerification(title: string, text: string, callback: () => void) {
|
||||
function withPasswordVerification(title: string, text: string, callback: (password: string) => void) {
|
||||
verifyTitle.value = title
|
||||
verifyText.value = text
|
||||
verifyCallback.value = callback
|
||||
@@ -280,6 +231,11 @@ function withPasswordVerification(title: string, text: string, callback: () => v
|
||||
verifyPasswordDialog.value = true
|
||||
}
|
||||
|
||||
// 弹窗请求密码验证
|
||||
function onVerifyPassword({ title, text, callback }: VerifyPasswordPayload) {
|
||||
withPasswordVerification(title, text, callback)
|
||||
}
|
||||
|
||||
// 确认密码验证
|
||||
async function confirmVerifyPassword() {
|
||||
if (!verifyPassword.value) {
|
||||
@@ -287,59 +243,11 @@ async function confirmVerifyPassword() {
|
||||
return
|
||||
}
|
||||
if (verifyCallback.value) {
|
||||
verifyCallback.value()
|
||||
verifyCallback.value(verifyPassword.value)
|
||||
}
|
||||
verifyPasswordDialog.value = false
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
async function disableOtp() {
|
||||
if (passkeyList.value.length > 0) {
|
||||
$toast.error(t('profile.otpDisableRestrictedByPasskey'))
|
||||
return
|
||||
}
|
||||
withPasswordVerification(t('profile.disableTwoFactor'), t('profile.confirmToDisableOtp'), async () => {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('mfa/otp/disable', {
|
||||
password: verifyPassword.value,
|
||||
})
|
||||
if (result.success) {
|
||||
accountInfo.value.is_otp = false
|
||||
$toast.success(t('profile.otpDisableSuccess'))
|
||||
otpDialog.value = false
|
||||
} else {
|
||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error(t('profile.otpCodeRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('mfa/otp/verify', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
otpDialog.value = false
|
||||
accountInfo.value.is_otp = true
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
@@ -352,116 +260,6 @@ async function fetchPassKeyList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开PassKey注册对话框
|
||||
async function openPassKeyDialog() {
|
||||
passkeyName.value = ''
|
||||
passkeyDialog.value = true
|
||||
await fetchPassKeyList()
|
||||
}
|
||||
|
||||
// 注册PassKey
|
||||
async function registerPassKey() {
|
||||
if (!passkeyName.value) {
|
||||
$toast.error(t('profile.passkeyNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult: { [key: string]: any } = await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
passkeyRegistering.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const { options, challenge } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyChallenge.value = challenge
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
...publicKeyOptions,
|
||||
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||
user: {
|
||||
...publicKeyOptions.user,
|
||||
id: base64UrlToUint8Array(publicKeyOptions.user.id),
|
||||
},
|
||||
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
if (!credential) {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url((credential as any).rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64Url((credential as any).response.attestationObject),
|
||||
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
|
||||
transports: (credential as any).response.getTransports ? (credential as any).response.getTransports() : [],
|
||||
},
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult: { [key: string]: any } = await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
name: passkeyName.value,
|
||||
})
|
||||
|
||||
if (finishResult.success) {
|
||||
$toast.success(t('profile.passkeyRegisterSuccess'))
|
||||
passkeyName.value = ''
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PassKey注册失败:', error)
|
||||
if (error.name === 'NotAllowedError') {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
} else {
|
||||
$toast.error(t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
}
|
||||
passkeyRegistering.value = false
|
||||
}
|
||||
|
||||
// 删除PassKey
|
||||
async function deletePassKey(passkeyId: number) {
|
||||
withPasswordVerification(t('common.delete') + t('profile.usePasskey'), t('profile.confirmToDeletePasskey'), async () => {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('mfa/passkey/delete', {
|
||||
passkey_id: passkeyId,
|
||||
password: verifyPassword.value,
|
||||
})
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 加载当前用户数据
|
||||
onMounted(() => {
|
||||
fetchUserInfo()
|
||||
@@ -515,11 +313,7 @@ watch(
|
||||
<!-- 双重验证菜单按钮 -->
|
||||
<VMenu v-model="mfaMenu" :close-on-content-click="false">
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
:color="hasMfaEnabled ? 'warning' : 'success'"
|
||||
variant="tonal"
|
||||
v-bind="props"
|
||||
>
|
||||
<VBtn :color="hasMfaEnabled ? 'warning' : 'success'" variant="tonal" v-bind="props">
|
||||
<VIcon icon="mdi-shield-key" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">
|
||||
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}
|
||||
@@ -528,7 +322,14 @@ watch(
|
||||
</VBtn>
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem @click="getOtpUri(); mfaMenu = false">
|
||||
<VListItem
|
||||
@click="
|
||||
() => {
|
||||
otpDialog = true
|
||||
mfaMenu = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cellphone-key" />
|
||||
</template>
|
||||
@@ -537,9 +338,16 @@ watch(
|
||||
{{ t('profile.enabled') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem @click="openPassKeyDialog(); mfaMenu = false">
|
||||
<VListItem
|
||||
@click="
|
||||
() => {
|
||||
passkeyDialog = true
|
||||
mfaMenu = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-key-variant" />
|
||||
<VIcon icon="material-symbols:passkey" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
|
||||
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
|
||||
@@ -691,179 +499,20 @@ watch(
|
||||
</VRow>
|
||||
|
||||
<!-- 双重验证弹窗 -->
|
||||
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable>
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<!-- 如果已启用OTP,显示清除界面 -->
|
||||
<template v-if="accountInfo.is_otp && !qrCode">
|
||||
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.authenticatorManagement') }}</h4>
|
||||
<VAlert type="success" variant="tonal" class="mb-4">
|
||||
{{ t('profile.authenticatorEnabled') }}
|
||||
</VAlert>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.clearAuthenticatorTip') }}
|
||||
</p>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" @click="disableOtp(); otpDialog = false">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
{{ t('profile.clearAuthenticator') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设置新的OTP -->
|
||||
<template v-else>
|
||||
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.setupAuthenticator') }}</h4>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.authenticatorApp') }}</h5>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.authenticatorAppDescription') }}
|
||||
</p>
|
||||
<div class="my-6">
|
||||
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
|
||||
</div>
|
||||
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
:label="t('profile.enterVerificationCode')"
|
||||
class="mb-8"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</template>
|
||||
</VCardText>
|
||||
<VDialogCloseBtn @click="otpDialog = false" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<OTPAuthDialog
|
||||
v-model="otpDialog"
|
||||
v-model:is-otp="accountInfo.is_otp"
|
||||
:passkey-list="passkeyList"
|
||||
@verify-password="onVerifyPassword"
|
||||
/>
|
||||
|
||||
<!-- PassKey管理对话框 -->
|
||||
<VDialog v-if="passkeyDialog" v-model="passkeyDialog" max-width="45rem" scrollable>
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.passkeyManagement') }}</h4>
|
||||
|
||||
<!-- 安全警告 -->
|
||||
<VAlert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-6"
|
||||
icon="mdi-alert"
|
||||
>
|
||||
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
|
||||
<template #domain>
|
||||
<b>{{ t('profile.accessDomain') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 注册新通行密钥 -->
|
||||
<VCard v-if="accountInfo.is_otp" variant="tonal" class="mb-6">
|
||||
<VCardText>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
|
||||
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
|
||||
<VForm @submit.prevent="registerPassKey">
|
||||
<VTextField
|
||||
v-model="passkeyName"
|
||||
:label="t('profile.passkeyName')"
|
||||
:placeholder="t('profile.passkeyNamePlaceholder')"
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-form-textbox"
|
||||
/>
|
||||
<VBtn
|
||||
color="primary"
|
||||
type="submit"
|
||||
:loading="passkeyRegistering"
|
||||
prepend-icon="mdi-plus"
|
||||
>
|
||||
{{ t('profile.registerPasskey') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 未启用 OTP 提示 -->
|
||||
<VAlert
|
||||
v-else
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-6"
|
||||
icon="mdi-shield-lock"
|
||||
>
|
||||
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
|
||||
<template #otp>
|
||||
<b>{{ t('profile.otpAuthenticator') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<VCard variant="tonal">
|
||||
<VCardText>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registeredPasskeys') }}</h5>
|
||||
<VList v-if="passkeyList.length > 0" class="mt-4">
|
||||
<VListItem
|
||||
v-for="passkey in passkeyList"
|
||||
:key="passkey.id"
|
||||
class="mb-2 py-4"
|
||||
rounded="lg"
|
||||
border
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-key-variant" size="32" class="me-4" />
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium">
|
||||
{{ passkey.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
{{ t('profile.createdAt') }}: {{ new Date(passkey.created_at).toLocaleString(locale) }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VBtn
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
color="error"
|
||||
size="small"
|
||||
@click="deletePassKey(passkey.id)"
|
||||
/>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VAlert v-else type="info" variant="tonal" class="mt-4">
|
||||
{{ t('profile.noPasskeys') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<div class="d-flex justify-end mt-6">
|
||||
<VBtn variant="outlined" @click="passkeyDialog = false">{{ t('common.close') }}</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VDialogCloseBtn @click="passkeyDialog = false" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<PasskeyDialog
|
||||
v-model="passkeyDialog"
|
||||
:is-otp="accountInfo.is_otp"
|
||||
v-model:passkey-list="passkeyList"
|
||||
@verify-password="onVerifyPassword"
|
||||
/>
|
||||
|
||||
<!-- 密码验证对话框 -->
|
||||
<VDialog v-model="verifyPasswordDialog" max-width="30rem">
|
||||
|
||||
132
yarn.lock
132
yarn.lock
@@ -2045,6 +2045,13 @@
|
||||
resolved "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz"
|
||||
integrity sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==
|
||||
|
||||
"@types/qrcode@^1.5.6":
|
||||
version "1.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.6.tgz#07c33cb9ec0ad88be4636e636e28e54d99b65f42"
|
||||
integrity sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/resolve@1.20.2":
|
||||
version "1.20.2"
|
||||
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz"
|
||||
@@ -3041,6 +3048,11 @@ camelcase-css@^2.0.1:
|
||||
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
|
||||
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
|
||||
|
||||
camelcase@^5.0.0:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
|
||||
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
||||
|
||||
caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
|
||||
version "1.0.30001761"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz"
|
||||
@@ -3129,6 +3141,15 @@ clean-regexp@^1.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp "^1.0.5"
|
||||
|
||||
cliui@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
|
||||
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
|
||||
dependencies:
|
||||
string-width "^4.2.0"
|
||||
strip-ansi "^6.0.0"
|
||||
wrap-ansi "^6.2.0"
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
|
||||
@@ -3485,6 +3506,11 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
decamelize@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
|
||||
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
|
||||
|
||||
deep-is@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
|
||||
@@ -3568,6 +3594,11 @@ didyoumean@^1.2.2:
|
||||
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
|
||||
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
|
||||
|
||||
dijkstrajs@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
|
||||
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
|
||||
|
||||
dir-glob@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
|
||||
@@ -4477,6 +4508,11 @@ gensync@^1.0.0-beta.2:
|
||||
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
get-caller-file@^2.0.1:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
||||
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
||||
|
||||
get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
|
||||
@@ -6099,6 +6135,11 @@ pluralize@^8.0.0:
|
||||
resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz"
|
||||
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
|
||||
|
||||
pngjs@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
|
||||
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
|
||||
|
||||
possible-typed-array-names@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"
|
||||
@@ -6261,10 +6302,14 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qrcode.vue@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz"
|
||||
integrity sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==
|
||||
qrcode@^1.5.4:
|
||||
version "1.5.4"
|
||||
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88"
|
||||
integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==
|
||||
dependencies:
|
||||
dijkstrajs "^1.0.1"
|
||||
pngjs "^5.0.0"
|
||||
yargs "^15.3.1"
|
||||
|
||||
qs@6.13.0:
|
||||
version "6.13.0"
|
||||
@@ -6470,11 +6515,21 @@ regjsparser@^0.12.0:
|
||||
dependencies:
|
||||
jsesc "~3.0.2"
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
|
||||
|
||||
require-from-string@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz"
|
||||
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
|
||||
|
||||
require-main-filename@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
|
||||
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
|
||||
|
||||
requires-port@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
|
||||
@@ -6676,6 +6731,11 @@ serve-static@1.16.2:
|
||||
parseurl "~1.3.3"
|
||||
send "0.19.0"
|
||||
|
||||
set-blocking@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
|
||||
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
|
||||
|
||||
set-function-length@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz"
|
||||
@@ -6914,16 +6974,7 @@ std-env@^3.9.0:
|
||||
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
|
||||
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -7008,14 +7059,7 @@ stringify-object@^3.3.0:
|
||||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -7995,6 +8039,11 @@ which-collection@^1.0.2:
|
||||
is-weakmap "^2.0.2"
|
||||
is-weakset "^2.0.3"
|
||||
|
||||
which-module@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
|
||||
integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
|
||||
|
||||
which-typed-array@^1.1.16, which-typed-array@^1.1.18:
|
||||
version "1.1.19"
|
||||
resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz"
|
||||
@@ -8194,6 +8243,15 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
||||
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
|
||||
@@ -8221,6 +8279,11 @@ xml-name-validator@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz"
|
||||
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
|
||||
|
||||
y18n@^4.0.0:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
|
||||
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
|
||||
|
||||
yallist@^3.0.2:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz"
|
||||
@@ -8244,6 +8307,31 @@ yaml@^2.0.0, yaml@^2.3.4, yaml@^2.7.0:
|
||||
resolved "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz"
|
||||
integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==
|
||||
|
||||
yargs-parser@^18.1.2:
|
||||
version "18.1.3"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
|
||||
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
|
||||
dependencies:
|
||||
camelcase "^5.0.0"
|
||||
decamelize "^1.2.0"
|
||||
|
||||
yargs@^15.3.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
|
||||
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
|
||||
dependencies:
|
||||
cliui "^6.0.0"
|
||||
decamelize "^1.2.0"
|
||||
find-up "^4.1.0"
|
||||
get-caller-file "^2.0.1"
|
||||
require-directory "^2.1.1"
|
||||
require-main-filename "^2.0.0"
|
||||
set-blocking "^2.0.0"
|
||||
string-width "^4.2.0"
|
||||
which-module "^2.0.0"
|
||||
y18n "^4.0.0"
|
||||
yargs-parser "^18.1.2"
|
||||
|
||||
yauzl@^2.10.0:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user