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

@@ -0,0 +1,283 @@
<script setup lang="ts">
import { useVerifyMfa, useSendMfaEmailCode } from '@/composables/api/useMfa'
import { generateDeviceFingerprint, getDeviceName } from '@/utils/deviceFingerprint'
import type { MfaMethod } from '@/types/mfa'
const props = defineProps<{
mfaSessionToken: string
methods: MfaMethod[]
preferredMethod: string
expiresIn: number
}>()
const emit = defineEmits<{
verified: [data: unknown]
cancelled: []
}>()
const verifyMutation = useVerifyMfa()
const sendEmailMutation = useSendMfaEmailCode()
const selectedMethod = ref<string>(props.preferredMethod)
const otpCode = ref('')
const backupCode = ref('')
const trustDevice = ref(false)
const errorMessage = ref('')
const timeLeft = ref(props.expiresIn)
const isBackupMethod = computed(() => selectedMethod.value === 'backup_code')
// Countdown timer
const countdownInterval = setInterval(() => {
timeLeft.value--
if (timeLeft.value <= 0) {
clearInterval(countdownInterval)
errorMessage.value = 'MFA-sessie verlopen. Log opnieuw in.'
}
}, 1000)
onUnmounted(() => {
clearInterval(countdownInterval)
})
const formattedTimeLeft = computed(() => {
const minutes = Math.floor(timeLeft.value / 60)
const seconds = timeLeft.value % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
const methodTabs = computed(() => {
const tabs: Array<{ value: string; title: string; icon: string }> = []
if (props.methods.includes('totp' as MfaMethod))
tabs.push({ value: 'totp', title: 'Authenticator', icon: 'tabler-device-mobile' })
if (props.methods.includes('email' as MfaMethod))
tabs.push({ value: 'email', title: 'E-mailcode', icon: 'tabler-mail' })
if (props.methods.includes('backup_code' as MfaMethod))
tabs.push({ value: 'backup_code', title: 'Backup code', icon: 'tabler-key' })
return tabs
})
function onMethodChange(method: string) {
errorMessage.value = ''
otpCode.value = ''
backupCode.value = ''
if (method === 'email')
handleSendEmailCode()
}
async function handleSendEmailCode() {
try {
await sendEmailMutation.mutateAsync(props.mfaSessionToken)
}
catch {
// Rate limited or other error — user can try resend link
}
}
function onOtpFinish(code: string) {
otpCode.value = code
handleVerify()
}
async function handleVerify() {
errorMessage.value = ''
const code = isBackupMethod.value ? backupCode.value : otpCode.value
if (!code) return
try {
const data = await verifyMutation.mutateAsync({
mfa_session_token: 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,
})
emit('verified', data)
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Verificatie mislukt. Probeer het opnieuw.'
otpCode.value = ''
backupCode.value = ''
}
}
</script>
<template>
<VCard
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-0'"
>
<VCardText>
<h4 class="text-h4 mb-1">
Tweestapsverificatie
</h4>
<p class="mb-0">
Voer je verificatiecode in om in te loggen
</p>
<VChip
v-if="timeLeft > 0"
size="small"
color="secondary"
variant="tonal"
class="mt-2"
>
<VIcon
start
icon="tabler-clock"
size="14"
/>
{{ formattedTimeLeft }}
</VChip>
</VCardText>
<VCardText>
<!-- Method tabs -->
<VTabs
v-if="methodTabs.length > 1"
v-model="selectedMethod"
class="mb-4"
density="comfortable"
@update:model-value="(v: unknown) => onMethodChange(String(v))"
>
<VTab
v-for="tab in methodTabs"
:key="tab.value"
:value="tab.value"
>
<VIcon
:icon="tab.icon"
size="20"
class="me-1"
/>
{{ tab.title }}
</VTab>
</VTabs>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="handleVerify">
<VRow>
<!-- OTP input for TOTP and email methods -->
<VCol
v-if="!isBackupMethod"
cols="12"
>
<p class="text-body-2 mb-2">
{{
selectedMethod === 'totp'
? 'Voer de 6-cijferige code in uit je authenticator app'
: 'Voer de 6-cijferige code in die naar je e-mailadres is gestuurd'
}}
</p>
<VOtpInput
v-model="otpCode"
:disabled="verifyMutation.isPending.value || timeLeft <= 0"
type="number"
class="pa-0"
@finish="onOtpFinish"
/>
</VCol>
<!-- Backup code input -->
<VCol
v-else
cols="12"
>
<p class="text-body-2 mb-2">
Voer een van je backup codes in (formaat: XXXX-XXXX)
</p>
<AppTextField
v-model="backupCode"
placeholder="XXXX-XXXX"
autofocus
class="text-center"
/>
</VCol>
<!-- Trust device -->
<VCol cols="12">
<VCheckbox
v-model="trustDevice"
label="Onthoud dit apparaat voor 30 dagen"
density="compact"
/>
</VCol>
<!-- Verify button -->
<VCol cols="12">
<VBtn
block
type="submit"
:loading="verifyMutation.isPending.value"
:disabled="timeLeft <= 0"
>
Verifi&euml;ren
</VBtn>
</VCol>
<!-- Resend email code -->
<VCol
v-if="selectedMethod === 'email'"
cols="12"
class="text-center"
>
<div class="d-flex justify-center align-center flex-wrap">
<span class="me-1 text-body-2">Geen code ontvangen?</span>
<a
class="text-primary text-body-2"
href="#"
@click.prevent="handleSendEmailCode"
>
Opnieuw versturen
</a>
</div>
</VCol>
<!-- Back to login -->
<VCol cols="12">
<a
class="d-flex align-center justify-center text-body-2"
href="#"
@click.prevent="emit('cancelled')"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1 flip-in-rtl"
/>
<span>Terug naar inloggen</span>
</a>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</template>
<style lang="scss">
.v-otp-input {
.v-otp-input__content {
padding-inline: 0;
}
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { useDisableMfa } from '@/composables/api/useMfa'
const props = defineProps<{
modelValue: boolean
currentMethod: 'totp' | 'email' | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
disabled: []
}>()
const disableMutation = useDisableMfa()
const code = ref('')
const method = ref<string>(props.currentMethod === 'totp' ? 'totp' : 'backup_code')
const errorMessage = ref('')
const isDialogOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
watch(isDialogOpen, (open) => {
if (open) {
code.value = ''
errorMessage.value = ''
method.value = props.currentMethod === 'totp' ? 'totp' : 'backup_code'
}
})
async function handleDisable() {
errorMessage.value = ''
try {
await disableMutation.mutateAsync({ code: code.value, method: method.value })
isDialogOpen.value = false
emit('disabled')
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Kon MFA niet uitschakelen. Probeer het opnieuw.'
code.value = ''
}
}
</script>
<template>
<VDialog
v-model="isDialogOpen"
max-width="460"
>
<VCard>
<VCardTitle class="d-flex align-center pt-4">
<VIcon
icon="tabler-shield-off"
color="error"
class="me-2"
/>
Tweestapsverificatie uitschakelen
</VCardTitle>
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
Weet je zeker dat je tweestapsverificatie wilt uitschakelen? Je account wordt minder veilig.
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="handleDisable">
<p class="text-body-2 mb-2">
Voer je {{ currentMethod === 'totp' ? 'authenticator code' : 'backup code' }} in ter bevestiging
</p>
<AppTextField
v-model="code"
:placeholder="currentMethod === 'totp' ? '123456' : 'XXXX-XXXX'"
autofocus
/>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="disableMutation.isPending.value"
:disabled="!code"
@click="handleDisable"
>
Uitschakelen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,276 @@
<script setup lang="ts">
import { useSetupEmail, useConfirmEmail } from '@/composables/api/useMfa'
const props = defineProps<{
modelValue: boolean
userEmail: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
completed: []
}>()
const setupMutation = useSetupEmail()
const confirmMutation = useConfirmEmail()
const step = ref(1)
const confirmCode = ref('')
const backupCodes = ref<string[]>([])
const errorMessage = ref('')
const savedBackupCodes = ref(false)
const codeSent = ref(false)
const isDialogOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
watch(isDialogOpen, (open) => {
if (open) {
step.value = 1
errorMessage.value = ''
confirmCode.value = ''
backupCodes.value = []
savedBackupCodes.value = false
codeSent.value = false
}
})
async function handleSendCode() {
errorMessage.value = ''
try {
await setupMutation.mutateAsync()
codeSent.value = true
step.value = 2
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Kon geen code versturen. Probeer het opnieuw.'
}
}
async function handleConfirm() {
errorMessage.value = ''
try {
const data = await confirmMutation.mutateAsync(confirmCode.value)
backupCodes.value = data.backup_codes
step.value = 3
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Ongeldige code. Probeer het opnieuw.'
confirmCode.value = ''
}
}
function copyBackupCodes() {
const text = backupCodes.value.join('\n')
navigator.clipboard.writeText(text)
}
function downloadBackupCodes() {
const text = `Crewli MFA Backup Codes\n${'='.repeat(30)}\n\n${backupCodes.value.join('\n')}\n\nBewaar deze codes op een veilige plek.\nElke code kan slechts eenmaal worden gebruikt.`
const blob = new Blob([text], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'crewli-backup-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
function handleComplete() {
isDialogOpen.value = false
emit('completed')
}
</script>
<template>
<VDialog
v-model="isDialogOpen"
max-width="520"
persistent
>
<VCard>
<VCardTitle class="d-flex align-center pt-4">
<VIcon
icon="tabler-mail-code"
class="me-2"
/>
E-mailverificatie instellen
</VCardTitle>
<!-- Step 1: Send code -->
<template v-if="step === 1">
<VCardText>
<p class="text-body-1 mb-4">
We sturen een verificatiecode naar <strong>{{ userEmail }}</strong>
</p>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="setupMutation.isPending.value"
@click="handleSendCode"
>
Code versturen
</VBtn>
</VCardActions>
</template>
<!-- Step 2: Verify code -->
<template v-if="step === 2">
<VCardText>
<VAlert
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Code verstuurd! Check je inbox.
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<p class="text-body-2 mb-2">
Voer de 6-cijferige code in
</p>
<VOtpInput
v-model="confirmCode"
type="number"
class="pa-0"
:disabled="confirmMutation.isPending.value"
@finish="handleConfirm"
/>
<div class="text-center mt-4">
<a
class="text-primary text-body-2"
href="#"
@click.prevent="handleSendCode"
>
Code opnieuw versturen
</a>
</div>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="step = 1"
>
Terug
</VBtn>
<VBtn
color="primary"
:loading="confirmMutation.isPending.value"
:disabled="confirmCode.length < 6"
@click="handleConfirm"
>
Verifi&euml;ren
</VBtn>
</VCardActions>
</template>
<!-- Step 3: Backup codes (same as TOTP) -->
<template v-if="step === 3">
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je e-mail.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in backupCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold text-body-1"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<div class="d-flex gap-2 mb-4">
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyBackupCodes"
>
Kopieer
</VBtn>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-download"
@click="downloadBackupCodes"
>
Download
</VBtn>
</div>
<VCheckbox
v-model="savedBackupCodes"
label="Ik heb mijn backup codes veilig opgeslagen"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
color="primary"
:disabled="!savedBackupCodes"
@click="handleComplete"
>
Voltooien
</VBtn>
</VCardActions>
</template>
</VCard>
</VDialog>
</template>
<style lang="scss">
.v-otp-input .v-otp-input__content {
padding-inline: 0;
}
</style>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import QRCode from 'qrcode'
import { useSetupTotp, useConfirmTotp } from '@/composables/api/useMfa'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
completed: []
}>()
const setupMutation = useSetupTotp()
const confirmMutation = useConfirmTotp()
const step = ref(1)
const qrDataUrl = ref('')
const secret = ref('')
const confirmCode = ref('')
const backupCodes = ref<string[]>([])
const errorMessage = ref('')
const savedBackupCodes = ref(false)
const isDialogOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
watch(isDialogOpen, async (open) => {
if (open) {
step.value = 1
errorMessage.value = ''
confirmCode.value = ''
backupCodes.value = []
savedBackupCodes.value = false
await startSetup()
}
})
async function startSetup() {
try {
const data = await setupMutation.mutateAsync()
secret.value = data.secret
qrDataUrl.value = await QRCode.toDataURL(data.provisioning_uri, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
})
}
catch {
errorMessage.value = 'Kon TOTP setup niet starten. Probeer het opnieuw.'
}
}
async function handleConfirm() {
errorMessage.value = ''
try {
const data = await confirmMutation.mutateAsync(confirmCode.value)
backupCodes.value = data.backup_codes
step.value = 3
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Ongeldige code. Probeer het opnieuw.'
confirmCode.value = ''
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
}
function copyBackupCodes() {
const text = backupCodes.value.join('\n')
navigator.clipboard.writeText(text)
}
function downloadBackupCodes() {
const text = `Crewli MFA Backup Codes\n${'='.repeat(30)}\n\n${backupCodes.value.join('\n')}\n\nBewaar deze codes op een veilige plek.\nElke code kan slechts eenmaal worden gebruikt.`
const blob = new Blob([text], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'crewli-backup-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
function handleComplete() {
isDialogOpen.value = false
emit('completed')
}
</script>
<template>
<VDialog
v-model="isDialogOpen"
max-width="520"
persistent
>
<VCard>
<VCardTitle class="d-flex align-center pt-4">
<VIcon
icon="tabler-shield-lock"
class="me-2"
/>
Authenticator app instellen
</VCardTitle>
<!-- Step 1: QR Code -->
<template v-if="step === 1">
<VCardText>
<p class="text-body-1 mb-4">
Scan de QR-code met je authenticator app (Google Authenticator, Authy, etc.)
</p>
<div
v-if="qrDataUrl"
class="d-flex justify-center mb-4"
>
<img
:src="qrDataUrl"
alt="QR Code"
width="256"
height="256"
style="border-radius: 8px;"
>
</div>
<VAlert
v-if="setupMutation.isPending.value"
type="info"
variant="tonal"
class="mb-4"
>
QR-code wordt gegenereerd...
</VAlert>
<VExpansionPanels variant="accordion">
<VExpansionPanel title="Kun je niet scannen? Voer deze code handmatig in:">
<VExpansionPanelText>
<AppTextField
:model-value="secret"
readonly
class="font-weight-bold"
append-inner-icon="tabler-copy"
@click:append-inner="() => copyToClipboard(secret)"
/>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:disabled="!qrDataUrl"
@click="step = 2"
>
Volgende
</VBtn>
</VCardActions>
</template>
<!-- Step 2: Verify code -->
<template v-if="step === 2">
<VCardText>
<p class="text-body-1 mb-4">
Open je authenticator app en voer de 6-cijferige code in
</p>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VOtpInput
v-model="confirmCode"
type="number"
class="pa-0"
:disabled="confirmMutation.isPending.value"
@finish="handleConfirm"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="step = 1"
>
Terug
</VBtn>
<VBtn
color="primary"
:loading="confirmMutation.isPending.value"
:disabled="confirmCode.length < 6"
@click="handleConfirm"
>
Verifi&euml;ren
</VBtn>
</VCardActions>
</template>
<!-- Step 3: Backup codes -->
<template v-if="step === 3">
<VCardText>
<VAlert
type="warning"
variant="tonal"
class="mb-4"
>
Bewaar deze codes op een veilige plek. Je kunt ze gebruiken als je geen toegang hebt tot je authenticator app.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in backupCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold text-body-1"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<div class="d-flex gap-2 mb-4">
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyBackupCodes"
>
Kopieer
</VBtn>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-download"
@click="downloadBackupCodes"
>
Download
</VBtn>
</div>
<VCheckbox
v-model="savedBackupCodes"
label="Ik heb mijn backup codes veilig opgeslagen"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
color="primary"
:disabled="!savedBackupCodes"
@click="handleComplete"
>
Voltooien
</VBtn>
</VCardActions>
</template>
</VCard>
</VDialog>
</template>
<style lang="scss">
.v-otp-input .v-otp-input__content {
padding-inline: 0;
}
</style>