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() {