feat: set preferred MFA method from account settings

Adds the ability for users to change their preferred/primary MFA method
when both TOTP and email are available.

Backend:
- Add PUT /auth/mfa/preferred-method endpoint with validation
  (method must be totp/email, MFA must be enabled, TOTP must be
  configured if selecting totp)
- Add totp_configured and email_configured fields to MFA status
  endpoint (totp = has secret + enabled, email = always when enabled)
- Fix setupEmail() to preserve mfa_secret so TOTP config survives
  when email is set up as a second method

Frontend (organizer + portal):
- Add useSetPreferredMethod() composable to useMfa.ts
- Add totp_configured/email_configured to MfaStatus type
- SecurityTab method cards now show "Primaire methode" chip on the
  preferred method and "Als primair instellen" button on the other
- Portal security section shows per-method rows with status chips
  and primary switching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 22:47:34 +02:00
parent a77986334c
commit d5fb15e5fe
9 changed files with 225 additions and 43 deletions

View File

@@ -7,6 +7,7 @@ import {
useRevokeDevice,
useRevokeAllDevices,
useRegenerateBackupCodes,
useSetPreferredMethod,
} from '@/composables/api/useMfa'
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
@@ -59,6 +60,8 @@ const revokeDeviceMutation = useRevokeDevice()
const revokeAllMutation = useRevokeAllDevices()
const regenerateCodesMutation = useRegenerateBackupCodes()
const setPreferredMethodMutation = useSetPreferredMethod()
const showTotpSetup = ref(false)
const showEmailSetup = ref(false)
const showDisableDialog = ref(false)
@@ -69,8 +72,13 @@ const regenerateError = ref('')
const isEnabled = computed(() => mfaStatus.value?.enabled ?? false)
const totpConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'totp')
const emailConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'email')
const totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false)
const emailConfigured = computed(() => mfaStatus.value?.email_configured ?? false)
const preferredMethod = computed(() => mfaStatus.value?.method ?? null)
function handleSetPreferred(method: 'totp' | 'email') {
setPreferredMethodMutation.mutate(method)
}
const backupCodesRemaining = computed(() => mfaStatus.value?.backup_codes_remaining ?? 0)
const backupCodesColor = computed(() => {
@@ -298,14 +306,30 @@ function copyRegeneratedCodes() {
</div>
</div>
<template v-if="totpConfigured">
<VChip
v-if="preferredMethod === 'totp'"
color="primary"
variant="tonal"
size="small"
class="mt-1"
>
Primaire methode
</VChip>
<VBtn
v-else
variant="text"
size="small"
color="primary"
class="mt-1 ms-n2"
:loading="setPreferredMethodMutation.isPending.value"
@click="handleSetPreferred('totp')"
>
Als primair instellen
</VBtn>
</template>
<p
v-if="totpConfigured && mfaStatus?.method === 'totp'"
class="text-body-2 text-medium-emphasis mb-3"
>
Primaire methode
</p>
<p
v-else-if="!totpConfigured"
v-else
class="text-body-2 text-medium-emphasis mb-3"
>
Gebruik Google Authenticator, Authy of een andere app.
@@ -315,6 +339,7 @@ function copyRegeneratedCodes() {
v-if="totpConfigured"
variant="tonal"
size="small"
class="mt-2"
@click="showTotpSetup = true"
>
Opnieuw instellen
@@ -367,14 +392,30 @@ function copyRegeneratedCodes() {
</div>
</div>
<template v-if="emailConfigured">
<VChip
v-if="preferredMethod === 'email'"
color="primary"
variant="tonal"
size="small"
class="mt-1"
>
Primaire methode
</VChip>
<VBtn
v-else
variant="text"
size="small"
color="primary"
class="mt-1 ms-n2"
:loading="setPreferredMethodMutation.isPending.value"
@click="handleSetPreferred('email')"
>
Als primair instellen
</VBtn>
</template>
<p
v-if="emailConfigured && mfaStatus?.method === 'email'"
class="text-body-2 text-medium-emphasis mb-3"
>
Primaire methode
</p>
<p
v-else-if="!emailConfigured"
v-else
class="text-body-2 text-medium-emphasis mb-3"
>
Ontvang een code per e-mail bij elke login.
@@ -384,6 +425,7 @@ function copyRegeneratedCodes() {
v-if="emailConfigured"
variant="tonal"
size="small"
class="mt-2"
@click="showEmailSetup = true"
>
Opnieuw instellen

View File

@@ -112,6 +112,21 @@ export function useMfaStatus() {
})
}
export function useSetPreferredMethod() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (method: 'totp' | 'email') => {
const { data } = await apiClient.put<ApiResponse<{ method: string }>>('/auth/mfa/preferred-method', { method })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
// ─── Trusted devices ───
export function useTrustedDevices() {

View File

@@ -12,6 +12,8 @@ export interface MfaStatus {
confirmed_at: string | null
backup_codes_remaining: number
is_required: boolean
totp_configured: boolean
email_configured: boolean
}
export interface MfaUserStatus {

View File

@@ -109,6 +109,21 @@ export function useMfaStatus() {
})
}
export function useSetPreferredMethod() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (method: 'totp' | 'email') => {
const { data } = await apiClient.put<ApiResponse<{ method: string }>>('/auth/mfa/preferred-method', { method })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
// ─── Trusted devices ───
export function useTrustedDevices() {

View File

@@ -7,6 +7,7 @@ import {
useTrustedDevices,
useRevokeDevice,
useRevokeAllDevices,
useSetPreferredMethod,
} from '@/composables/api/useMfa'
import { apiClient } from '@/lib/axios'
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
@@ -209,16 +210,19 @@ const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
const revokeDeviceMutation = useRevokeDevice()
const revokeAllMutation = useRevokeAllDevices()
const setPreferredMethodMutation = useSetPreferredMethod()
const showTotpSetup = ref(false)
const showEmailSetup = ref(false)
const showDisableDialog = ref(false)
const isMfaEnabled = computed(() => mfaStatus.value?.enabled ?? false)
const mfaMethodLabel = computed(() => {
if (mfaStatus.value?.method === 'totp') return 'Authenticator app'
if (mfaStatus.value?.method === 'email') return 'E-mailcode'
const totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false)
const emailConfigured = computed(() => mfaStatus.value?.email_configured ?? false)
const preferredMethod = computed(() => mfaStatus.value?.method ?? null)
return null
})
function handleSetPreferred(method: 'totp' | 'email') {
setPreferredMethodMutation.mutate(method)
}
function onMfaSetupCompleted() { refetchMfaStatus() }
function onMfaDisabled() { refetchMfaStatus(); refetchDevices() }
@@ -668,28 +672,95 @@ function viewEvent(eventId: string) {
</template>
<template v-else>
<div class="d-flex align-center justify-space-between mb-3">
<div>
<VChip
color="success"
variant="tonal"
<!-- Per-method status -->
<div class="d-flex flex-column gap-3 mb-4">
<!-- TOTP row -->
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<VIcon
icon="tabler-device-mobile"
size="20"
class="me-2"
/>
<span class="text-body-2 font-weight-medium me-2">Authenticator app</span>
<VChip
:color="totpConfigured ? 'success' : 'default'"
variant="tonal"
size="x-small"
>
{{ totpConfigured ? 'Actief' : 'Niet ingesteld' }}
</VChip>
<VChip
v-if="totpConfigured && preferredMethod === 'totp'"
color="primary"
variant="tonal"
size="x-small"
class="ms-1"
>
Primair
</VChip>
</div>
<VBtn
v-if="totpConfigured && preferredMethod !== 'totp'"
variant="text"
size="small"
class="me-2"
color="primary"
:loading="setPreferredMethodMutation.isPending.value"
@click="handleSetPreferred('totp')"
>
Ingeschakeld
</VChip>
<span class="text-body-2">via {{ mfaMethodLabel }}</span>
Als primair
</VBtn>
</div>
<!-- Email row -->
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<VIcon
icon="tabler-mail"
size="20"
class="me-2"
/>
<span class="text-body-2 font-weight-medium me-2">E-mailcode</span>
<VChip
color="success"
variant="tonal"
size="x-small"
>
Actief
</VChip>
<VChip
v-if="preferredMethod === 'email'"
color="primary"
variant="tonal"
size="x-small"
class="ms-1"
>
Primair
</VChip>
</div>
<VBtn
v-if="preferredMethod !== 'email'"
variant="text"
size="small"
color="primary"
:loading="setPreferredMethodMutation.isPending.value"
@click="handleSetPreferred('email')"
>
Als primair
</VBtn>
</div>
<VBtn
color="error"
variant="tonal"
size="small"
@click="showDisableDialog = true"
>
Uitschakelen
</VBtn>
</div>
<VBtn
color="error"
variant="tonal"
size="small"
class="mb-4"
@click="showDisableDialog = true"
>
Uitschakelen
</VBtn>
<!-- Trusted devices -->
<template v-if="trustedDevices && trustedDevices.length > 0">
<p class="text-subtitle-2 mt-4 mb-2">

View File

@@ -12,6 +12,8 @@ export interface MfaStatus {
confirmed_at: string | null
backup_codes_remaining: number
is_required: boolean
totp_configured: boolean
email_configured: boolean
}
export interface MfaUserStatus {