refactor(auth): migrate MfaChallengeCard to useAuthStore.verifyMfa

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 22:01:32 +02:00
parent eb7f3eb057
commit b191fbe917
3 changed files with 187 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useSendMfaEmailCode, useVerifyMfa } from '@/composables/api/useMfa'
import { useSendMfaEmailCode } from '@/composables/api/useMfa'
import { useAuthStore } from '@/stores/useAuthStore'
import { generateDeviceFingerprint, getDeviceName } from '@/utils/deviceFingerprint'
import type { MfaMethod } from '@/types/mfa'
@@ -11,12 +12,13 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
verified: [data: unknown]
verified: []
cancelled: []
}>()
const verifyMutation = useVerifyMfa()
const authStore = useAuthStore()
const sendEmailMutation = useSendMfaEmailCode()
const isVerifying = ref(false)
const selectedMethod = ref<string>(props.preferredMethod)
const otpCode = ref('')
@@ -93,25 +95,31 @@ async function handleVerify() {
if (!code)
return
isVerifying.value = true
try {
const data = await verifyMutation.mutateAsync({
mfa_session_token: props.mfaSessionToken,
const result = await authStore.verifyMfa({
sessionToken: props.mfaSessionToken,
code,
method: selectedMethod.value as MfaMethod,
trust_device: trustDevice.value || undefined,
device_fingerprint: trustDevice.value ? generateDeviceFingerprint() : undefined,
device_name: trustDevice.value ? getDeviceName() : undefined,
trustDevice: trustDevice.value || undefined,
deviceFingerprint: trustDevice.value ? generateDeviceFingerprint() : undefined,
deviceName: trustDevice.value ? getDeviceName() : undefined,
})
emit('verified', data)
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
if (result.kind === 'authenticated') {
emit('verified')
errorMessage.value = ax.response?.data?.message ?? 'Verificatie mislukt. Probeer het opnieuw.'
return
}
errorMessage.value = result.reason || 'Verificatie mislukt. Probeer het opnieuw.'
otpCode.value = ''
backupCode.value = ''
}
finally {
isVerifying.value = false
}
}
</script>
@@ -193,7 +201,7 @@ async function handleVerify() {
</p>
<VOtpInput
v-model="otpCode"
:disabled="verifyMutation.isPending.value || timeLeft <= 0"
:disabled="isVerifying || timeLeft <= 0"
type="number"
class="pa-0"
@finish="onOtpFinish"
@@ -230,7 +238,7 @@ async function handleVerify() {
<VBtn
block
type="submit"
:loading="verifyMutation.isPending.value"
:loading="isVerifying"
:disabled="timeLeft <= 0"
>
Verifi&euml;ren

View File

@@ -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<MfaVerifyResult>>()
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: '<div><slot /></div>' },
VCardText: { template: '<div><slot /></div>' },
VChip: { template: '<span><slot /></span>' },
VTabs: { template: '<div><slot /></div>' },
VTab: { template: '<button><slot /></button>' },
VAlert: { template: '<div data-test="alert"><slot /></div>' },
VForm: { template: '<form><slot /></form>' },
VRow: { template: '<div><slot /></div>' },
VCol: { template: '<div><slot /></div>' },
VOtpInput: {
name: 'VOtpInput',
props: ['modelValue', 'disabled'],
emits: ['update:modelValue', 'finish'],
template: '<input data-test="otp" />',
},
AppTextField: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: '<input data-test="backup" />',
},
VCheckbox: {
name: 'VCheckbox',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
template: '<input type="checkbox" data-test="trust" />',
},
VBtn: { template: '<button data-test="verify-btn"><slot /></button>' },
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<typeof makeWrapper>, 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()
})
})

View File

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