From b191fbe91759136d21e5dba7104243d1b0d1550f Mon Sep 17 00:00:00 2001
From: "bert.hausmans"
Date: Tue, 5 May 2026 22:01:32 +0200
Subject: [PATCH] refactor(auth): migrate MfaChallengeCard to
useAuthStore.verifyMfa
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The card consumed the API directly via useVerifyMfa() (TanStack Query
mutation). Per Decision F's intent (store owns business logic, the
component consumes typed results), the card now calls
useAuthStore.verifyMfa() and pattern-matches on the MfaVerifyResult
discriminated union.
Changes:
- MfaChallengeCard: drop useVerifyMfa import; call authStore.verifyMfa
with camelCase args (sessionToken, trustDevice, deviceFingerprint,
deviceName); local isVerifying ref replaces verifyMutation.isPending.
On result.kind === 'authenticated' emit `verified` (no payload —
the store has already refreshed user state); on 'failed' surface
result.reason with a generic fallback.
- emit signature: `verified: [data: unknown]` → `verified: []`.
- login.vue: onMfaVerified no longer calls authStore.refreshUser —
authStore.verifyMfa() refreshes internally. Page just routes to
resolvePostLoginTarget().
Adds 4 vitest specs in components/auth/__tests__/MfaChallengeCard.spec.ts
covering: success path emits `verified` with camelCase args, failure
path shows reason and suppresses emit, trustDevice toggle honours
fingerprint + device name, fallback message when reason is empty.
Test count 209 → 213. Lint + typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../src/components/auth/MfaChallengeCard.vue | 38 +++--
.../auth/__tests__/MfaChallengeCard.spec.ts | 161 ++++++++++++++++++
apps/app/src/pages/login.vue | 8 +-
3 files changed, 187 insertions(+), 20 deletions(-)
create mode 100644 apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts
diff --git a/apps/app/src/components/auth/MfaChallengeCard.vue b/apps/app/src/components/auth/MfaChallengeCard.vue
index d675c28c..2d88cd01 100644
--- a/apps/app/src/components/auth/MfaChallengeCard.vue
+++ b/apps/app/src/components/auth/MfaChallengeCard.vue
@@ -1,5 +1,6 @@
@@ -193,7 +201,7 @@ async function handleVerify() {
Verifiëren
diff --git a/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts b/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts
new file mode 100644
index 00000000..fbed77fd
--- /dev/null
+++ b/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts
@@ -0,0 +1,161 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import type { MfaVerifyResult } from '@/types/auth'
+
+const mockVerifyMfa = vi.fn<(args: unknown) => Promise>()
+const mockSendEmailMutateAsync = vi.fn()
+
+vi.mock('@/stores/useAuthStore', () => ({
+ useAuthStore: () => ({
+ verifyMfa: mockVerifyMfa,
+ }),
+}))
+
+vi.mock('@/composables/api/useMfa', () => ({
+ useSendMfaEmailCode: () => ({
+ mutateAsync: mockSendEmailMutateAsync,
+ isPending: { value: false },
+ }),
+}))
+
+vi.mock('@/utils/deviceFingerprint', () => ({
+ generateDeviceFingerprint: () => 'fp-test',
+ getDeviceName: () => 'Chrome on macOS',
+}))
+
+const MfaChallengeCard = (await import('../MfaChallengeCard.vue')).default
+
+const stubs = {
+ VCard: { template: '
' },
+ VCardText: { template: '
' },
+ VChip: { template: '' },
+ VTabs: { template: '
' },
+ VTab: { template: '' },
+ VAlert: { template: '
' },
+ VForm: { template: '' },
+ VRow: { template: '
' },
+ VCol: { template: '
' },
+ VOtpInput: {
+ name: 'VOtpInput',
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue', 'finish'],
+ template: '',
+ },
+ AppTextField: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template: '',
+ },
+ VCheckbox: {
+ name: 'VCheckbox',
+ props: ['modelValue', 'label'],
+ emits: ['update:modelValue'],
+ template: '',
+ },
+ VBtn: { template: '' },
+ VIcon: true,
+}
+
+describe('MfaChallengeCard — useAuthStore.verifyMfa migration (WS-3 PR-B2a)', () => {
+ beforeEach(() => {
+ mockVerifyMfa.mockReset()
+ mockSendEmailMutateAsync.mockReset()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ function makeWrapper() {
+ return mount(MfaChallengeCard, {
+ props: {
+ mfaSessionToken: 'session-abc',
+ methods: ['totp'],
+ preferredMethod: 'totp',
+ expiresIn: 600,
+ },
+ global: {
+ stubs,
+ mocks: {
+ $vuetify: { display: { smAndUp: true } },
+ },
+ },
+ })
+ }
+
+ async function triggerVerify(wrapper: ReturnType, code: string) {
+ const otp = wrapper.findComponent({ name: 'VOtpInput' })
+
+ otp.vm.$emit('finish', code)
+ await nextTick()
+ await nextTick()
+ }
+
+ it('calls authStore.verifyMfa with camelCase args and emits `verified` on success', async () => {
+ mockVerifyMfa.mockResolvedValue({ kind: 'authenticated' })
+
+ const wrapper = makeWrapper()
+
+ await triggerVerify(wrapper, '123456')
+
+ expect(mockVerifyMfa).toHaveBeenCalledTimes(1)
+ expect(mockVerifyMfa).toHaveBeenCalledWith({
+ sessionToken: 'session-abc',
+ code: '123456',
+ method: 'totp',
+ trustDevice: undefined,
+ deviceFingerprint: undefined,
+ deviceName: undefined,
+ })
+
+ expect(wrapper.emitted('verified')).toHaveLength(1)
+ expect(wrapper.emitted('verified')?.[0]).toEqual([])
+ })
+
+ it('displays the result.reason on failure and does NOT emit verified', async () => {
+ mockVerifyMfa.mockResolvedValue({
+ kind: 'failed',
+ reason: 'Code is incorrect.',
+ })
+
+ const wrapper = makeWrapper()
+
+ await triggerVerify(wrapper, '999999')
+
+ expect(wrapper.text()).toContain('Code is incorrect.')
+ expect(wrapper.emitted('verified')).toBeUndefined()
+ })
+
+ it('honours trustDevice flag with fingerprint + device name', async () => {
+ mockVerifyMfa.mockResolvedValue({ kind: 'authenticated' })
+
+ const wrapper = makeWrapper()
+ const trust = wrapper.findComponent({ name: 'VCheckbox' })
+
+ trust.vm.$emit('update:modelValue', true)
+ await nextTick()
+
+ await triggerVerify(wrapper, '123456')
+
+ expect(mockVerifyMfa).toHaveBeenCalledWith({
+ sessionToken: 'session-abc',
+ code: '123456',
+ method: 'totp',
+ trustDevice: true,
+ deviceFingerprint: 'fp-test',
+ deviceName: 'Chrome on macOS',
+ })
+ })
+
+ it('uses a generic fallback message when result.reason is empty', async () => {
+ mockVerifyMfa.mockResolvedValue({ kind: 'failed', reason: '' })
+
+ const wrapper = makeWrapper()
+
+ await triggerVerify(wrapper, '999999')
+
+ expect(wrapper.text()).toContain('Verificatie mislukt')
+ expect(wrapper.emitted('verified')).toBeUndefined()
+ })
+})
diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue
index 009535f6..42ecb448 100644
--- a/apps/app/src/pages/login.vue
+++ b/apps/app/src/pages/login.vue
@@ -121,11 +121,9 @@ async function handleLogin() {
}
function onMfaVerified() {
- // After MFA verify, the response sets the auth cookie. refreshUser()
- // hydrates the store from /auth/me with the new cookie.
- authStore.refreshUser().then(() => {
- router.replace(resolvePostLoginTarget())
- })
+ // useAuthStore.verifyMfa() already refreshed the store post-cookie;
+ // the page just needs to route to the resolved landing target.
+ router.replace(resolvePostLoginTarget())
}
function onMfaCancelled() {