diff --git a/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php b/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php
index 6e700161..b5bb85be 100644
--- a/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php
+++ b/api/app/Http/Controllers/Api/V1/Auth/MfaSetupController.php
@@ -11,6 +11,7 @@ use App\Enums\MfaMethod;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
final class MfaSetupController extends Controller
{
@@ -140,9 +141,40 @@ final class MfaSetupController extends Controller
'confirmed_at' => $user->mfa_confirmed_at?->toIso8601String(),
'backup_codes_remaining' => $this->mfaService->getRemainingBackupCodeCount($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
{
$normalizedCode = strtoupper(str_replace([' ', '-'], '', $code));
diff --git a/api/app/Services/MfaService.php b/api/app/Services/MfaService.php
index 038652b8..dd61b091 100644
--- a/api/app/Services/MfaService.php
+++ b/api/app/Services/MfaService.php
@@ -90,14 +90,16 @@ final class MfaService
/**
* Setup email as MFA method (simpler than TOTP — no QR code).
* Sends a verification code to confirm.
+ * Preserves the TOTP secret so both methods can coexist.
*/
public function setupEmail(User $user): void
{
- $user->update([
- 'mfa_method' => MfaMethod::EMAIL->value,
- 'mfa_secret' => null,
- 'mfa_confirmed_at' => null,
- ]);
+ if (! $user->mfa_enabled) {
+ $user->update([
+ 'mfa_method' => MfaMethod::EMAIL->value,
+ 'mfa_confirmed_at' => null,
+ ]);
+ }
$this->sendEmailCode($user);
}
diff --git a/api/routes/api.php b/api/routes/api.php
index 8fe034c4..e9f51369 100644
--- a/api/routes/api.php
+++ b/api/routes/api.php
@@ -137,6 +137,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::post('auth/mfa/disable', [MfaSetupController::class, 'disable']);
Route::post('auth/mfa/backup-codes', [MfaSetupController::class, 'regenerateBackupCodes']);
Route::get('auth/mfa/status', [MfaSetupController::class, 'status']);
+ Route::put('auth/mfa/preferred-method', [MfaSetupController::class, 'setPreferredMethod']);
// Trusted devices
Route::get('auth/trusted-devices', [TrustedDeviceController::class, 'index']);
diff --git a/apps/app/src/components/account-settings/SecurityTab.vue b/apps/app/src/components/account-settings/SecurityTab.vue
index 0e6041e1..8a680de0 100644
--- a/apps/app/src/components/account-settings/SecurityTab.vue
+++ b/apps/app/src/components/account-settings/SecurityTab.vue
@@ -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() {
+
+
- Primaire methode -
-
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() {
+
+
- Primaire methode -
-
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
diff --git a/apps/app/src/composables/api/useMfa.ts b/apps/app/src/composables/api/useMfa.ts
index abb52781..de68cb33 100644
--- a/apps/app/src/composables/api/useMfa.ts
+++ b/apps/app/src/composables/api/useMfa.ts
@@ -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
diff --git a/apps/portal/src/types/mfa.ts b/apps/portal/src/types/mfa.ts
index 905cdd8f..3bbb2555 100644
--- a/apps/portal/src/types/mfa.ts
+++ b/apps/portal/src/types/mfa.ts
@@ -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 {