feat: MFA frontend with auth page restyling, challenge screen, and setup wizard

- Restyle organizer auth pages: Dutch text, remove placeholder social login
- Restyle portal auth pages to Vuexy v1 centered card pattern with decorative shapes
- MFA challenge card component with VOtpInput, method tabs, backup code input,
  trusted device checkbox, and session countdown timer
- Login pages handle mfa_required response with device fingerprint header
- Security settings page with TOTP setup (QR code), email setup, disable MFA,
  backup codes regeneration, and trusted devices management
- Portal profile page includes MFA security section
- Admin user detail page shows MFA status with reset button
- MFA enforcement route guard redirects to security settings when required
- Device fingerprint utility for trusted device identification
- MFA types, composables with TanStack Query for both apps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 21:32:17 +02:00
parent a9e8e9bb62
commit 0be2956ea4
38 changed files with 3991 additions and 377 deletions

View File

@@ -4,6 +4,7 @@ import {
useUpdateAdminUser,
useStartImpersonation,
} from '@/composables/api/useAdmin'
import { useAdminResetMfa } from '@/composables/api/useMfa'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
@@ -86,6 +87,21 @@ function confirmImpersonate() {
})
}
// MFA Reset
const isMfaResetDialogOpen = ref(false)
const { mutate: resetMfa, isPending: isResettingMfa } = useAdminResetMfa()
const showMfaResetSuccess = ref(false)
function confirmMfaReset() {
resetMfa(userId.value, {
onSuccess: () => {
isMfaResetDialogOpen.value = false
showMfaResetSuccess.value = true
refetch()
},
})
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
@@ -253,6 +269,30 @@ function getInitials(name: string): string {
{{ user.email_verified_at ? formatDate(user.email_verified_at) : 'Niet geverifieerd' }}
</p>
</VCol>
<VCol cols="6">
<p class="text-body-2 text-disabled mb-1">
Tweestapsverificatie
</p>
<p class="text-body-1">
<VChip
:color="user.mfa_enabled ? 'success' : 'default'"
size="small"
variant="tonal"
>
{{ user.mfa_enabled ? 'Ingeschakeld' : 'Uitgeschakeld' }}
</VChip>
<VBtn
v-if="user.mfa_enabled"
variant="tonal"
color="error"
size="small"
class="ms-2"
@click="isMfaResetDialogOpen = true"
>
MFA resetten
</VBtn>
</p>
</VCol>
</VRow>
</VCardText>
</VCard>
@@ -404,6 +444,41 @@ function getInitials(name: string): string {
</VCard>
</VDialog>
<!-- MFA Reset Dialog -->
<VDialog
v-model="isMfaResetDialogOpen"
max-width="460"
>
<VCard title="MFA resetten">
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
Weet je zeker dat je de tweestapsverificatie van <strong>{{ user?.full_name }}</strong> wilt uitschakelen?
De gebruiker moet MFA opnieuw instellen bij de volgende login.
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isMfaResetDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isResettingMfa"
@click="confirmMfaReset"
>
MFA resetten
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Success snackbar -->
<VSnackbar
v-model="showEditSuccess"
@@ -412,5 +487,13 @@ function getInitials(name: string): string {
>
Gebruiker bijgewerkt
</VSnackbar>
<VSnackbar
v-model="showMfaResetSuccess"
color="success"
:timeout="3000"
>
MFA is gereset voor deze gebruiker
</VSnackbar>
</div>
</template>