feat: 优化通行密钥错误提示与代码结构

- feat(login): 优化通行密钥(Passkey)登录逻辑,支持 Conditional UI 自动填充,并改进错误提示。
- feat(userProfile): 优化双重验证弹窗样式。
- feat(qrcode): 优化二维码生成逻辑与显示。
- feat(passkey): 优化通行密钥错误提示,添加最后使用时间显示。
This commit is contained in:
PKC278
2026-01-11 20:02:34 +08:00
parent 1688a2ca25
commit 7e6116de45
12 changed files with 870 additions and 574 deletions
+2 -1
View File
@@ -57,7 +57,7 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.2.0", "pinia-plugin-persistedstate": "^4.2.0",
"qrcode.vue": "^3.6.0", "qrcode": "^1.5.4",
"sass": "^1.83.4", "sass": "^1.83.4",
"tailwindcss": "^ 3.4.17", "tailwindcss": "^ 3.4.17",
"vue": "^3.5.13", "vue": "^3.5.13",
@@ -84,6 +84,7 @@
"@types/mousetrap": "^1.6.15", "@types/mousetrap": "^1.6.15",
"@types/node": "^20.1.4", "@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.6",
"@types/webfontloader": "^1.6.34", "@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0", "@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0", "@typescript-eslint/parser": "^8.20.0",
+17
View File
@@ -861,6 +861,16 @@ export interface User {
nickname?: string nickname?: string
} }
// 通行密钥
export interface PassKey {
id: number
name: string
created_at: string
last_used_at?: string
aaguid?: string
transports?: string
}
// 存储空间 // 存储空间
export interface Storage { export interface Storage {
// 总空间 // 总空间
@@ -1429,3 +1439,10 @@ export interface SubscribeShareStatistics {
// 总复用人次 // 总复用人次
total_reuse_count?: number total_reuse_count?: number
} }
// 通用API响应
export interface ApiResponse<T = any> {
success: boolean
message?: string
data: T
}
+231
View 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
View 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>
+16 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' import api from '@/api'
import QrcodeVue from 'qrcode.vue' import QRCode from 'qrcode'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
@@ -24,6 +24,9 @@ const emit = defineEmits(['done', 'close'])
// 二维码内容 // 二维码内容
const qrCodeContent = ref('') const qrCodeContent = ref('')
// 二维码图片 base64
const qrCodeImage = ref('')
// 下方的提示信息 // 下方的提示信息
const text = ref(t('dialog.u115Auth.scanQrCode')) 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') const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
if (result.success && result.data) { if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent qrCodeContent.value = result.data.codeContent
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.codeContent, {
width: 200,
margin: 1,
})
timeoutTimer = setTimeout(checkQrcode, 3000) timeoutTimer = setTimeout(checkQrcode, 3000)
} else { } else {
text.value = result.message text.value = result.message
@@ -129,7 +137,13 @@ onUnmounted(() => {
<VDivider /> <VDivider />
<VCardText class="pt-2 flex flex-col items-center justify-center"> <VCardText class="pt-2 flex flex-col items-center justify-center">
<div class="mt-6 rounded text-center p-3 border"> <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>
<div> <div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text"> <VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
+12 -10
View File
@@ -244,17 +244,16 @@ export default {
wallpapers: 'Wallpapers', wallpapers: 'Wallpapers',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
otpCode: 'Two-Factor Code', otpCode: 'Verification Code',
stayLoggedIn: 'Stay Logged In', stayLoggedIn: 'Stay Logged In',
login: 'Login', login: 'Login',
networkError: 'Login failed, please check your network connection!', 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!', permissionDenied: 'Login failed, you do not have permission to access!',
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!', noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
serverError: 'Login failed, server error!', serverError: 'Login failed, server error!',
loginFailed: 'Login Failed', loginFailed: 'Login Failed',
checkCredentials: 'Please check your username, password or two-factor authentication code!', secondaryVerification: 'Secondary Verification',
twoFactorAuth: 'Two-Factor Authentication',
loginWithPasskey: 'Login with Passkey', loginWithPasskey: 'Login with Passkey',
loginWithOtp: 'Login with OTP', loginWithOtp: 'Login with OTP',
orUsePasskey: 'Or use Passkey for verification', orUsePasskey: 'Or use Passkey for verification',
@@ -264,7 +263,8 @@ export default {
passkeyNotSelected: 'No Passkey selected', passkeyNotSelected: 'No Passkey selected',
passkeyLoginFailed: 'Passkey login failed', passkeyLoginFailed: 'Passkey login failed',
passkeyAuthCanceled: 'Passkey authentication canceled', 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', passkeyVerifyFailed: 'Passkey verification failed',
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again', passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
mfa: { mfa: {
@@ -2561,6 +2561,7 @@ export default {
noRecentPlugins: 'None', noRecentPlugins: 'None',
}, },
profile: { profile: {
disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',
personalInfo: 'Personal Information', personalInfo: 'Personal Information',
uploadNewAvatar: 'Upload New Avatar', uploadNewAvatar: 'Upload New Avatar',
avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar', avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',
@@ -2600,18 +2601,20 @@ export default {
passkeyManagement: 'Passkey Management', passkeyManagement: 'Passkey Management',
registerNewPasskey: 'Register New Passkey', registerNewPasskey: 'Register New Passkey',
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.', 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', passkeyName: 'Passkey Name',
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello', passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
registerPasskey: 'Register Passkey', registerPasskey: 'Register Passkey',
registeredPasskeys: 'Registered Passkeys', createdAt: 'Created',
createdAt: 'Created At', lastUsedAt: 'Last used',
noPasskeys: 'You have not registered any passkeys yet', noPasskeys: 'You havent registered any passkeys yet',
passkeyNameRequired: 'Please enter a passkey name', passkeyNameRequired: 'Please enter a passkey name',
passkeyRegisterSuccess: 'Passkey registered successfully', passkeyRegisterSuccess: 'Passkey registered successfully',
passkeyRegisterFailed: 'Registration failed', passkeyRegisterFailed: 'Registration failed',
passkeyRegisterCancelled: 'Registration cancelled', passkeyRegisterCancelled: 'Registration cancelled',
passkeyDeleteSuccess: 'Passkey deleted', passkeyDeleteSuccess: 'Passkey deleted',
passkeyDeleteFailed: 'Delete failed', 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.', 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.', 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', accessDomain: 'access domain name',
@@ -2625,9 +2628,8 @@ export default {
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.', 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.', 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.', confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
authenticatorApp: 'Authenticator App',
authenticatorAppDescription: 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: secretKeyTip:
"If you're having trouble with the QR code, select manual entry in your app and enter the code above.", "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', enterVerificationCode: 'Enter verification code to confirm enabling two-factor authentication',
+11 -9
View File
@@ -243,17 +243,16 @@ export default {
wallpapers: '壁纸', wallpapers: '壁纸',
username: '用户名', username: '用户名',
password: '密码', password: '密码',
otpCode: '双重验证码', otpCode: '验证码',
stayLoggedIn: '保持登录', stayLoggedIn: '保持登录',
login: '登录', login: '登录',
networkError: '登录失败,请检查网络连接!', networkError: '登录失败,请检查网络连接!',
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!', authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!',
permissionDenied: '登录失败,您没有权限访问!', permissionDenied: '登录失败,您没有权限访问!',
noPermission: '登录失败,您没有任何功能权限,请联系管理员!', noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
serverError: '登录失败,服务器错误!', serverError: '登录失败,服务器错误!',
loginFailed: '登录失败', loginFailed: '登录失败',
checkCredentials: '请检查用户名、密码或双重验证码是否正确!', secondaryVerification: '二次验证',
twoFactorAuth: '双重验证',
loginWithPasskey: '使用通行密钥登录', loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证码登录', loginWithOtp: '使用验证码登录',
orUsePasskey: '或使用通行密钥进行验证', orUsePasskey: '或使用通行密钥进行验证',
@@ -263,7 +262,8 @@ export default {
passkeyNotSelected: '未选择通行密钥', passkeyNotSelected: '未选择通行密钥',
passkeyLoginFailed: '通行密钥登录失败', passkeyLoginFailed: '通行密钥登录失败',
passkeyAuthCanceled: '通行密钥认证被取消', passkeyAuthCanceled: '通行密钥认证被取消',
passkeyLoginRetry: '通行密钥登录失败,请重试', passkeyNotSupported: '当前浏览器不支持通行密钥',
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
passkeyVerifyFailed: '通行密钥验证失败', passkeyVerifyFailed: '通行密钥验证失败',
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试', passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
mfa: { mfa: {
@@ -2530,6 +2530,7 @@ export default {
noRecentPlugins: '无', noRecentPlugins: '无',
}, },
profile: { profile: {
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
personalInfo: '个人信息', personalInfo: '个人信息',
uploadNewAvatar: '上传新头像', uploadNewAvatar: '上传新头像',
avatarFormatError: '上传的文件不符合要求,请重新选择头像', avatarFormatError: '上传的文件不符合要求,请重新选择头像',
@@ -2569,11 +2570,12 @@ export default {
passkeyManagement: '通行密钥管理', passkeyManagement: '通行密钥管理',
registerNewPasskey: '注册新通行密钥', registerNewPasskey: '注册新通行密钥',
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。', passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
passkeyAppDescription: '通行密钥是比密码更简单、更安全的替代方案。您只需通过指纹、面部识别或屏幕锁定即可登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
passkeyName: '通行密钥名称', passkeyName: '通行密钥名称',
passkeyNamePlaceholder: '例如:iPhone、Windows Hello', passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
registerPasskey: '注册通行密钥', registerPasskey: '注册通行密钥',
registeredPasskeys: '已注册的通行密钥', createdAt: '创建于',
createdAt: '创建时间', lastUsedAt: '最后使用时间',
noPasskeys: '您还没有注册任何通行密钥', noPasskeys: '您还没有注册任何通行密钥',
passkeyNameRequired: '请输入通行密钥名称', passkeyNameRequired: '请输入通行密钥名称',
passkeyRegisterSuccess: '通行密钥注册成功', passkeyRegisterSuccess: '通行密钥注册成功',
@@ -2581,6 +2583,7 @@ export default {
passkeyRegisterCancelled: '注册被取消', passkeyRegisterCancelled: '注册被取消',
passkeyDeleteSuccess: '通行密钥已删除', passkeyDeleteSuccess: '通行密钥已删除',
passkeyDeleteFailed: '删除失败', passkeyDeleteFailed: '删除失败',
deletePasskey: '删除通行密钥',
passkeyDomainWarning: '通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。', passkeyDomainWarning: '通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。', otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名', accessDomain: '访问域名',
@@ -2594,9 +2597,8 @@ export default {
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。', otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。', confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。', confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
authenticatorApp: '身份验证器',
authenticatorAppDescription: authenticatorAppDescription:
'使用Google Authenticator、Microsoft Authenticator、Authy1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。', '使用 Google Authenticator、Microsoft Authenticator、Authy1Password验证器应用扫描二维码,获取 6 位验证码。',
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。', secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
enterVerificationCode: '输入验证码以确认开启双重验证', enterVerificationCode: '输入验证码以确认开启双重验证',
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。', avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
+11 -9
View File
@@ -244,17 +244,16 @@ export default {
wallpapers: '壁紙', wallpapers: '壁紙',
username: '用戶名', username: '用戶名',
password: '密碼', password: '密碼',
otpCode: '雙重驗證碼', otpCode: '驗證碼',
stayLoggedIn: '保持登錄', stayLoggedIn: '保持登錄',
login: '登錄', login: '登錄',
networkError: '登錄失敗,請檢查網絡連接!', networkError: '登錄失敗,請檢查網絡連接!',
authFailure: '登錄失敗,請檢查用戶名、密碼或雙重驗證是否正確!', authFailure: '登錄失敗,請檢查用戶名、密碼或二次驗證是否正確!',
permissionDenied: '登錄失敗,您沒有權限訪問!', permissionDenied: '登錄失敗,您沒有權限訪問!',
serverError: '登錄失敗,服務器錯誤!', serverError: '登錄失敗,服務器錯誤!',
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!', noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
loginFailed: '登錄失敗', loginFailed: '登錄失敗',
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!', secondaryVerification: '二次驗證',
twoFactorAuth: '雙重驗證',
loginWithPasskey: '使用通行密鑰登錄', loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證碼登錄', loginWithOtp: '使用驗證碼登錄',
orUsePasskey: '或使用通行密鑰進行驗證', orUsePasskey: '或使用通行密鑰進行驗證',
@@ -264,7 +263,8 @@ export default {
passkeyNotSelected: '未選擇通行密鑰', passkeyNotSelected: '未選擇通行密鑰',
passkeyLoginFailed: '通行密鑰登錄失敗', passkeyLoginFailed: '通行密鑰登錄失敗',
passkeyAuthCanceled: '通行密鑰驗證被取消', passkeyAuthCanceled: '通行密鑰驗證被取消',
passkeyLoginRetry: '通行密鑰登錄失敗,請重試', passkeyNotSupported: '當前瀏覽器不支援通行密鑰',
passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',
passkeyVerifyFailed: '通行密鑰驗证失敗', passkeyVerifyFailed: '通行密鑰驗证失敗',
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試', passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
mfa: { mfa: {
@@ -2516,6 +2516,7 @@ export default {
noRecentPlugins: '無', noRecentPlugins: '無',
}, },
profile: { profile: {
disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器!',
personalInfo: '個人信息', personalInfo: '個人信息',
uploadNewAvatar: '上傳新頭像', uploadNewAvatar: '上傳新頭像',
avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像', avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像',
@@ -2555,11 +2556,12 @@ export default {
passkeyManagement: '通行密鑰管理', passkeyManagement: '通行密鑰管理',
registerNewPasskey: '註冊新通行密鑰', registerNewPasskey: '註冊新通行密鑰',
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。', passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
passkeyAppDescription: '通行密鑰是比傳統密碼更簡單、更安全的替代方案。您只需透過指紋、臉部辨識或螢幕鎖定即可登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
passkeyName: '通行密鑰名稱', passkeyName: '通行密鑰名稱',
passkeyNamePlaceholder: '例如:iPhone、Windows Hello', passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
registerPasskey: '註冊通行密鑰', registerPasskey: '註冊通行密鑰',
registeredPasskeys: '已註冊的通行密鑰', createdAt: '建立於',
createdAt: '建立時間', lastUsedAt: '最後使用時間',
noPasskeys: '您還沒有註冊任何通行密鑰', noPasskeys: '您還沒有註冊任何通行密鑰',
passkeyNameRequired: '請輸入通行密鑰名稱', passkeyNameRequired: '請輸入通行密鑰名稱',
passkeyRegisterSuccess: '通行密鑰註冊成功', passkeyRegisterSuccess: '通行密鑰註冊成功',
@@ -2567,6 +2569,7 @@ export default {
passkeyRegisterCancelled: '註冊被取消', passkeyRegisterCancelled: '註冊被取消',
passkeyDeleteSuccess: '通行密鑰已刪除', passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyDeleteFailed: '刪除失敗', passkeyDeleteFailed: '刪除失敗',
deletePasskey: '刪除通行密鑰',
passkeyDomainWarning: '通行密鑰(PassKey)的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。', passkeyDomainWarning: '通行密鑰(PassKey)的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。', otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名', accessDomain: '訪問域名',
@@ -2580,9 +2583,8 @@ export default {
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。', otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。', confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。', confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
authenticatorApp: '身份驗證器',
authenticatorAppDescription: authenticatorAppDescription:
'使用Google Authenticator、Microsoft Authenticator、Authy1Password這樣的身份驗證器應用程序,掃描二維碼。它將為您生成一個6位數的代碼,供您在下方輸入。', '使用 Google Authenticator、Microsoft Authenticator、Authy1Password驗證器應用程式掃描 QR Code,取得 6 位數驗證碼。',
secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。', secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。',
enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證', enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證',
avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。', avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
+95 -92
View File
@@ -12,6 +12,7 @@ import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { getNavMenus } from '@/router/i18n-menu' import { getNavMenus } from '@/router/i18n-menu'
import { filterMenusByPermission } from '@/utils/permission' import { filterMenusByPermission } from '@/utils/permission'
import type { ApiResponse } from '@/api/types'
// //
const { t } = useI18n() const { t } = useI18n()
@@ -42,7 +43,7 @@ const errorMessage = ref('')
// //
const isOTP = ref(false) const isOTP = ref(false)
// //
const mfaDialog = ref(false) const mfaDialog = ref(false)
// MFA PassKey loading // MFA PassKey loading
@@ -92,17 +93,11 @@ interface PassKeyAuthOptions {
// PassKey API // PassKey API
interface PassKeyStartResponse { interface PassKeyStartResponse {
success: boolean options: string // JSON
message?: string challenge: string
data: {
options: string // JSON
challenge: string
}
} }
interface PassKeyFinishResponse { interface PassKeyFinishResponse {
success: boolean
message?: string
access_token: string access_token: string
super_user: boolean super_user: boolean
user_id: number user_id: number
@@ -117,10 +112,10 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
const { username, isConditional = false, signal } = options const { username, isConditional = false, signal } = options
// 1. // 1.
const startResponse = (await api.post<PassKeyStartResponse>( const startResponse = (await api.post(
'/mfa/passkey/authenticate/start', '/mfa/passkey/authenticate/start',
username ? { username } : {}, username ? { username } : {},
)) as any as PassKeyStartResponse )) as ApiResponse<PassKeyStartResponse>
if (!startResponse.success) { if (!startResponse.success) {
throw new Error(startResponse.message || 'PassKey start failed') throw new Error(startResponse.message || 'PassKey start failed')
@@ -130,7 +125,7 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
const publicKeyOptions = JSON.parse(optionsStr) const publicKeyOptions = JSON.parse(optionsStr)
// 2. WebAuthn API // 2. WebAuthn API
const credentialRequestOptions: any = { const credentialRequestOptions: CredentialRequestOptions = {
publicKey: { publicKey: {
...publicKeyOptions, ...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge), challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
@@ -176,18 +171,37 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
} }
// 4. // 4.
const finishResponse = (await api.post<PassKeyFinishResponse>('/mfa/passkey/authenticate/finish', { const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON, credential: credentialJSON,
challenge: challenge, challenge: challenge,
})) as any as PassKeyFinishResponse })) as PassKeyFinishResponse
if (!finishResponse || !finishResponse.access_token) {
throw new Error('PassKey finish failed: No access token')
}
return finishResponse return finishResponse
} }
// 使PassKey ( Conditional UI) // PassKey
async function loginWithPassKey(isConditional = false) { async function handlePassKeyAuth(
authOptions: PassKeyAuthOptions,
setLoading: (loading: boolean) => void,
onSuccess: (response: PassKeyFinishResponse) => Promise<void>,
) {
const { isConditional = false } = authOptions
errorMessage.value = '' errorMessage.value = ''
// ()
if (!isConditional && !window.PublicKeyCredential) {
if (!window.isSecureContext) {
errorMessage.value = t('login.passkeySecureContextRequired')
} else {
errorMessage.value = t('login.passkeyNotSupported')
}
return
}
// ( Conditional UI) // ( Conditional UI)
if (!isConditional) { if (!isConditional) {
// Conditional UI // Conditional UI
@@ -206,12 +220,12 @@ async function loginWithPassKey(isConditional = false) {
// loading // loading
isManualPassKeyActive = true isManualPassKeyActive = true
passkeyLoading.value = true setLoading(true)
} }
try { try {
const finishResponse = await authenticateWithPassKey({ const finishResponse = await authenticateWithPassKey({
isConditional, ...authOptions,
signal: signal:
isConditional && conditionalAbortController isConditional && conditionalAbortController
? conditionalAbortController.signal ? conditionalAbortController.signal
@@ -220,10 +234,10 @@ async function loginWithPassKey(isConditional = false) {
: undefined, : undefined,
}) })
await handleLoginSuccess(finishResponse) await onSuccess(finishResponse)
} catch (error: any) { } catch (error: any) {
// Conditional UI // Conditional UI
// 1. passkeyLoading false // 1. loading false
// 2. AbortError // 2. AbortError
if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) { if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) {
console.warn('[PassKey] Conditional UI silenced error:', error) console.warn('[PassKey] Conditional UI silenced error:', error)
@@ -237,19 +251,17 @@ async function loginWithPassKey(isConditional = false) {
} }
// //
if (error.response) { if (error.name === 'NotAllowedError') {
errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed')
} else if (error.name === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled') errorMessage.value = t('login.passkeyAuthCanceled')
} else { } else {
errorMessage.value = t('login.passkeyLoginRetry') errorMessage.value = t('login.authFailure')
} }
} finally { } finally {
// loading // loading
if (!isConditional) { if (!isConditional) {
// //
isManualPassKeyActive = false isManualPassKeyActive = false
passkeyLoading.value = false setLoading(false)
manualAbortController = null manualAbortController = null
} else { } else {
// Conditional UI // 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) { async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale) await setI18nLanguage(locale)
@@ -267,23 +290,6 @@ async function switchLanguage(locale: SupportedLocale) {
langMenu.value = false 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() { async function subscribeForPushNotifications() {
if ('serviceWorker' in navigator && 'PushManager' in window) { if ('serviceWorker' in navigator && 'PushManager' in window) {
@@ -304,7 +310,7 @@ async function subscribeForPushNotifications() {
try { try {
await api.post('/message/webpush/subscribe', subscription) await api.post('/message/webpush/subscribe', subscription)
} catch (e) { } catch (e) {
console.log(e) console.error(e)
} }
} }
} }
@@ -394,26 +400,32 @@ async function login() {
// //
if (!error.response) { if (!error.response) {
errorMessage.value = t('login.networkError') errorMessage.value = t('login.networkError')
} else if (error.response.status === 401) { return
// 401MFA }
// MFA
const mfaRequired = error.response.headers?.['x-mfa-required'] === 'true' switch (error.response.status) {
if (mfaRequired && !form.value.otp_password) { case 401:
// MFA // 401MFA
isOTP.value = true // MFA
mfaDialog.value = true if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {
return // MFA
} isOTP.value = true
// MFAOTP mfaDialog.value = true
errorMessage.value = t('login.authFailure') return
// OTP }
form.value.otp_password = '' // MFAOTP
} else if (error.response.status === 403) { errorMessage.value = t('login.authFailure')
errorMessage.value = t('login.permissionDenied') // OTP
} else if (error.response.status === 500) { form.value.otp_password = ''
errorMessage.value = t('login.serverError') break
} else { case 403:
errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}` 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 { } finally {
loading.value = false loading.value = false
@@ -430,29 +442,15 @@ function loginWithOTP() {
async function verifyWithPassKey() { async function verifyWithPassKey() {
if (!form.value.username) return if (!form.value.username) return
mfaPasskeyLoading.value = true await handlePassKeyAuth(
errorMessage.value = '' { username: form.value.username },
val => (mfaPasskeyLoading.value = val),
try { async response => {
const finishResponse = await authenticateWithPassKey({ // MFA
username: form.value.username, mfaDialog.value = false
}) await handleLoginSuccess(response)
},
// 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
}
} }
// //
@@ -517,7 +515,7 @@ onUnmounted(() => {
<!-- 登录页面容器 --> <!-- 登录页面容器 -->
<div class="relative flex min-h-screen flex-col items-center justify-center"> <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 <VCard
class="auth-card px-7 py-3 w-full h-full" class="auth-card px-7 py-3 w-full h-full"
:class="{ 'glass-effect': !isTransparentTheme }" :class="{ 'glass-effect': !isTransparentTheme }"
@@ -610,7 +608,7 @@ onUnmounted(() => {
variant="tonal" variant="tonal"
color="success" color="success"
class="mt-3 passkey-btn" class="mt-3 passkey-btn"
prepend-icon="mdi-key-variant" prepend-icon="material-symbols:passkey"
:loading="passkeyLoading" :loading="passkeyLoading"
@click="loginWithPassKey(false)" @click="loginWithPassKey(false)"
> >
@@ -626,11 +624,11 @@ onUnmounted(() => {
</VCard> </VCard>
</div> </div>
<!-- MFA双重验证对话框 --> <!-- MFA二次验证对话框 -->
<VDialog v-model="mfaDialog" max-width="400" persistent> <VDialog v-model="mfaDialog" max-width="400" persistent>
<VCard> <VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ t('login.twoFactorAuth') }}</VCardTitle> <VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
<VCardText> <VCardText class="pt-0">
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p> <p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<!-- TOTP验证 --> <!-- TOTP验证 -->
@@ -665,7 +663,7 @@ onUnmounted(() => {
variant="tonal" variant="tonal"
color="success" color="success"
class="passkey-btn" class="passkey-btn"
prepend-icon="mdi-key-variant" prepend-icon="material-symbols:passkey"
:loading="mfaPasskeyLoading" :loading="mfaPasskeyLoading"
@click="verifyWithPassKey" @click="verifyWithPassKey"
> >
@@ -674,6 +672,11 @@ onUnmounted(() => {
</VCardText> </VCardText>
</VCard> </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> <VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
</VCardText> </VCardText>
</VCard> </VCard>
+7 -32
View File
@@ -23,27 +23,10 @@ cleanupOutdatedCaches()
// 预缓存并路由 // 预缓存并路由
precacheAndRoute(self.__WB_MANIFEST) precacheAndRoute(self.__WB_MANIFEST)
// 变量记录是否为更新安装(兼容旧版前端监听逻辑)
let isUpdate = false
// 监听安装事件 // 监听安装事件
self.addEventListener('install', () => { self.addEventListener('install', () => {
// 强制等待中的 Service Worker 立即激活 // 强制等待中的 Service Worker 立即激活
self.skipWaiting() 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( event.waitUntil(
(async () => { (async () => {
await self.clients.claim() await self.clients.claim()
// 清理旧版本的运行时缓存 // 清理旧版本的运行时缓存
await cleanupRuntimeCaches(true) 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, request }) =>
url.pathname.includes('/api/v1/') && url.pathname.includes('/api/v1/') &&
request.method === 'GET' && request.method === 'GET' &&
!url.pathname.includes('/api/v1/system/message') && // 排除 SSE 长连接 !url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
!url.pathname.includes('/api/v1/common/message') && // 排除通用消息 !url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
!url.pathname.includes('/api/v1/message/') && // 排除所有消息类接口 !url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
!url.pathname.includes('/api/v1/system/global'), // 排除global接口 !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({ new NetworkFirst({
cacheName: `api-cache-${CACHE_VERSION}`, cacheName: `api-cache-${CACHE_VERSION}`,
networkTimeoutSeconds: 5, networkTimeoutSeconds: 5,
+46 -397
View File
@@ -1,14 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import QrcodeVue from 'qrcode.vue'
import { VForm } from 'vuetify/lib/components/index.mjs' import { VForm } from 'vuetify/lib/components/index.mjs'
import api from '@/api' 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 avatar1 from '@images/avatars/avatar-1.png'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'
import PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'
// //
const { t, locale } = useI18n() const { t, locale } = useI18n()
@@ -35,15 +35,6 @@ const isSaving = ref(false)
// //
const otpDialog = ref(false) const otpDialog = ref(false)
// otp uri
const otpUri = ref('')
// otp secret
const secret = ref('')
//
const otpPassword = ref('')
// //
const currentAvatar = ref(avatar1) const currentAvatar = ref(avatar1)
@@ -65,34 +56,12 @@ const accountInfo = ref<User>({
nickname: '', nickname: '',
}) })
//
const qrCode = ref('')
// PassKey
interface PassKey {
id: number
name: string
created_at: string
last_used_at?: string
aaguid?: string
transports?: string
}
// PassKey // PassKey
const passkeyList = ref<PassKey[]>([]) const passkeyList = ref<PassKey[]>([])
// PassKey // PassKey
const passkeyDialog = ref(false) const passkeyDialog = ref(false)
// PassKeyloading
const passkeyRegistering = ref(false)
// PassKey
const passkeyName = ref('')
// PassKey challenge
const passkeyChallenge = ref('')
// //
const mfaMenu = ref(false) const mfaMenu = ref(false)
@@ -103,7 +72,7 @@ const verifyPasswordDialog = ref(false)
const verifyPassword = ref('') const verifyPassword = ref('')
// //
const verifyCallback = ref<(() => void) | null>(null) const verifyCallback = ref<((password: string) => void) | null>(null)
// //
const verifyTitle = ref('') const verifyTitle = ref('')
@@ -246,33 +215,15 @@ async function saveAccountInfo() {
isSaving.value = false isSaving.value = false
} }
// Otp Uri //
async function getOtpUri() { interface VerifyPasswordPayload {
// OTP title: string
if (accountInfo.value.is_otp) { text: string
qrCode.value = '' // callback: (password: string) => void
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)
}
} }
// //
function withPasswordVerification(title: string, text: string, callback: () => void) { function withPasswordVerification(title: string, text: string, callback: (password: string) => void) {
verifyTitle.value = title verifyTitle.value = title
verifyText.value = text verifyText.value = text
verifyCallback.value = callback verifyCallback.value = callback
@@ -280,6 +231,11 @@ function withPasswordVerification(title: string, text: string, callback: () => v
verifyPasswordDialog.value = true verifyPasswordDialog.value = true
} }
//
function onVerifyPassword({ title, text, callback }: VerifyPasswordPayload) {
withPasswordVerification(title, text, callback)
}
// //
async function confirmVerifyPassword() { async function confirmVerifyPassword() {
if (!verifyPassword.value) { if (!verifyPassword.value) {
@@ -287,59 +243,11 @@ async function confirmVerifyPassword() {
return return
} }
if (verifyCallback.value) { if (verifyCallback.value) {
verifyCallback.value() verifyCallback.value(verifyPassword.value)
} }
verifyPasswordDialog.value = false 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 // PassKey
async function fetchPassKeyList() { async function fetchPassKeyList() {
try { 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(() => { onMounted(() => {
fetchUserInfo() fetchUserInfo()
@@ -515,11 +313,7 @@ watch(
<!-- 双重验证菜单按钮 --> <!-- 双重验证菜单按钮 -->
<VMenu v-model="mfaMenu" :close-on-content-click="false"> <VMenu v-model="mfaMenu" :close-on-content-click="false">
<template #activator="{ props }"> <template #activator="{ props }">
<VBtn <VBtn :color="hasMfaEnabled ? 'warning' : 'success'" variant="tonal" v-bind="props">
:color="hasMfaEnabled ? 'warning' : 'success'"
variant="tonal"
v-bind="props"
>
<VIcon icon="mdi-shield-key" /> <VIcon icon="mdi-shield-key" />
<span v-if="display.mdAndUp.value" class="ms-2"> <span v-if="display.mdAndUp.value" class="ms-2">
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }} {{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}
@@ -528,7 +322,14 @@ watch(
</VBtn> </VBtn>
</template> </template>
<VList> <VList>
<VListItem @click="getOtpUri(); mfaMenu = false"> <VListItem
@click="
() => {
otpDialog = true
mfaMenu = false
}
"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-cellphone-key" /> <VIcon icon="mdi-cellphone-key" />
</template> </template>
@@ -537,9 +338,16 @@ watch(
{{ t('profile.enabled') }} {{ t('profile.enabled') }}
</VListItemSubtitle> </VListItemSubtitle>
</VListItem> </VListItem>
<VListItem @click="openPassKeyDialog(); mfaMenu = false"> <VListItem
@click="
() => {
passkeyDialog = true
mfaMenu = false
}
"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-key-variant" /> <VIcon icon="material-symbols:passkey" />
</template> </template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle> <VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success"> <VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
@@ -691,179 +499,20 @@ watch(
</VRow> </VRow>
<!-- 双重验证弹窗 --> <!-- 双重验证弹窗 -->
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable> <OTPAuthDialog
<VCard> v-model="otpDialog"
<VCardText> v-model:is-otp="accountInfo.is_otp"
<!-- 如果已启用OTP显示清除界面 --> :passkey-list="passkeyList"
<template v-if="accountInfo.is_otp && !qrCode"> @verify-password="onVerifyPassword"
<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>
<!-- PassKey管理对话框 --> <!-- PassKey管理对话框 -->
<VDialog v-if="passkeyDialog" v-model="passkeyDialog" max-width="45rem" scrollable> <PasskeyDialog
<VCard> v-model="passkeyDialog"
<VCardText> :is-otp="accountInfo.is_otp"
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.passkeyManagement') }}</h4> v-model:passkey-list="passkeyList"
@verify-password="onVerifyPassword"
<!-- 安全警告 --> />
<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>
<!-- 密码验证对话框 --> <!-- 密码验证对话框 -->
<VDialog v-model="verifyPasswordDialog" max-width="30rem"> <VDialog v-model="verifyPasswordDialog" max-width="30rem">
+110 -22
View File
@@ -2045,6 +2045,13 @@
resolved "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz" resolved "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz"
integrity sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA== 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": "@types/resolve@1.20.2":
version "1.20.2" version "1.20.2"
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz" 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" resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== 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: caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
version "1.0.30001761" version "1.0.30001761"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz"
@@ -3129,6 +3141,15 @@ clean-regexp@^1.0.0:
dependencies: dependencies:
escape-string-regexp "^1.0.5" 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: color-convert@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" 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: dependencies:
ms "^2.1.3" 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: deep-is@^0.1.3:
version "0.1.4" version "0.1.4"
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" 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" resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== 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: dir-glob@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" 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" resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== 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: 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" version "1.3.0"
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" 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" resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz"
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== 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: possible-typed-array-names@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz" 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" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qrcode.vue@^3.6.0: qrcode@^1.5.4:
version "3.6.0" version "1.5.4"
resolved "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz" resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88"
integrity sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg== integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==
dependencies:
dijkstrajs "^1.0.1"
pngjs "^5.0.0"
yargs "^15.3.1"
qs@6.13.0: qs@6.13.0:
version "6.13.0" version "6.13.0"
@@ -6470,11 +6515,21 @@ regjsparser@^0.12.0:
dependencies: dependencies:
jsesc "~3.0.2" 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: require-from-string@^2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" 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== 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: requires-port@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" 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" parseurl "~1.3.3"
send "0.19.0" 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: set-function-length@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" 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" resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
"string-width-cjs@npm:string-width@^4.2.0": "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==
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:
version "4.2.3" version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -7008,14 +7059,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1" is-obj "^1.0.1"
is-regexp "^1.0.0" is-regexp "^1.0.0"
"strip-ansi-cjs@npm: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==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -7995,6 +8039,11 @@ which-collection@^1.0.2:
is-weakmap "^2.0.2" is-weakmap "^2.0.2"
is-weakset "^2.0.3" 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: which-typed-array@^1.1.16, which-typed-array@^1.1.18:
version "1.1.19" version "1.1.19"
resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz" 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" string-width "^4.1.0"
strip-ansi "^6.0.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: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" 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" resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz"
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== 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: yallist@^3.0.2:
version "3.1.1" version "3.1.1"
resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz" 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" resolved "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz"
integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ== 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: yauzl@^2.10.0:
version "2.10.0" version "2.10.0"
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"