From 14636d2f0e674601b4ad77458f7ad8cf658a92d9 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 15 Sep 2026 20:06:18 +0800 Subject: [PATCH] fix(totp): display normalized API error messages --- .../user/profile/TotpDisableDialog.vue | 7 +- .../user/profile/TotpSetupModal.vue | 9 ++- .../profile/__tests__/Totp.errors.spec.ts | 78 +++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/user/profile/__tests__/Totp.errors.spec.ts diff --git a/frontend/src/components/user/profile/TotpDisableDialog.vue b/frontend/src/components/user/profile/TotpDisableDialog.vue index c5e9afe7a2..97bf2a63dd 100644 --- a/frontend/src/components/user/profile/TotpDisableDialog.vue +++ b/frontend/src/components/user/profile/TotpDisableDialog.vue @@ -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 } diff --git a/frontend/src/components/user/profile/TotpSetupModal.vue b/frontend/src/components/user/profile/TotpSetupModal.vue index 312ecf7f4b..72372dc1e1 100644 --- a/frontend/src/components/user/profile/TotpSetupModal.vue +++ b/frontend/src/components/user/profile/TotpSetupModal.vue @@ -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() diff --git a/frontend/src/components/user/profile/__tests__/Totp.errors.spec.ts b/frontend/src/components/user/profile/__tests__/Totp.errors.spec.ts new file mode 100644 index 0000000000..e3e664ebcb --- /dev/null +++ b/frontend/src/components/user/profile/__tests__/Totp.errors.spec.ts @@ -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() +})