fix(user): harden profile and MFA workflows (#666)

This commit is contained in:
InfinityPacer
2026-08-11 23:17:58 +08:00
committed by GitHub
parent 6c384bf1ee
commit df4d697845
9 changed files with 951 additions and 57 deletions

View File

@@ -230,11 +230,6 @@
"count": 4
}
},
"src/components/dialog/PasskeyDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/RcloneConfigDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -1010,14 +1005,6 @@
"count": 4
}
},
"src/views/user/UserProfileView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/views/workflow/WorkflowShareView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1

View File

@@ -46,6 +46,9 @@ const qrCodeImage = ref('')
// 二维码信息
const qrCode = ref('')
// 每次生成请求独占一个序号,关闭、重开或重试都会使旧请求失效。
let otpGeneration = 0
// 清空当前 OTP 设置流程的临时数据。
function resetOtpSetupState() {
qrCodeImage.value = ''
@@ -64,6 +67,7 @@ function setOtpGenerateError(message?: string) {
// 为当前用户获取 OTP URI 并生成二维码图片。
async function getOtpUri() {
const generation = ++otpGeneration
resetOtpSetupState()
// 如果已经启用OTP只打开对话框不生成新的二维码
if (props.isOtp) {
@@ -80,23 +84,26 @@ async function getOtpUri() {
const uri = result.data?.uri?.trim()
const otpSecret = result.data?.secret?.trim()
if (result.success && uri) {
otpUri.value = uri
secret.value = otpSecret || ''
qrCode.value = uri
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(uri, {
if (result.success && uri && otpSecret) {
const image = await QRCode.toDataURL(uri, {
width: 200,
margin: 1,
})
if (generation !== otpGeneration || !props.modelValue) return
otpUri.value = uri
secret.value = otpSecret
qrCode.value = uri
qrCodeImage.value = image
} else {
if (generation !== otpGeneration || !props.modelValue) return
setOtpGenerateError(result.message || 'empty otp uri')
}
} catch (error) {
if (generation !== otpGeneration || !props.modelValue) return
console.error(error)
setOtpGenerateError(error instanceof Error ? error.message : String(error))
} finally {
otpLoading.value = false
if (generation === otpGeneration) otpLoading.value = false
}
}
@@ -159,6 +166,7 @@ watch(
otpPassword.value = ''
} else {
// 弹窗关闭时,清空数据
otpGeneration += 1
resetOtpSetupState()
otpLoading.value = false
otpPassword.value = ''
@@ -166,6 +174,11 @@ watch(
},
{ immediate: true },
)
// 组件卸载代表 OTP 会话结束,迟到的生成结果不得再产生界面副作用。
onUnmounted(() => {
otpGeneration += 1
})
</script>
<template>

View File

@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
import { formatDateDifference } from '@core/utils/formatters'
import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types'
import { isAxiosError } from 'axios'
interface Props {
modelValue: boolean
@@ -35,13 +36,27 @@ const show = computed({
// PassKey列表
const passkeyList = ref<PassKey[]>([])
// 列表请求状态用于区分加载失败和合法空列表。
const passkeyListLoading = ref(false)
const passkeyListFailed = ref(false)
// PassKey注册loading
const passkeyRegistering = ref(false)
// PassKey名称
const passkeyName = ref('')
const passkeyTransactionToken = ref('')
let passkeyListGeneration = 0
let passkeyRegistrationGeneration = 0
let passkeyRegistrationAbortController: AbortController | null = null
// 注册尝试只属于当前对话框会话,结束会话或开始新尝试时取消整条 WebAuthn 链路。
function invalidatePasskeyRegistration() {
passkeyRegistrationGeneration += 1
passkeyRegistrationAbortController?.abort()
passkeyRegistrationAbortController = null
passkeyRegistering.value = false
}
// 格式化日期
function formatDate(dateStr: string) {
@@ -50,14 +65,24 @@ function formatDate(dateStr: string) {
// 获取PassKey列表
async function fetchPassKeyList() {
const generation = ++passkeyListGeneration
passkeyListLoading.value = true
passkeyListFailed.value = false
try {
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
if (generation !== passkeyListGeneration || !props.modelValue) return
if (result.success) {
passkeyList.value = result.data || []
emit('update:passkeyList', passkeyList.value)
} else {
passkeyListFailed.value = true
}
} catch (error) {
if (generation !== passkeyListGeneration || !props.modelValue) return
passkeyListFailed.value = true
console.error(error)
} finally {
if (generation === passkeyListGeneration) passkeyListLoading.value = false
}
}
@@ -78,12 +103,25 @@ async function registerPassKey() {
return
}
invalidatePasskeyRegistration()
const generation = passkeyRegistrationGeneration
const registrationAbortController = new AbortController()
passkeyRegistrationAbortController = registrationAbortController
const registrationName = passkeyName.value
passkeyRegistering.value = true
try {
// 1. 开始注册
const startResult = (await api.post('mfa/passkey/register/start', {
name: passkeyName.value,
})) as ApiResponse<{ options: string; transaction_token: string }>
const startResult = (await api.post(
'mfa/passkey/register/start',
{
name: registrationName,
},
{
signal: registrationAbortController.signal,
},
)) as ApiResponse<{ options: string; transaction_token: string }>
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
@@ -92,7 +130,6 @@ async function registerPassKey() {
const { options, transaction_token: transactionToken } = startResult.data
const publicKeyOptions = JSON.parse(options)
passkeyTransactionToken.value = transactionToken
// 2. 调用WebAuthn API
const credential = (await navigator.credentials.create({
@@ -108,8 +145,11 @@ async function registerPassKey() {
id: base64UrlToUint8Array(cred.id),
})),
},
signal: registrationAbortController.signal,
})) as PublicKeyCredential
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
if (!credential) {
$toast.error(t('profile.passkeyRegisterCancelled'))
return
@@ -129,11 +169,19 @@ async function registerPassKey() {
}
// 4. 完成注册
const finishResult = (await api.post('mfa/passkey/register/finish', {
credential: credentialJSON,
transaction_token: passkeyTransactionToken.value,
name: passkeyName.value,
})) as ApiResponse
const finishResult = (await api.post(
'mfa/passkey/register/finish',
{
credential: credentialJSON,
transaction_token: transactionToken,
name: registrationName,
},
{
signal: registrationAbortController.signal,
},
)) as ApiResponse
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
if (finishResult.success) {
$toast.success(t('profile.passkeyRegisterSuccess'))
@@ -142,21 +190,25 @@ async function registerPassKey() {
} else {
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
}
} catch (error: any) {
} catch (error) {
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
console.error('PassKey注册失败:', error)
if (error.name === 'NotAllowedError') {
if (error instanceof Error && error.name === 'NotAllowedError') {
$toast.error(t('profile.passkeyRegisterCancelled'))
} else if (error.name === 'NotSupportedError') {
} else if (error instanceof Error && error.name === 'NotSupportedError') {
$toast.error(t('login.passkeyNotSupported'))
} else if (error.message?.includes('start failed')) {
} else if (error instanceof Error && error.message.includes('start failed')) {
$toast.error(t('login.passkeyLoginStartFailed'))
} else if (error.response) {
} else if (isAxiosError(error) && error.response) {
$toast.error(error.response.data?.message || error.response.data?.detail || t('profile.passkeyRegisterFailed'))
} else {
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
$toast.error(error instanceof Error ? error.message : t('profile.passkeyRegisterFailed'))
}
} finally {
passkeyRegistering.value = false
if (generation === passkeyRegistrationGeneration) passkeyRegistering.value = false
if (passkeyRegistrationAbortController === registrationAbortController) {
passkeyRegistrationAbortController = null
}
}
}
@@ -194,13 +246,21 @@ watch(
passkeyName.value = ''
} else {
// 弹窗关闭时,清空数据
passkeyListGeneration += 1
invalidatePasskeyRegistration()
passkeyName.value = ''
passkeyTransactionToken.value = ''
passkeyList.value = []
passkeyListLoading.value = false
passkeyListFailed.value = false
}
},
{ immediate: true },
)
onUnmounted(() => {
passkeyListGeneration += 1
invalidatePasskeyRegistration()
})
</script>
<template>
@@ -250,7 +310,21 @@ watch(
</VCard>
<!-- 已注册的通行密钥列表 -->
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
<LoadingBanner v-if="passkeyListLoading" class="mt-6" />
<VAlert
v-else-if="passkeyListFailed"
type="error"
variant="tonal"
:title="t('common.serverConnectionFailed')"
class="mt-6"
>
<template #append>
<VBtn color="error" variant="text" :loading="passkeyListLoading" @click="fetchPassKeyList">
{{ t('common.retry') }}
</VBtn>
</template>
</VAlert>
<div v-else-if="passkeyList.length > 0" class="mt-6 px-4">
<div
v-for="passkey in passkeyList"
:key="passkey.id"

View File

@@ -0,0 +1,186 @@
import OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiPost: vi.fn(),
qrCode: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({ default: { post: (...args: unknown[]) => mocks.apiPost(...args) } }))
vi.mock('qrcode', () => ({ default: { toDataURL: (...args: unknown[]) => mocks.qrCode(...args) } }))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseBtn = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { 'aria-label': '关闭', onClick: () => emit('click'), type: 'button' })
},
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise
})
return { promise, resolve }
}
async function renderDialog(isOtp = false) {
return renderWithProviders(OTPAuthDialog, {
props: { isOtp, modelValue: true },
global: { stubs: { VDialogCloseBtn: DialogCloseBtn } },
})
}
describe('OTPAuthDialog', () => {
beforeEach(() => {
mocks.apiPost.mockReset()
mocks.qrCode.mockReset()
mocks.qrCode.mockResolvedValue('data:image/png;base64,otp')
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('generates a fresh URI, secret, and QR code when an inactive session opens', async () => {
mocks.apiPost.mockResolvedValue({
data: { secret: 'SECRET-ONE', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET-ONE' },
success: true,
})
await renderDialog()
expect(await screen.findByText('SECRET-ONE')).toBeInTheDocument()
expect(screen.getByRole('img', { name: '设置二次验证' })).toHaveAttribute('src', 'data:image/png;base64,otp')
expect(mocks.qrCode).toHaveBeenCalledWith('otpauth://totp/MoviePilot:alice?secret=SECRET-ONE', {
margin: 1,
width: 200,
})
})
it.each([
['URI', { data: { secret: 'SECRET', uri: '' }, success: true }],
['secret', { data: { secret: '', uri: 'otpauth://totp/MoviePilot:alice' }, success: true }],
])('treats a missing %s as a retryable generation failure', async (_field, response) => {
mocks.apiPost.mockResolvedValue(response)
await renderDialog()
expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument()
expect(screen.queryByRole('img', { name: '设置二次验证' })).not.toBeInTheDocument()
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('获取身份验证器设置失败'))
})
it('retries after a generation HTTP failure', async () => {
mocks.apiPost.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({
data: { secret: 'SECRET-TWO', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET-TWO' },
success: true,
})
await renderDialog()
await fireEvent.click(await screen.findByRole('button', { name: '重试' }))
expect(await screen.findByText('SECRET-TWO')).toBeInTheDocument()
expect(mocks.apiPost).toHaveBeenCalledTimes(2)
})
it('enables OTP only after a successful verification', async () => {
mocks.apiPost
.mockResolvedValueOnce({
data: { secret: 'SECRET', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET' },
success: true,
})
.mockResolvedValueOnce({ success: false, message: '验证码错误' })
.mockResolvedValueOnce({ success: true })
const { emitted } = await renderDialog()
await screen.findByText('SECRET')
await fireEvent.click(screen.getByRole('button', { name: '确认' }))
expect(mocks.toastError).toHaveBeenCalledWith('请填写6位验证码')
await fireEvent.update(screen.getByLabelText('输入身份验证器生成的 6 位验证码'), '123456')
await fireEvent.click(screen.getByRole('button', { name: '确认' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('启用二次验证失败:验证码错误'))
expect(emitted()['update:isOtp']).toBeUndefined()
await fireEvent.click(screen.getByRole('button', { name: '确认' }))
await waitFor(() => expect(emitted()['update:isOtp']).toEqual([[true]]))
expect(mocks.apiPost).toHaveBeenLastCalledWith('mfa/otp/verify', {
otpPassword: '123456',
uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET',
})
})
it('disables OTP through password confirmation only after backend success', async () => {
mocks.apiPost
.mockResolvedValueOnce({ message: '密码错误', success: false })
.mockResolvedValueOnce({ success: true })
const { emitted } = await renderDialog(true)
await fireEvent.click(screen.getByRole('button', { name: '关闭二次验证' }))
const request = (emitted().verifyPassword as unknown[][] | undefined)?.[0]?.[0] as {
callback: (password: string) => Promise<void>
}
expect(request).toMatchObject({ text: '关闭二次验证前需要验证登录密码。', title: '关闭二次验证' })
await request.callback('wrong')
expect(mocks.toastError).toHaveBeenCalledWith('关闭二次验证失败:密码错误')
expect(emitted()['update:isOtp']).toBeUndefined()
await request.callback('correct')
await waitFor(() => expect(emitted()['update:isOtp']).toEqual([[false]]))
expect(mocks.apiPost).toHaveBeenLastCalledWith('mfa/otp/disable', { password: 'correct' })
})
it('ignores a stale generation response after close and reopen', async () => {
const oldRequest = deferred<{ data: { secret: string; uri: string }; success: boolean }>()
const newRequest = deferred<{ data: { secret: string; uri: string }; success: boolean }>()
mocks.apiPost.mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise)
const result = await renderDialog()
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledOnce())
await result.rerender({ isOtp: false, modelValue: false })
await result.rerender({ isOtp: false, modelValue: true })
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
newRequest.resolve({ data: { secret: 'NEW', uri: 'otpauth://new' }, success: true })
expect(await screen.findByText('NEW')).toBeInTheDocument()
oldRequest.resolve({ data: { secret: 'OLD', uri: 'otpauth://old' }, success: true })
await Promise.resolve()
await Promise.resolve()
await waitFor(() => expect(screen.getByText('NEW')).toBeInTheDocument())
expect(screen.queryByText('OLD')).not.toBeInTheDocument()
})
it('ignores a pending generation failure after the shared dialog unmounts', async () => {
const request = deferred<{ message: string; success: boolean }>()
mocks.apiPost.mockReturnValue(request.promise)
const { unmount } = await renderDialog()
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledOnce())
unmount()
request.resolve({ message: 'late failure', success: false })
await Promise.resolve()
await Promise.resolve()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('emits close when the user closes the dialog', async () => {
mocks.apiPost.mockResolvedValue({
data: { secret: 'SECRET', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET' },
success: true,
})
const { emitted } = await renderDialog()
await screen.findByText('SECRET')
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
await fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(emitted()['update:modelValue']).toEqual([[false], [false]])
})
})

View File

@@ -0,0 +1,274 @@
import PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createPassKey } from '@tests/support/factories/user'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
credentialCreate: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseBtn = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { 'aria-label': '关闭', onClick: () => emit('click'), type: 'button' })
},
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise
})
return { promise, resolve }
}
function registrationCredential(id: string): PublicKeyCredential {
const bytes = new Uint8Array([1, 2, 3]).buffer
return {
id,
rawId: bytes,
response: {
attestationObject: bytes,
clientDataJSON: bytes,
getTransports: () => ['internal'],
},
type: 'public-key',
} as unknown as PublicKeyCredential
}
function startResponse(token: string) {
return {
data: {
options: JSON.stringify({ challenge: 'AQID', user: { id: 'BAUG', name: 'alice' } }),
transaction_token: token,
},
success: true,
}
}
async function renderDialog() {
return renderWithProviders(PasskeyDialog, {
props: { modelValue: true },
global: { stubs: { VDialogCloseBtn: DialogCloseBtn } },
})
}
describe('PasskeyDialog', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.credentialCreate.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.apiGet.mockResolvedValue({ data: [], success: true })
Object.defineProperty(window, 'PublicKeyCredential', { configurable: true, value: class PublicKeyCredential {} })
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true })
Object.defineProperty(navigator, 'credentials', {
configurable: true,
value: { create: (...args: unknown[]) => mocks.credentialCreate(...args) },
})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('loads the current list and emits it to the profile', async () => {
mocks.apiGet.mockResolvedValue({ data: [createPassKey()], success: true })
const { emitted } = await renderDialog()
expect(await screen.findByText('MacBook Touch ID')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('mfa/passkey/list')
expect(emitted()['update:passkeyList']).toEqual([[[createPassKey()]]])
})
it('distinguishes a list HTTP failure from an empty list and retries', async () => {
mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({
data: [createPassKey({ name: 'Recovered Key' })],
success: true,
})
await renderDialog()
expect(await screen.findByText('服务器连接失败')).toBeInTheDocument()
expect(screen.queryByText('您还没有注册任何通行密钥')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('Recovered Key')).toBeInTheDocument()
})
it('validates name and browser capability before starting registration', async () => {
await renderDialog()
await screen.findByText('您还没有注册任何通行密钥')
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
expect(mocks.toastError).toHaveBeenCalledWith('请输入通行密钥名称')
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
Object.defineProperty(window, 'PublicKeyCredential', { configurable: true, value: undefined })
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false })
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
expect(mocks.toastError).toHaveBeenCalledWith('通行密钥需要 HTTPS 安全连接')
expect(mocks.apiPost).not.toHaveBeenCalled()
})
it('registers a credential with the transaction from the same attempt and refreshes the list', async () => {
mocks.apiPost.mockResolvedValueOnce(startResponse('tx-one')).mockResolvedValueOnce({ success: true })
mocks.credentialCreate.mockResolvedValue(registrationCredential('credential-one'))
mocks.apiGet
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: [createPassKey({ name: 'Laptop' })], success: true })
await renderDialog()
await screen.findByText('您还没有注册任何通行密钥')
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
expect(mocks.apiPost).toHaveBeenNthCalledWith(
2,
'mfa/passkey/register/finish',
expect.objectContaining({
credential: expect.objectContaining({ id: 'credential-one', type: 'public-key' }),
name: 'Laptop',
transaction_token: 'tx-one',
}),
expect.objectContaining({ signal: expect.anything() }),
)
expect(await screen.findByText('Laptop')).toBeInTheDocument()
expect(mocks.toastSuccess).toHaveBeenCalledWith('通行密钥注册成功')
})
it('does not finish registration after the credential chooser is cancelled', async () => {
mocks.apiPost.mockResolvedValueOnce(startResponse('tx-one'))
mocks.credentialCreate.mockResolvedValue(null)
await renderDialog()
await screen.findByText('您还没有注册任何通行密钥')
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('注册被取消'))
expect(mocks.apiPost).toHaveBeenCalledOnce()
})
it('cancels WebAuthn and does not finish registration after the shared dialog unmounts', async () => {
const credentialRequest = deferred<PublicKeyCredential>()
mocks.apiPost.mockResolvedValueOnce(startResponse('tx-one'))
mocks.credentialCreate.mockReturnValue(credentialRequest.promise)
const { unmount } = await renderDialog()
await screen.findByText('您还没有注册任何通行密钥')
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
await waitFor(() => expect(mocks.credentialCreate).toHaveBeenCalledOnce())
const credentialOptions = mocks.credentialCreate.mock.calls[0][0] as CredentialCreationOptions
unmount()
expect(credentialOptions.signal?.aborted).toBe(true)
credentialRequest.resolve(registrationCredential('late-credential'))
await flushPromises()
expect(mocks.apiPost).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
it('deletes only after password confirmation and refreshes only after success', async () => {
mocks.apiGet.mockResolvedValue({ data: [createPassKey()], success: true })
mocks.apiPost
.mockResolvedValueOnce({ message: '密码错误', success: false })
.mockResolvedValueOnce({ success: true })
const { emitted } = await renderDialog()
await screen.findByText('MacBook Touch ID')
await fireEvent.click(screen.getByRole('button', { name: '' }))
const request = (emitted().verifyPassword as unknown[][] | undefined)?.[0]?.[0] as {
callback: (password: string) => Promise<void>
}
await request.callback('wrong')
expect(mocks.toastError).toHaveBeenCalledWith('密码错误')
expect(mocks.apiGet).toHaveBeenCalledOnce()
await request.callback('correct')
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
expect(mocks.apiPost).toHaveBeenLastCalledWith('mfa/passkey/delete', { passkey_id: 11, password: 'correct' })
})
it('ignores an old list response after close and reopen', async () => {
const oldRequest = deferred<{ data: ReturnType<typeof createPassKey>[]; success: boolean }>()
const newRequest = deferred<{ data: ReturnType<typeof createPassKey>[]; success: boolean }>()
mocks.apiGet.mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise)
const result = await renderDialog()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
await result.rerender({ modelValue: false })
await result.rerender({ modelValue: true })
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
newRequest.resolve({ data: [createPassKey({ name: 'New Session Key' })], success: true })
expect(await screen.findByText('New Session Key')).toBeInTheDocument()
oldRequest.resolve({ data: [createPassKey({ name: 'Old Session Key' })], success: true })
await Promise.resolve()
await Promise.resolve()
await waitFor(() => expect(screen.getByText('New Session Key')).toBeInTheDocument())
expect(screen.queryByText('Old Session Key')).not.toBeInTheDocument()
})
it('keeps each fast registration attempt bound to its own transaction', async () => {
const oldCredential = deferred<PublicKeyCredential>()
mocks.credentialCreate
.mockReturnValueOnce(oldCredential.promise)
.mockResolvedValueOnce(registrationCredential('new'))
mocks.apiPost.mockImplementation((url: string) => {
if (url === 'mfa/passkey/register/start') {
const starts = mocks.apiPost.mock.calls.filter(call => call[0] === url).length
return Promise.resolve(startResponse(starts === 1 ? 'tx-old' : 'tx-new'))
}
if (url === 'mfa/passkey/register/finish') return Promise.resolve({ success: true })
return Promise.reject(new Error(`unexpected POST ${url}`))
})
const result = await renderDialog()
await screen.findByText('您还没有注册任何通行密钥')
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Old')
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
await waitFor(() => expect(mocks.credentialCreate).toHaveBeenCalledOnce())
await result.rerender({ modelValue: false })
await result.rerender({ modelValue: true })
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'New')
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
await waitFor(() =>
expect(mocks.apiPost.mock.calls.filter(call => call[0] === 'mfa/passkey/register/finish')).toHaveLength(1),
)
oldCredential.resolve(registrationCredential('old'))
await flushPromises()
expect(mocks.apiPost.mock.calls.filter(call => call[0] === 'mfa/passkey/register/finish')).toHaveLength(1)
expect(mocks.apiPost.mock.calls.find(call => call[0] === 'mfa/passkey/register/finish')?.[1]).toMatchObject({
credential: expect.objectContaining({ id: 'new' }),
transaction_token: 'tx-new',
})
})
it('emits close when the user closes the dialog', async () => {
const { emitted } = await renderDialog()
await screen.findByText('您还没有注册任何通行密钥')
await fireEvent.click(screen.getAllByRole('button', { name: '关闭' })[0])
expect(emitted()['update:modelValue']).toEqual([[false]])
})
})

View File

@@ -8,13 +8,14 @@ import { useDisplay } from 'vuetify'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import type { ApiResponse } from '@/api/types'
const OTPAuthDialog = defineAsyncComponent(() => import('@/components/dialog/OTPAuthDialog.vue'))
const PasskeyDialog = defineAsyncComponent(() => import('@/components/dialog/PasskeyDialog.vue'))
const VerifyPasswordDialog = defineAsyncComponent(() => import('@/components/dialog/VerifyPasswordDialog.vue'))
// 国际化
const { t, locale } = useI18n()
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -35,6 +36,10 @@ const refInputEl = ref<HTMLElement>()
// 正在保存
const isSaving = ref(false)
// 个人资料首次加载状态,失败时不展示未初始化的默认表单。
const isProfileLoading = ref(true)
const profileLoadFailed = ref(false)
// 当前头像缓存
const currentAvatar = ref(avatar1)
@@ -189,6 +194,8 @@ function restoreCurrentAvatar() {
// 加载当前用户信息
async function fetchUserInfo() {
isProfileLoading.value = true
profileLoadFailed.value = false
try {
const result: User = await api.get(`user/${userStore.userName}`)
if (result) {
@@ -197,11 +204,14 @@ async function fetchUserInfo() {
accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''
currentUserName.value = accountInfo.value.name
currentAvatar.value = accountInfo.value.avatar
// 同时加载PassKey列表
await fetchPassKeyList()
// Passkey 数量是辅助信息,不阻塞已成功取得的个人资料。
void fetchPassKeyList()
}
} catch (error) {
profileLoadFailed.value = true
console.log(error)
} finally {
isProfileLoading.value = false
}
}
@@ -220,7 +230,6 @@ async function saveAccountInfo() {
$toast.error(t('profile.passwordMismatch'))
return
}
accountInfo.value.password = newPassword.value
}
// 将nickname保存到settings中后端可以直接处理JSON对象
@@ -231,16 +240,21 @@ async function saveAccountInfo() {
const oldUserName = accountInfo.value.name
const oldAvatar = accountInfo.value.avatar
accountInfo.value.avatar = currentAvatar.value
accountInfo.value.name = currentUserName.value
isSaving.value = true
try {
// 创建一个临时对象来保存用户数据,确保所有字段都会发送
const userData = { ...accountInfo.value }
// 请求数据独立于已确认快照,失败后保留当前输入供用户重试。
const userData: User = {
...accountInfo.value,
avatar: currentAvatar.value,
name: currentUserName.value,
...(newPassword.value ? { password: newPassword.value } : {}),
}
const result: { [key: string]: any } = await api.put('user/', userData)
const result = (await api.put('user/', userData)) as ApiResponse
if (result.success) {
accountInfo.value.name = currentUserName.value
accountInfo.value.avatar = currentAvatar.value
if (oldUserName !== currentUserName.value) {
$toast.success(t('profile.usernameChangeSuccess', { oldName: oldUserName, newName: currentUserName.value }))
// 更新本地用户名显示
@@ -264,16 +278,13 @@ async function saveAccountInfo() {
} else {
$toast.error(t('profile.saveFailed', { message: result.message }))
}
// 失败缓存值还原
currentUserName.value = accountInfo.value.name
accountInfo.value.name = oldUserName
currentAvatar.value = accountInfo.value.avatar
accountInfo.value.avatar = oldAvatar
}
} catch (error) {
console.log('保存失败:', error)
$toast.error(t('profile.saveFailed', { message: t('common.serverConnectionFailed') }))
} finally {
isSaving.value = false
}
isSaving.value = false
}
// 验证密码载荷接口
@@ -313,7 +324,7 @@ async function confirmVerifyPassword(password = verifyPassword.value) {
// 获取PassKey列表
async function fetchPassKeyList() {
try {
const result: { [key: string]: any } = await api.get('mfa/passkey/list')
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
if (result.success) {
passkeyList.value = result.data || []
}
@@ -338,7 +349,15 @@ watch(
<template>
<div>
<VRow>
<LoadingBanner v-if="isProfileLoading" class="mt-12" />
<VAlert v-else-if="profileLoadFailed" type="error" variant="tonal" :title="t('common.serverConnectionFailed')">
<template #append>
<VBtn color="error" variant="text" :loading="isProfileLoading" @click="fetchUserInfo">
{{ t('common.retry') }}
</VBtn>
</template>
</VAlert>
<VRow v-else>
<VCol cols="12">
<VCard :title="t('profile.personalInfo')">
<VCardText class="flex">

View File

@@ -0,0 +1,309 @@
import UserProfileView from '@/views/user/UserProfileView.vue'
import type { PassKey } from '@/api/types'
import { useUserStore } from '@/stores'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createPassKey, createUser } from '@tests/support/factories/user'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPut: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
put: (...args: unknown[]) => mocks.apiPut(...args),
},
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
function currentUser() {
return createUser({
avatar: 'saved-avatar.png',
email: 'alice@example.com',
is_otp: false,
settings: { nickname: 'Alice' },
})
}
function mockSuccessfulLoad() {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'user/alice') return Promise.resolve(currentUser())
if (url === 'mfa/passkey/list') return Promise.resolve({ data: [createPassKey()], success: true })
return Promise.reject(new Error(`unexpected GET ${url}`))
})
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise
})
return { promise, resolve }
}
async function renderProfile() {
return renderWithProviders(UserProfileView, {
initialState: {
user: {
avatar: 'store-avatar.png',
userId: 7,
userName: 'alice',
},
},
stubActions: false,
})
}
async function uploadAvatar(input: HTMLInputElement, file: File) {
Object.defineProperty(input, 'files', { configurable: true, value: [file] })
await fireEvent(input, new Event('input', { bubbles: true }))
}
describe('UserProfileView', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPut.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('loads the current profile and passkey count from their real response shapes', async () => {
mockSuccessfulLoad()
await renderProfile()
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'user/alice')
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'mfa/passkey/list')
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
expect(await screen.findByText('1 个密钥')).toBeInTheDocument()
})
it('shows a retryable error instead of a blank profile after loading fails', async () => {
mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockImplementation((url: string) => {
if (url === 'user/alice') return Promise.resolve(currentUser())
return Promise.resolve({ data: [], success: true })
})
await renderProfile()
expect(await screen.findByText('服务器连接失败')).toBeInTheDocument()
expect(screen.queryByLabelText('邮箱')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
})
it('shows the loaded profile while the passkey badge request is still pending', async () => {
const passkeyRequest = deferred<{ data: PassKey[]; success: boolean }>()
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'user/alice') return Promise.resolve(currentUser())
if (url === 'mfa/passkey/list') return passkeyRequest.promise
return Promise.reject(new Error(`unexpected GET ${url}`))
})
await renderProfile()
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
passkeyRequest.resolve({ data: [], success: true })
})
it('validates password confirmation without sending an update', async () => {
mockSuccessfulLoad()
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
await fireEvent.update(screen.getByLabelText('密码'), 'new-password')
await fireEvent.update(screen.getByLabelText('确认密码'), 'different')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(mocks.toastError).toHaveBeenCalledWith('两次输入的密码不一致')
expect(mocks.apiPut).not.toHaveBeenCalled()
})
it('validates avatar files and supports restoring the saved or default avatar', async () => {
mockSuccessfulLoad()
const { container } = await renderProfile()
await screen.findByDisplayValue('alice@example.com')
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
await uploadAvatar(fileInput, new File(['text'], 'avatar.txt', { type: 'text/plain' }))
expect(mocks.toastError).toHaveBeenCalledWith('上传的文件不符合要求,请重新选择头像')
await uploadAvatar(fileInput, new File([new Uint8Array(800 * 1024 + 1)], 'large.png', { type: 'image/png' }))
expect(mocks.toastError).toHaveBeenCalledWith('文件大小不得大于800KB')
await uploadAvatar(fileInput, new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('新头像上传成功,待保存后生效!'))
await fireEvent.click(screen.getByRole('button', { name: '重置' }))
await fireEvent.click(screen.getByRole('button', { name: '默认' }))
expect(mocks.toastSuccess).toHaveBeenCalledWith('已还原当前使用头像!')
expect(mocks.toastSuccess).toHaveBeenCalledWith('已重置为默认头像,待保存后生效!')
})
it('keeps notification identity fields editable in the profile payload', async () => {
mockSuccessfulLoad()
mocks.apiPut.mockResolvedValue({ success: true })
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
const identities: Array<[string, string]> = [
['企业微信用户', 'wechat-user'],
['微信 ClawBot 用户', 'clawbot-user'],
['飞书用户', 'feishu-user'],
['Telegram用户', 'telegram-user'],
['Slack用户', 'slack-user'],
['Discord用户', 'discord-user'],
['VoceChat用户', 'vocechat-user'],
['SynologyChat用户', 'synology-user'],
['豆瓣用户', 'douban-user'],
]
for (const [label, value] of identities) await fireEvent.update(screen.getByLabelText(label), value)
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
expect(mocks.apiPut.mock.calls[0][1]).toMatchObject({
settings: {
discord_userid: 'discord-user',
douban_userid: 'douban-user',
feishu_openid: 'feishu-user',
slack_userid: 'slack-user',
synologychat_userid: 'synology-user',
telegram_userid: 'telegram-user',
vocechat_userid: 'vocechat-user',
wechat_userid: 'wechat-user',
wechatclawbot_userid: 'clawbot-user',
},
})
})
it('saves edited profile data and updates the current-user store only after success', async () => {
mockSuccessfulLoad()
mocks.apiPut.mockResolvedValue({ success: true })
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
await fireEvent.update(screen.getByLabelText('邮箱'), 'updated@example.com')
await fireEvent.update(screen.getByLabelText('昵称'), 'Alice Updated')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
expect(mocks.apiPut).toHaveBeenCalledWith(
'user/',
expect.objectContaining({
email: 'updated@example.com',
name: 'alice',
settings: expect.objectContaining({ nickname: 'Alice Updated' }),
}),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('用户信息保存成功!')
expect(useUserStore().userName).toBe('alice')
})
it('keeps edited input retryable after a business failure', async () => {
mockSuccessfulLoad()
mocks.apiPut.mockResolvedValue({ message: '没有权限', success: false })
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
await fireEvent.update(screen.getByLabelText('邮箱'), 'retry@example.com')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:没有权限!'))
expect(screen.getByDisplayValue('retry@example.com')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
})
it('shows an HTTP save failure and allows the same input to be retried', async () => {
mockSuccessfulLoad()
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ success: true })
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
await fireEvent.update(screen.getByLabelText('邮箱'), 'retry@example.com')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:服务器连接失败!'))
expect(screen.getByDisplayValue('retry@example.com')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(2))
})
it('prevents duplicate save submissions while a request is pending', async () => {
mockSuccessfulLoad()
let resolveUpdate!: (value: { success: boolean }) => void
mocks.apiPut.mockReturnValue(
new Promise(resolve => {
resolveUpdate = resolve
}),
)
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
const save = screen.getByRole('button', { name: '保存' })
await fireEvent.click(save)
await fireEvent.click(save)
expect(mocks.apiPut).toHaveBeenCalledOnce()
resolveUpdate({ success: true })
await waitFor(() => expect(screen.getByRole('button', { name: '保存' })).toBeEnabled())
})
it('projects OTP and passkey dialog updates back into the profile badges', async () => {
mockSuccessfulLoad()
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
await fireEvent.click(screen.getByText('二次验证'))
const otpEvents = mocks.openSharedDialog.mock.calls[0][2] as Record<string, (value: unknown) => void>
otpEvents['update:isOtp'](true)
otpEvents['update:modelValue'](false)
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
expect(await screen.findByText('已启用')).toBeInTheDocument()
await fireEvent.click(screen.getByText('通行密钥管理'))
const passkeyEvents = mocks.openSharedDialog.mock.calls[1][2] as Record<string, (value: unknown) => void>
passkeyEvents['update:passkeyList']([createPassKey(), createPassKey({ id: 12, name: 'Security Key' })])
passkeyEvents['update:modelValue'](false)
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
expect(await screen.findByText('2 个密钥')).toBeInTheDocument()
})
it('forwards MFA password verification through the shared verification dialog', async () => {
mockSuccessfulLoad()
const controllers = Array.from({ length: 2 }, () => ({ close: vi.fn(), id: 1, updateProps: vi.fn() }))
mocks.openSharedDialog.mockImplementation(() => controllers.shift())
await renderProfile()
await screen.findByDisplayValue('alice@example.com')
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
await fireEvent.click(screen.getByText('二次验证'))
const otpEvents = mocks.openSharedDialog.mock.calls[0][2] as Record<string, (value: unknown) => void>
const verified = vi.fn()
otpEvents.verifyPassword({ callback: verified, text: '请输入密码', title: '验证密码' })
const verifyProps = mocks.openSharedDialog.mock.calls[1][1]
const verifyEvents = mocks.openSharedDialog.mock.calls[1][2] as Record<string, (value?: unknown) => void>
expect(verifyProps).toEqual({ text: '请输入密码', title: '验证密码' })
await verifyEvents.confirm('secret')
expect(verified).toHaveBeenCalledWith('secret')
})
})

View File

@@ -1,4 +1,4 @@
import type { User } from '@/api/types'
import type { PassKey, User } from '@/api/types'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
/** 构造与用户查询接口一致的稳定测试数据。 */
@@ -16,3 +16,14 @@ export function createUser(overrides: Partial<User> = {}): User {
...overrides,
}
}
/** 构造与通行密钥列表接口一致的稳定测试数据。 */
export function createPassKey(overrides: Partial<PassKey> = {}): PassKey {
return {
id: 11,
name: 'MacBook Touch ID',
created_at: '2026-08-01T08:00:00Z',
last_used_at: '2026-08-10T08:00:00Z',
...overrides,
}
}

View File

@@ -341,6 +341,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/utils/torrentDownloadCache.ts',
'src/components/dialog/SiteAddEditDialog.vue',
'src/components/dialog/UserAddEditDialog.vue',
'src/components/dialog/OTPAuthDialog.vue',
'src/components/dialog/PasskeyDialog.vue',
'src/components/dialog/SiteCookieUpdateDialog.vue',
'src/components/dialog/SiteImportDialog.vue',
'src/components/cards/SubscribeShareCard.vue',
@@ -387,6 +389,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/utils/searchStream.ts',
'src/views/site/SiteCardListView.vue',
'src/views/user/UserListView.vue',
'src/views/user/UserProfileView.vue',
'src/views/reorganize/DownloadingListView.vue',
'src/views/plugin/PluginCardListView.vue',
'src/utils/siteIconCache.ts',
@@ -417,12 +420,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 80,
statements: 80,
},
'src/components/dialog/OTPAuthDialog.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/dialog/PasskeyDialog.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/user/UserListView.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/views/user/UserProfileView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/cards/TorrentCard.vue': {
branches: 75,
functions: 80,