fix(totp): display normalized API error messages

This commit is contained in:
chihao.ou
2026-09-15 20:06:18 +08:00
parent 881f320269
commit 14636d2f0e
3 changed files with 87 additions and 7 deletions
@@ -87,6 +87,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { totpAPI } from '@/api'
import { extractApiErrorMessage } from '@/utils/apiError'
const emit = defineEmits<{
close: []
@@ -120,7 +121,7 @@ const loadVerificationMethod = async () => {
const method = await totpAPI.getVerificationMethod()
verificationMethod.value = method.method
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('common.error'))
appStore.showError(extractApiErrorMessage(err, t('common.error')))
emit('close')
} finally {
methodLoading.value = false
@@ -148,7 +149,7 @@ const handleSendCode = async () => {
}
}, 1000)
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
appStore.showError(extractApiErrorMessage(err, t('profile.totp.sendCodeFailed')))
} finally {
sendingCode.value = false
}
@@ -168,7 +169,7 @@ const handleDisable = async () => {
appStore.showSuccess(t('profile.totp.disableSuccess'))
emit('success')
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('profile.totp.disableFailed'))
appStore.showError(extractApiErrorMessage(err, t('profile.totp.disableFailed')))
} finally {
loading.value = false
}
@@ -171,6 +171,7 @@ import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { totpAPI } from '@/api'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { TotpSetupResponse } from '@/types'
import QRCode from 'qrcode'
@@ -315,7 +316,7 @@ const loadVerificationMethod = async () => {
const method = await totpAPI.getVerificationMethod()
verificationMethod.value = method.method
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('common.error'))
appStore.showError(extractApiErrorMessage(err, t('common.error')))
emit('close')
} finally {
methodLoading.value = false
@@ -343,7 +344,7 @@ const handleSendCode = async () => {
}
}, 1000)
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
appStore.showError(extractApiErrorMessage(err, t('profile.totp.sendCodeFailed')))
} finally {
sendingCode.value = false
}
@@ -360,7 +361,7 @@ const handleVerifyAndSetup = async () => {
setupData.value = await totpAPI.initiateSetup(request)
step.value = 1
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('profile.totp.setupFailed'))
appStore.showError(extractApiErrorMessage(err, t('profile.totp.setupFailed')))
} finally {
setupLoading.value = false
}
@@ -380,7 +381,7 @@ const handleVerify = async () => {
appStore.showSuccess(t('profile.totp.enableSuccess'))
emit('success')
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('profile.totp.verifyFailed'))
appStore.showError(extractApiErrorMessage(err, t('profile.totp.verifyFailed')))
code.value = ['', '', '', '', '', '']
nextTick(() => {
inputRefs.value[0]?.focus()
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import TotpSetupModal from '../TotpSetupModal.vue'
import TotpDisableDialog from '../TotpDisableDialog.vue'
const mocks = vi.hoisted(() => ({
showError: vi.fn(), showSuccess: vi.fn(),
totpAPI: {
getVerificationMethod: vi.fn(), sendVerifyCode: vi.fn(),
initiateSetup: vi.fn(), enable: vi.fn(), disable: vi.fn()
}
}))
vi.mock('@/api', () => ({ totpAPI: mocks.totpAPI }))
vi.mock('@/stores/app', () => ({ useAppStore: () => mocks }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
vi.mock('qrcode', () => ({ default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr') } }))
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks()
mocks.totpAPI.getVerificationMethod.mockResolvedValue({ method: 'password' })
})
describe.each([
{ name: 'setup', component: TotpSetupModal, action: mocks.totpAPI.initiateSetup, fallback: 'profile.totp.setupFailed' },
{ name: 'disable', component: TotpDisableDialog, action: mocks.totpAPI.disable, fallback: 'profile.totp.disableFailed' }
])('TOTP $name errors', ({ name, component, action, fallback }) => {
it('shows the verification-method API error before closing', async () => {
mocks.totpAPI.getVerificationMethod.mockRejectedValue({ status: 503, message: 'Verification is temporarily unavailable' })
const wrapper = mount(component)
await flushPromises()
expect(mocks.showError).toHaveBeenCalledWith('Verification is temporarily unavailable')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('shows why the email verification code cannot be sent', async () => {
mocks.totpAPI.getVerificationMethod.mockResolvedValue({ method: 'email' })
mocks.totpAPI.sendVerifyCode.mockRejectedValue({ status: 429, message: 'Please wait before requesting another code' })
const wrapper = mount(component)
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === 'profile.totp.sendCode')!.trigger('click')
await flushPromises()
expect(mocks.showError).toHaveBeenCalledWith('Please wait before requesting another code')
})
it.each([
[{ status: 400, message: 'Incorrect password' }, 'Incorrect password'],
[{ response: { data: { message: 'Legacy message' } } }, 'Legacy message'],
[{}, fallback]
])('preserves the action error or localized fallback: %j', async (error, expected) => {
action.mockRejectedValue(error)
const wrapper = mount(component)
await flushPromises()
await wrapper.get('input[type="password"]').setValue('incorrect-password')
if (name === 'setup') await wrapper.get('.btn-primary').trigger('click')
else await wrapper.get('form').trigger('submit')
await flushPromises()
expect(mocks.showError).toHaveBeenCalledWith(expected)
expect(wrapper.emitted('success')).toBeUndefined()
})
})
it('shows the reason a TOTP code was rejected', async () => {
mocks.totpAPI.initiateSetup.mockResolvedValue({ secret: 'EXAMPLE', qr_code_url: '', setup_token: 'setup-token' })
mocks.totpAPI.enable.mockRejectedValue({ status: 400, message: 'Invalid TOTP code' })
const wrapper = mount(TotpSetupModal)
await flushPromises()
await wrapper.get('input[type="password"]').setValue('password')
await wrapper.get('.btn-primary').trigger('click')
await flushPromises()
await wrapper.get('.btn-primary').trigger('click')
for (const input of wrapper.findAll('input[maxlength="1"]')) await input.setValue('1')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(mocks.totpAPI.enable).toHaveBeenCalledWith({ totp_code: '111111', setup_token: 'setup-token' })
expect(mocks.showError).toHaveBeenCalledWith('Invalid TOTP code')
expect(wrapper.emitted('success')).toBeUndefined()
})