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:
@@ -11,6 +11,7 @@ use App\Enums\MfaMethod;
|
|||||||
use App\Services\MfaService;
|
use App\Services\MfaService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
final class MfaSetupController extends Controller
|
final class MfaSetupController extends Controller
|
||||||
{
|
{
|
||||||
@@ -140,9 +141,40 @@ final class MfaSetupController extends Controller
|
|||||||
'confirmed_at' => $user->mfa_confirmed_at?->toIso8601String(),
|
'confirmed_at' => $user->mfa_confirmed_at?->toIso8601String(),
|
||||||
'backup_codes_remaining' => $this->mfaService->getRemainingBackupCodeCount($user),
|
'backup_codes_remaining' => $this->mfaService->getRemainingBackupCodeCount($user),
|
||||||
'is_required' => $this->mfaService->isMfaRequired($user),
|
'is_required' => $this->mfaService->isMfaRequired($user),
|
||||||
|
'totp_configured' => $user->mfa_enabled && $user->mfa_secret !== null,
|
||||||
|
'email_configured' => $user->mfa_enabled,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setPreferredMethod(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'method' => ['required', Rule::in(['totp', 'email'])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user->mfa_enabled) {
|
||||||
|
return $this->error('MFA is niet ingeschakeld.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $request->input('method');
|
||||||
|
|
||||||
|
if ($method === 'totp' && $user->mfa_secret === null) {
|
||||||
|
return $this->error('Authenticator app is niet geconfigureerd.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->update(['mfa_method' => $method]);
|
||||||
|
|
||||||
|
activity('mfa')
|
||||||
|
->causedBy($user)
|
||||||
|
->performedOn($user)
|
||||||
|
->withProperties(['method' => $method])
|
||||||
|
->log('mfa.preferred_method.changed');
|
||||||
|
|
||||||
|
return $this->success(['method' => $method], 'Primaire methode gewijzigd.');
|
||||||
|
}
|
||||||
|
|
||||||
private function verifyBackupCodeForDisable(\App\Models\User $user, string $code): void
|
private function verifyBackupCodeForDisable(\App\Models\User $user, string $code): void
|
||||||
{
|
{
|
||||||
$normalizedCode = strtoupper(str_replace([' ', '-'], '', $code));
|
$normalizedCode = strtoupper(str_replace([' ', '-'], '', $code));
|
||||||
|
|||||||
@@ -90,14 +90,16 @@ final class MfaService
|
|||||||
/**
|
/**
|
||||||
* Setup email as MFA method (simpler than TOTP — no QR code).
|
* Setup email as MFA method (simpler than TOTP — no QR code).
|
||||||
* Sends a verification code to confirm.
|
* Sends a verification code to confirm.
|
||||||
|
* Preserves the TOTP secret so both methods can coexist.
|
||||||
*/
|
*/
|
||||||
public function setupEmail(User $user): void
|
public function setupEmail(User $user): void
|
||||||
{
|
{
|
||||||
|
if (! $user->mfa_enabled) {
|
||||||
$user->update([
|
$user->update([
|
||||||
'mfa_method' => MfaMethod::EMAIL->value,
|
'mfa_method' => MfaMethod::EMAIL->value,
|
||||||
'mfa_secret' => null,
|
|
||||||
'mfa_confirmed_at' => null,
|
'mfa_confirmed_at' => null,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->sendEmailCode($user);
|
$this->sendEmailCode($user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::post('auth/mfa/disable', [MfaSetupController::class, 'disable']);
|
Route::post('auth/mfa/disable', [MfaSetupController::class, 'disable']);
|
||||||
Route::post('auth/mfa/backup-codes', [MfaSetupController::class, 'regenerateBackupCodes']);
|
Route::post('auth/mfa/backup-codes', [MfaSetupController::class, 'regenerateBackupCodes']);
|
||||||
Route::get('auth/mfa/status', [MfaSetupController::class, 'status']);
|
Route::get('auth/mfa/status', [MfaSetupController::class, 'status']);
|
||||||
|
Route::put('auth/mfa/preferred-method', [MfaSetupController::class, 'setPreferredMethod']);
|
||||||
|
|
||||||
// Trusted devices
|
// Trusted devices
|
||||||
Route::get('auth/trusted-devices', [TrustedDeviceController::class, 'index']);
|
Route::get('auth/trusted-devices', [TrustedDeviceController::class, 'index']);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useRevokeDevice,
|
useRevokeDevice,
|
||||||
useRevokeAllDevices,
|
useRevokeAllDevices,
|
||||||
useRegenerateBackupCodes,
|
useRegenerateBackupCodes,
|
||||||
|
useSetPreferredMethod,
|
||||||
} from '@/composables/api/useMfa'
|
} from '@/composables/api/useMfa'
|
||||||
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
|
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
|
||||||
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
|
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
|
||||||
@@ -59,6 +60,8 @@ const revokeDeviceMutation = useRevokeDevice()
|
|||||||
const revokeAllMutation = useRevokeAllDevices()
|
const revokeAllMutation = useRevokeAllDevices()
|
||||||
const regenerateCodesMutation = useRegenerateBackupCodes()
|
const regenerateCodesMutation = useRegenerateBackupCodes()
|
||||||
|
|
||||||
|
const setPreferredMethodMutation = useSetPreferredMethod()
|
||||||
|
|
||||||
const showTotpSetup = ref(false)
|
const showTotpSetup = ref(false)
|
||||||
const showEmailSetup = ref(false)
|
const showEmailSetup = ref(false)
|
||||||
const showDisableDialog = ref(false)
|
const showDisableDialog = ref(false)
|
||||||
@@ -69,8 +72,13 @@ const regenerateError = ref('')
|
|||||||
|
|
||||||
const isEnabled = computed(() => mfaStatus.value?.enabled ?? false)
|
const isEnabled = computed(() => mfaStatus.value?.enabled ?? false)
|
||||||
|
|
||||||
const totpConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'totp')
|
const totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false)
|
||||||
const emailConfigured = computed(() => isEnabled.value && mfaStatus.value?.method === 'email')
|
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 backupCodesRemaining = computed(() => mfaStatus.value?.backup_codes_remaining ?? 0)
|
||||||
const backupCodesColor = computed(() => {
|
const backupCodesColor = computed(() => {
|
||||||
@@ -298,14 +306,30 @@ function copyRegeneratedCodes() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<template v-if="totpConfigured">
|
||||||
v-if="totpConfigured && mfaStatus?.method === 'totp'"
|
<VChip
|
||||||
class="text-body-2 text-medium-emphasis mb-3"
|
v-if="preferredMethod === 'totp'"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
class="mt-1"
|
||||||
>
|
>
|
||||||
Primaire methode
|
Primaire methode
|
||||||
</p>
|
</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
|
<p
|
||||||
v-else-if="!totpConfigured"
|
v-else
|
||||||
class="text-body-2 text-medium-emphasis mb-3"
|
class="text-body-2 text-medium-emphasis mb-3"
|
||||||
>
|
>
|
||||||
Gebruik Google Authenticator, Authy of een andere app.
|
Gebruik Google Authenticator, Authy of een andere app.
|
||||||
@@ -315,6 +339,7 @@ function copyRegeneratedCodes() {
|
|||||||
v-if="totpConfigured"
|
v-if="totpConfigured"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
size="small"
|
size="small"
|
||||||
|
class="mt-2"
|
||||||
@click="showTotpSetup = true"
|
@click="showTotpSetup = true"
|
||||||
>
|
>
|
||||||
Opnieuw instellen
|
Opnieuw instellen
|
||||||
@@ -367,14 +392,30 @@ function copyRegeneratedCodes() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<template v-if="emailConfigured">
|
||||||
v-if="emailConfigured && mfaStatus?.method === 'email'"
|
<VChip
|
||||||
class="text-body-2 text-medium-emphasis mb-3"
|
v-if="preferredMethod === 'email'"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
class="mt-1"
|
||||||
>
|
>
|
||||||
Primaire methode
|
Primaire methode
|
||||||
</p>
|
</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
|
<p
|
||||||
v-else-if="!emailConfigured"
|
v-else
|
||||||
class="text-body-2 text-medium-emphasis mb-3"
|
class="text-body-2 text-medium-emphasis mb-3"
|
||||||
>
|
>
|
||||||
Ontvang een code per e-mail bij elke login.
|
Ontvang een code per e-mail bij elke login.
|
||||||
@@ -384,6 +425,7 @@ function copyRegeneratedCodes() {
|
|||||||
v-if="emailConfigured"
|
v-if="emailConfigured"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
size="small"
|
size="small"
|
||||||
|
class="mt-2"
|
||||||
@click="showEmailSetup = true"
|
@click="showEmailSetup = true"
|
||||||
>
|
>
|
||||||
Opnieuw instellen
|
Opnieuw instellen
|
||||||
|
|||||||
@@ -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 ───
|
// ─── Trusted devices ───
|
||||||
|
|
||||||
export function useTrustedDevices() {
|
export function useTrustedDevices() {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface MfaStatus {
|
|||||||
confirmed_at: string | null
|
confirmed_at: string | null
|
||||||
backup_codes_remaining: number
|
backup_codes_remaining: number
|
||||||
is_required: boolean
|
is_required: boolean
|
||||||
|
totp_configured: boolean
|
||||||
|
email_configured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaUserStatus {
|
export interface MfaUserStatus {
|
||||||
|
|||||||
@@ -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 ───
|
// ─── Trusted devices ───
|
||||||
|
|
||||||
export function useTrustedDevices() {
|
export function useTrustedDevices() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useTrustedDevices,
|
useTrustedDevices,
|
||||||
useRevokeDevice,
|
useRevokeDevice,
|
||||||
useRevokeAllDevices,
|
useRevokeAllDevices,
|
||||||
|
useSetPreferredMethod,
|
||||||
} from '@/composables/api/useMfa'
|
} from '@/composables/api/useMfa'
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
|
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
|
||||||
@@ -209,16 +210,19 @@ const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
|
|||||||
const revokeDeviceMutation = useRevokeDevice()
|
const revokeDeviceMutation = useRevokeDevice()
|
||||||
const revokeAllMutation = useRevokeAllDevices()
|
const revokeAllMutation = useRevokeAllDevices()
|
||||||
|
|
||||||
|
const setPreferredMethodMutation = useSetPreferredMethod()
|
||||||
|
|
||||||
const showTotpSetup = ref(false)
|
const showTotpSetup = ref(false)
|
||||||
const showEmailSetup = ref(false)
|
const showEmailSetup = ref(false)
|
||||||
const showDisableDialog = ref(false)
|
const showDisableDialog = ref(false)
|
||||||
const isMfaEnabled = computed(() => mfaStatus.value?.enabled ?? false)
|
const isMfaEnabled = computed(() => mfaStatus.value?.enabled ?? false)
|
||||||
const mfaMethodLabel = computed(() => {
|
const totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false)
|
||||||
if (mfaStatus.value?.method === 'totp') return 'Authenticator app'
|
const emailConfigured = computed(() => mfaStatus.value?.email_configured ?? false)
|
||||||
if (mfaStatus.value?.method === 'email') return 'E-mailcode'
|
const preferredMethod = computed(() => mfaStatus.value?.method ?? null)
|
||||||
|
|
||||||
return null
|
function handleSetPreferred(method: 'totp' | 'email') {
|
||||||
})
|
setPreferredMethodMutation.mutate(method)
|
||||||
|
}
|
||||||
|
|
||||||
function onMfaSetupCompleted() { refetchMfaStatus() }
|
function onMfaSetupCompleted() { refetchMfaStatus() }
|
||||||
function onMfaDisabled() { refetchMfaStatus(); refetchDevices() }
|
function onMfaDisabled() { refetchMfaStatus(); refetchDevices() }
|
||||||
@@ -668,27 +672,94 @@ function viewEvent(eventId: string) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="d-flex align-center justify-space-between mb-3">
|
<!-- Per-method status -->
|
||||||
<div>
|
<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"
|
||||||
|
color="primary"
|
||||||
|
:loading="setPreferredMethodMutation.isPending.value"
|
||||||
|
@click="handleSetPreferred('totp')"
|
||||||
|
>
|
||||||
|
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
|
<VChip
|
||||||
color="success"
|
color="success"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
size="small"
|
size="x-small"
|
||||||
class="me-2"
|
|
||||||
>
|
>
|
||||||
Ingeschakeld
|
Actief
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="preferredMethod === 'email'"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
size="x-small"
|
||||||
|
class="ms-1"
|
||||||
|
>
|
||||||
|
Primair
|
||||||
</VChip>
|
</VChip>
|
||||||
<span class="text-body-2">via {{ mfaMethodLabel }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<VBtn
|
||||||
|
v-if="preferredMethod !== 'email'"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
:loading="setPreferredMethodMutation.isPending.value"
|
||||||
|
@click="handleSetPreferred('email')"
|
||||||
|
>
|
||||||
|
Als primair
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<VBtn
|
<VBtn
|
||||||
color="error"
|
color="error"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
size="small"
|
size="small"
|
||||||
|
class="mb-4"
|
||||||
@click="showDisableDialog = true"
|
@click="showDisableDialog = true"
|
||||||
>
|
>
|
||||||
Uitschakelen
|
Uitschakelen
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Trusted devices -->
|
<!-- Trusted devices -->
|
||||||
<template v-if="trustedDevices && trustedDevices.length > 0">
|
<template v-if="trustedDevices && trustedDevices.length > 0">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface MfaStatus {
|
|||||||
confirmed_at: string | null
|
confirmed_at: string | null
|
||||||
backup_codes_remaining: number
|
backup_codes_remaining: number
|
||||||
is_required: boolean
|
is_required: boolean
|
||||||
|
totp_configured: boolean
|
||||||
|
email_configured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaUserStatus {
|
export interface MfaUserStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user