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>

View File

@@ -0,0 +1,173 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/lib/axios'
import type {
MfaConfirmResponse,
MfaStatus,
MfaTotpSetup,
MfaVerifyPayload,
TrustedDevice,
} from '@/types/mfa'
interface ApiResponse<T> {
success: boolean
data: T
message: string
}
// ─── Setup ───
export function useSetupTotp() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<MfaTotpSetup>>('/auth/mfa/setup/totp')
return data.data
},
})
}
export function useConfirmTotp() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (code: string) => {
const { data } = await apiClient.post<ApiResponse<MfaConfirmResponse>>('/auth/mfa/setup/totp/confirm', { code })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
export function useSetupEmail() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<null>>('/auth/mfa/setup/email')
return data
},
})
}
export function useConfirmEmail() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (code: string) => {
const { data } = await apiClient.post<ApiResponse<MfaConfirmResponse>>('/auth/mfa/setup/email/confirm', { code })
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
// ─── Management ───
export function useDisableMfa() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { code: string; method: string }) => {
const { data } = await apiClient.post<ApiResponse<null>>('/auth/mfa/disable', payload)
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
export function useRegenerateBackupCodes() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { code: string }) => {
const { data } = await apiClient.post<ApiResponse<{ backup_codes: string[] }>>('/auth/mfa/backup-codes', payload)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
},
})
}
export function useMfaStatus() {
return useQuery({
queryKey: ['mfa-status'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<MfaStatus>>('/auth/mfa/status')
return data.data
},
})
}
// ─── Trusted devices ───
export function useTrustedDevices() {
return useQuery({
queryKey: ['trusted-devices'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<TrustedDevice[]>>('/auth/trusted-devices')
return data.data
},
})
}
export function useRevokeDevice() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/auth/trusted-devices/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
},
})
}
export function useRevokeAllDevices() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
await apiClient.delete('/auth/trusted-devices')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] })
},
})
}
// ─── Login flow (no auth needed — uses session token) ───
export function useVerifyMfa() {
return useMutation({
mutationFn: async (payload: MfaVerifyPayload) => {
const { data } = await apiClient.post('/auth/mfa/verify', payload)
return data
},
})
}
export function useSendMfaEmailCode() {
return useMutation({
mutationFn: async (mfaSessionToken: string) => {
const { data } = await apiClient.post('/auth/mfa/email/send', {
mfa_session_token: mfaSessionToken,
})
return data
},
})
}

View File

@@ -1,6 +1,13 @@
<script setup lang="ts">
import authV1BottomShape from '@images/svg/auth-v1-bottom-shape.svg?raw'
import authV1TopShape from '@images/svg/auth-v1-top-shape.svg?raw'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { apiClient } from '@/lib/axios'
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
import type { MfaMethod } from '@/types/mfa'
definePage({
name: 'login',
@@ -26,6 +33,13 @@ const isSubmitting = ref(false)
const passwordResetDone = computed(() => route.query.reset === '1')
// MFA challenge state
const showMfaChallenge = ref(false)
const mfaSessionToken = ref('')
const mfaMethods = ref<MfaMethod[]>([])
const mfaPreferredMethod = ref<string>('')
const mfaExpiresIn = ref(0)
function mapLoginErrorMessage(message: string | undefined): string {
if (!message) return 'Inloggen mislukt. Controleer je gegevens.'
if (message === 'Invalid credentials' || message.toLowerCase().includes('invalid credentials'))
@@ -37,8 +51,29 @@ function mapLoginErrorMessage(message: string | undefined): string {
async function onSubmit(): Promise<void> {
errorMessage.value = ''
isSubmitting.value = true
try {
await authStore.login(form.value.email.trim(), form.value.password)
const fingerprint = generateDeviceFingerprint()
const { data } = await apiClient.post('/auth/login', {
email: form.value.email.trim(),
password: form.value.password,
}, {
headers: { 'X-Device-Fingerprint': fingerprint },
})
if (data.mfa_required) {
mfaSessionToken.value = data.mfa_session_token
mfaMethods.value = (data.methods ?? []) as MfaMethod[]
mfaPreferredMethod.value = data.preferred_method ?? 'totp'
mfaExpiresIn.value = data.expires_in ?? 600
showMfaChallenge.value = true
return
}
// Normal login
authStore.setUser(data.data.user)
await portalStore.hydrateAfterAuth()
}
catch (error: unknown) {
@@ -56,144 +91,175 @@ async function onSubmit(): Promise<void> {
isSubmitting.value = false
}
// Navigate after login — outside try/catch so navigation errors
// (e.g. stale dynamic imports) don't mask a successful login.
navigateAfterLogin()
}
function navigateAfterLogin() {
const rawRedirect = typeof route.query.to === 'string' ? route.query.to : ''
let redirect = rawRedirect.startsWith('/') ? rawRedirect : ''
// Smart redirect based on number of events
if (!redirect) {
const events = portalStore.userEvents
if (events.length === 1) {
if (events.length === 1)
redirect = `/evenementen/${events[0]!.event_id}`
}
else {
else
redirect = '/evenementen'
}
}
router.replace(redirect).catch(() => {
// Dynamic import can fail after Vite HMR; a full reload recovers.
window.location.href = redirect
})
}
async function onMfaVerified() {
await authStore.fetchUser()
await portalStore.hydrateAfterAuth()
navigateAfterLogin()
}
function onMfaCancelled() {
showMfaChallenge.value = false
mfaSessionToken.value = ''
}
</script>
<template>
<div class="login-page d-flex align-center justify-center" style="min-height: 100vh; background: #f5f5f5;">
<VCard
:max-width="440"
class="pa-6 pa-sm-8"
style="width: 100%;"
>
<!-- Crewli branding -->
<div class="text-center mb-6">
<RouterLink
to="/"
class="d-inline-flex align-center gap-x-2 text-decoration-none"
>
<VIcon
icon="tabler-users-group"
size="32"
color="primary"
/>
<span class="text-h4 font-weight-bold text-high-emphasis">
Crewli
</span>
</RouterLink>
</div>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<div class="position-relative my-sm-16">
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1TopShape })"
class="text-primary auth-v1-top-shape d-none d-sm-block"
/>
<VCardText class="pa-0">
<h4 class="text-h5 mb-1">
Welkom terug!
</h4>
<p class="text-body-2 text-medium-emphasis mb-6">
Log in om je rooster en diensten te bekijken
</p>
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1BottomShape })"
class="text-primary auth-v1-bottom-shape d-none d-sm-block"
/>
<VAlert
v-if="passwordResetDone"
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Wachtwoord gewijzigd. Je kunt nu inloggen.
</VAlert>
<!-- MFA Challenge -->
<MfaChallengeCard
v-if="showMfaChallenge"
:mfa-session-token="mfaSessionToken"
:methods="mfaMethods"
:preferred-method="mfaPreferredMethod"
:expires-in="mfaExpiresIn"
@verified="onMfaVerified"
@cancelled="onMfaCancelled"
/>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<VTextField
v-model="form.email"
autofocus
label="E-mailadres"
type="email"
placeholder="je@email.nl"
autocomplete="email"
variant="outlined"
density="comfortable"
hide-details="auto"
required
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="form.password"
label="Wachtwoord"
placeholder="Je wachtwoord"
autocomplete="current-password"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
required
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div class="d-flex align-center flex-wrap justify-end my-4">
<RouterLink
to="/wachtwoord-vergeten"
class="text-primary text-body-2"
>
Wachtwoord vergeten?
</RouterLink>
<!-- Login Card -->
<VCard
v-else
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-0'"
>
<VCardItem class="justify-center">
<VCardTitle>
<RouterLink to="/">
<div class="app-logo">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="app-logo-title">
{{ themeConfig.app.title }}
</h1>
</div>
</RouterLink>
</VCardTitle>
</VCardItem>
<VBtn
block
type="submit"
color="primary"
:loading="isSubmitting"
>
Inloggen
</VBtn>
</VCol>
</VRow>
</VForm>
<VCardText>
<h4 class="text-h4 mb-1">
Welkom terug!
</h4>
<p class="mb-0">
Log in om je rooster en diensten te bekijken
</p>
</VCardText>
<p class="text-body-2 text-center text-medium-emphasis mt-6 mb-0">
Nog geen account?
<RouterLink
to="/registreren"
class="text-primary font-weight-medium"
<VCardText>
<VAlert
v-if="passwordResetDone"
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Meld je aan als vrijwilliger
</RouterLink>
</p>
</VCardText>
</VCard>
Wachtwoord gewijzigd. Je kunt nu inloggen.
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.email"
autofocus
label="E-mailadres"
type="email"
placeholder="je@email.nl"
autocomplete="email"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.password"
label="Wachtwoord"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div class="d-flex align-center flex-wrap justify-end my-6">
<RouterLink
class="text-primary"
to="/wachtwoord-vergeten"
>
Wachtwoord vergeten?
</RouterLink>
</div>
<VBtn
block
type="submit"
:loading="isSubmitting"
>
Inloggen
</VBtn>
</VCol>
<VCol
cols="12"
class="text-body-1 text-center"
>
<span class="d-inline-block">Nog geen account? </span>
<RouterLink
class="text-primary ms-1 d-inline-block text-body-1"
to="/registreren"
>
Meld je aan als vrijwilliger
</RouterLink>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -2,7 +2,11 @@
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
import { useMfaStatus, useTrustedDevices, useRevokeDevice, useRevokeAllDevices } from '@/composables/api/useMfa'
import { apiClient } from '@/lib/axios'
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue'
definePage({
name: 'portal-profiel',
@@ -89,6 +93,36 @@ const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
// MFA
const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
const revokeDeviceMutation = useRevokeDevice()
const revokeAllMutation = useRevokeAllDevices()
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'
return null
})
function onMfaSetupCompleted() { refetchMfaStatus() }
function onMfaDisabled() { refetchMfaStatus(); refetchDevices() }
async function handleRevokeDevice(id: string) {
await revokeDeviceMutation.mutateAsync(id)
refetchDevices()
}
async function handleRevokeAllDevices() {
await revokeAllMutation.mutateAsync()
refetchDevices()
}
// Populate profile form from auth user / current person data
watch(
[() => authStore.user, () => portal.currentPerson],
@@ -477,6 +511,123 @@ async function savePassword() {
</VCardText>
</VCard>
<!-- Security / MFA -->
<VCard class="mb-4">
<VCardTitle class="d-flex align-center">
<VIcon
icon="tabler-shield-lock"
class="me-2"
/>
Beveiliging
</VCardTitle>
<VCardText>
<template v-if="!isMfaEnabled">
<p class="text-body-2 text-medium-emphasis mb-4">
Bescherm je account met tweestapsverificatie.
</p>
<div class="d-flex gap-2 flex-wrap">
<VBtn
variant="tonal"
prepend-icon="tabler-device-mobile"
@click="showTotpSetup = true"
>
Authenticator app
</VBtn>
<VBtn
variant="tonal"
prepend-icon="tabler-mail"
@click="showEmailSetup = true"
>
E-mailcode
</VBtn>
</div>
</template>
<template v-else>
<div class="d-flex align-center justify-space-between mb-3">
<div>
<VChip
color="success"
variant="tonal"
size="small"
class="me-2"
>
Ingeschakeld
</VChip>
<span class="text-body-2">via {{ mfaMethodLabel }}</span>
</div>
<VBtn
color="error"
variant="tonal"
size="small"
@click="showDisableDialog = true"
>
Uitschakelen
</VBtn>
</div>
<!-- Trusted devices -->
<template v-if="trustedDevices && trustedDevices.length > 0">
<p class="text-subtitle-2 mt-4 mb-2">
Vertrouwde apparaten
</p>
<VList density="compact">
<VListItem
v-for="device in trustedDevices"
:key="device.id"
class="px-0"
>
<VListItemTitle class="text-body-2">
{{ device.device_name ?? 'Onbekend apparaat' }}
</VListItemTitle>
<VListItemSubtitle class="text-caption">
IP: {{ device.ip_address }}
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
size="small"
color="error"
icon="tabler-trash"
@click="handleRevokeDevice(device.id)"
/>
</template>
</VListItem>
</VList>
<VBtn
v-if="trustedDevices.length > 1"
variant="tonal"
size="small"
color="error"
class="mt-2"
@click="handleRevokeAllDevices"
>
Alle apparaten intrekken
</VBtn>
</template>
</template>
</VCardText>
</VCard>
<!-- MFA setup dialogs -->
<MfaTotpSetupDialog
v-model="showTotpSetup"
@completed="onMfaSetupCompleted"
/>
<MfaEmailSetupDialog
v-model="showEmailSetup"
:user-email="authStore.user?.email ?? ''"
@completed="onMfaSetupCompleted"
/>
<MfaDisableDialog
v-model="showDisableDialog"
:current-method="mfaStatus?.method ?? null"
@disabled="onMfaDisabled"
/>
<!-- My events -->
<VCard v-if="portal.userEvents.length > 0">
<VCardTitle>Mijn evenementen</VCardTitle>

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import authV1BottomShape from '@images/svg/auth-v1-bottom-shape.svg?raw'
import authV1TopShape from '@images/svg/auth-v1-top-shape.svg?raw'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
@@ -19,9 +23,11 @@ const errorMessage = ref('')
onMounted(async () => {
const token = route.query.token as string
if (!token) {
errorMessage.value = 'Geen verificatietoken gevonden.'
isVerifying.value = false
return
}
try {
@@ -31,6 +37,7 @@ onMounted(async () => {
}
catch (error: unknown) {
const ax = error as { response?: { data?: { errors?: Record<string, string[]>; message?: string } } }
errorMessage.value = ax.response?.data?.errors?.token?.[0]
?? ax.response?.data?.errors?.new_email?.[0]
?? ax.response?.data?.message
@@ -43,72 +50,90 @@ onMounted(async () => {
</script>
<template>
<div class="d-flex align-center justify-center pa-4" style="min-height: 100vh; background: #f5f5f5;">
<VCard
:max-width="450"
width="100%"
class="pa-6"
>
<div class="text-center mb-4">
<VIcon
icon="tabler-users-group"
size="32"
color="primary"
/>
<span class="text-h5 font-weight-bold text-high-emphasis ms-2">
Crewli
</span>
</div>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<div class="position-relative my-sm-16">
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1TopShape })"
class="text-primary auth-v1-top-shape d-none d-sm-block"
/>
<VCardText class="text-center">
<template v-if="isVerifying">
<VProgressCircular
indeterminate
color="primary"
class="mb-4"
/>
<p>E-mailadres wordt geverifieerd...</p>
</template>
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1BottomShape })"
class="text-primary auth-v1-bottom-shape d-none d-sm-block"
/>
<template v-else-if="success">
<VIcon
size="64"
color="success"
class="mb-4"
>
tabler-circle-check
</VIcon>
<h4 class="text-h5 mb-2">
E-mailadres gewijzigd!
</h4>
<p class="text-body-2 text-medium-emphasis mb-4">
Je e-mailadres is succesvol gewijzigd.
Log opnieuw in met je nieuwe e-mailadres.
</p>
<VBtn
color="primary"
to="/login"
>
Ga naar inloggen
</VBtn>
</template>
<VCard
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-2'"
>
<VCardItem class="justify-center">
<VCardTitle>
<RouterLink to="/">
<div class="app-logo">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="app-logo-title">
{{ themeConfig.app.title }}
</h1>
</div>
</RouterLink>
</VCardTitle>
</VCardItem>
<template v-else>
<VIcon
size="64"
color="error"
class="mb-4"
>
tabler-circle-x
</VIcon>
<h4 class="text-h5 mb-2">
Verificatie mislukt
</h4>
<p class="text-body-2 text-medium-emphasis">
{{ errorMessage }}
</p>
</template>
</VCardText>
</VCard>
<VCardText class="text-center">
<template v-if="isVerifying">
<VProgressCircular
indeterminate
color="primary"
class="mb-4"
/>
<p>E-mailadres wordt geverifieerd...</p>
</template>
<template v-else-if="success">
<VIcon
size="64"
color="success"
class="mb-4"
>
tabler-circle-check
</VIcon>
<h4 class="text-h4 mb-2">
E-mailadres gewijzigd!
</h4>
<p class="text-body-1 mb-5">
Je e-mailadres is succesvol gewijzigd.
Log opnieuw in met je nieuwe e-mailadres.
</p>
<VBtn
block
to="/login"
>
Ga naar inloggen
</VBtn>
</template>
<template v-else>
<VIcon
size="64"
color="error"
class="mb-4"
>
tabler-circle-x
</VIcon>
<h4 class="text-h4 mb-2">
Verificatie mislukt
</h4>
<p class="text-body-1 text-medium-emphasis">
{{ errorMessage }}
</p>
</template>
</VCardText>
</VCard>
</div>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import authV1BottomShape from '@images/svg/auth-v1-bottom-shape.svg?raw'
import authV1TopShape from '@images/svg/auth-v1-top-shape.svg?raw'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
definePage({
@@ -41,6 +45,7 @@ async function onSubmit(): Promise<void> {
}
catch (error: unknown) {
const ax = error as { response?: { status?: number; data?: { message?: string } } }
if (ax.response?.status === 404 || ax.response?.status === 422)
errorMessage.value = ax.response?.data?.message ?? 'Resetlink ongeldig of verlopen. Vraag een nieuwe link aan.'
else
@@ -53,87 +58,113 @@ async function onSubmit(): Promise<void> {
</script>
<template>
<div class="auth-wrapper d-flex align-center justify-center pa-4 bg-surface">
<VCard
flat
:max-width="480"
width="100%"
class="pa-6"
>
<VCardTitle class="text-h5 px-0 pt-0">
Nieuw wachtwoord
</VCardTitle>
<VCardSubtitle class="px-0">
Kies een nieuw wachtwoord voor je account.
</VCardSubtitle>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<div class="position-relative my-sm-16">
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1TopShape })"
class="text-primary auth-v1-top-shape d-none d-sm-block"
/>
<VCardText class="px-0">
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</VAlert>
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1BottomShape })"
class="text-primary auth-v1-bottom-shape d-none d-sm-block"
/>
<VForm @submit.prevent="onSubmit">
<VTextField
v-model="email"
label="E-mailadres"
type="email"
variant="outlined"
density="comfortable"
class="mb-3"
autocomplete="email"
hide-details="auto"
required
/>
<VTextField
v-model="password"
label="Nieuw wachtwoord"
variant="outlined"
density="comfortable"
class="mb-3"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'tabler-eye-off' : 'tabler-eye'"
hide-details="auto"
autocomplete="new-password"
required
@click:append-inner="showPassword = !showPassword"
/>
<VTextField
v-model="passwordConfirmation"
label="Bevestig wachtwoord"
variant="outlined"
density="comfortable"
<VCard
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-2'"
>
<VCardItem class="justify-center">
<VCardTitle>
<RouterLink to="/">
<div class="app-logo">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="app-logo-title">
{{ themeConfig.app.title }}
</h1>
</div>
</RouterLink>
</VCardTitle>
</VCardItem>
<VCardText>
<h4 class="text-h4 mb-1">
Nieuw wachtwoord instellen
</h4>
<p class="mb-0">
Kies een nieuw wachtwoord voor je account.
</p>
</VCardText>
<VCardText>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
:type="showPasswordConfirmation ? 'text' : 'password'"
:append-inner-icon="showPasswordConfirmation ? 'tabler-eye-off' : 'tabler-eye'"
hide-details="auto"
autocomplete="new-password"
required
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
<VBtn
type="submit"
color="primary"
block
:loading="isSubmitting"
>
Wachtwoord opslaan
</VBtn>
</VForm>
{{ errorMessage }}
</VAlert>
<div class="text-center mt-4">
<RouterLink
to="/login"
class="text-body-2 text-primary"
>
Terug naar inloggen
</RouterLink>
</div>
</VCardText>
</VCard>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="password"
autofocus
label="Nieuw wachtwoord"
placeholder="············"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
:append-inner-icon="showPassword ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="showPassword = !showPassword"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="passwordConfirmation"
label="Bevestig wachtwoord"
placeholder="············"
:type="showPasswordConfirmation ? 'text' : 'password'"
autocomplete="new-password"
:append-inner-icon="showPasswordConfirmation ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
</VCol>
<VCol cols="12">
<VBtn
block
type="submit"
:loading="isSubmitting"
>
Wachtwoord opslaan
</VBtn>
</VCol>
<VCol cols="12">
<RouterLink
class="d-flex align-center justify-center"
to="/login"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1 flip-in-rtl"
/>
<span>Terug naar inloggen</span>
</RouterLink>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import authV1BottomShape from '@images/svg/auth-v1-bottom-shape.svg?raw'
import authV1TopShape from '@images/svg/auth-v1-top-shape.svg?raw'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
definePage({
@@ -19,7 +23,7 @@ async function onSubmit(): Promise<void> {
await apiClient.post('/auth/forgot-password', { email: email.value.trim(), app: 'portal' })
}
catch {
// Endpoint may not exist yet — still show generic success (no email enumeration)
// Always show generic success (no email enumeration)
}
finally {
isSubmitting.value = false
@@ -29,64 +33,101 @@ async function onSubmit(): Promise<void> {
</script>
<template>
<div class="auth-wrapper d-flex align-center justify-center pa-4 bg-surface">
<VCard
flat
:max-width="480"
width="100%"
class="pa-6"
>
<VCardTitle class="text-h5 px-0 pt-0">
Wachtwoord vergeten
</VCardTitle>
<VCardSubtitle class="px-0 text-wrap">
Vul je e-mailadres in. Als dit adres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
</VCardSubtitle>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<div class="position-relative my-sm-16">
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1TopShape })"
class="text-primary auth-v1-top-shape d-none d-sm-block"
/>
<VCardText class="px-0">
<VAlert
v-if="done"
type="success"
variant="tonal"
class="mb-4"
>
Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
</VAlert>
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1BottomShape })"
class="text-primary auth-v1-bottom-shape d-none d-sm-block"
/>
<VForm
v-else
@submit.prevent="onSubmit"
>
<VTextField
v-model="email"
label="E-mailadres"
type="email"
variant="outlined"
density="comfortable"
<VCard
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-0'"
>
<VCardItem class="justify-center">
<VCardTitle>
<RouterLink to="/">
<div class="app-logo">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="app-logo-title">
{{ themeConfig.app.title }}
</h1>
</div>
</RouterLink>
</VCardTitle>
</VCardItem>
<VCardText>
<h4 class="text-h4 mb-1">
Wachtwoord vergeten?
</h4>
<p class="mb-0">
Vul je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.
</p>
</VCardText>
<VCardText>
<VAlert
v-if="done"
type="success"
variant="tonal"
class="mb-4"
autocomplete="email"
hide-details="auto"
required
/>
<VBtn
type="submit"
color="primary"
block
:loading="isSubmitting"
>
Versturen
</VBtn>
</VForm>
Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
</VAlert>
<div class="text-center mt-4">
<RouterLink
to="/login"
class="text-body-2 text-primary"
<VForm
v-if="!done"
@submit.prevent="onSubmit"
>
Terug naar inloggen
</RouterLink>
</div>
</VCardText>
</VCard>
<VRow>
<VCol cols="12">
<AppTextField
v-model="email"
autofocus
label="E-mailadres"
type="email"
placeholder="je@email.nl"
/>
</VCol>
<VCol cols="12">
<VBtn
block
type="submit"
:loading="isSubmitting"
>
Verstuur herstelmail
</VBtn>
</VCol>
<VCol cols="12">
<RouterLink
class="d-flex align-center justify-center"
to="/login"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1 flip-in-rtl"
/>
<span>Terug naar inloggen</span>
</RouterLink>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -0,0 +1,61 @@
export const MfaMethod = {
TOTP: 'totp',
EMAIL: 'email',
BACKUP_CODE: 'backup_code',
} as const
export type MfaMethod = typeof MfaMethod[keyof typeof MfaMethod]
export interface MfaStatus {
enabled: boolean
method: 'totp' | 'email' | null
confirmed_at: string | null
backup_codes_remaining: number
is_required: boolean
}
export interface MfaUserStatus {
enabled: boolean
method: 'totp' | 'email' | null
confirmed_at: string | null
setup_required: boolean
}
export interface MfaTotpSetup {
secret: string
qr_code_url: string
provisioning_uri: string
}
export interface MfaSessionResponse {
success: true
mfa_required: true
mfa_session_token: string
methods: MfaMethod[]
preferred_method: 'totp' | 'email'
expires_in: number
}
export interface MfaConfirmResponse {
mfa_enabled: boolean
method: 'totp' | 'email'
backup_codes: string[]
}
export interface TrustedDevice {
id: string
device_name: string | null
ip_address: string
trusted_until: string
last_used_at: string | null
created_at: string
}
export interface MfaVerifyPayload {
mfa_session_token: string
code: string
method: MfaMethod
trust_device?: boolean
device_fingerprint?: string
device_name?: string
}

View File

@@ -37,6 +37,12 @@ export interface AuthMeUser {
roles?: string[]
permissions?: string[]
portal_events?: PortalEvent[]
mfa?: {
enabled: boolean
method: 'totp' | 'email' | null
confirmed_at: string | null
setup_required: boolean
}
}
/** GET /portal/me?event_id= — person payload (subset used by dashboard) */

View File

@@ -0,0 +1,49 @@
export function generateDeviceFingerprint(): string {
const components = [
navigator.userAgent,
`${screen.width}x${screen.height}`,
Intl.DateTimeFormat().resolvedOptions().timeZone,
navigator.language,
navigator.hardwareConcurrency?.toString() ?? '',
]
const str = components.join('|')
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return Math.abs(hash).toString(36)
}
export function getDeviceName(): string {
const ua = navigator.userAgent
let browser = 'Onbekend'
let os = 'Onbekend'
if (ua.includes('Chrome') && !ua.includes('Edg'))
browser = 'Chrome'
else if (ua.includes('Firefox'))
browser = 'Firefox'
else if (ua.includes('Safari') && !ua.includes('Chrome'))
browser = 'Safari'
else if (ua.includes('Edg'))
browser = 'Edge'
if (ua.includes('Mac OS'))
os = 'macOS'
else if (ua.includes('Windows'))
os = 'Windows'
else if (ua.includes('Linux'))
os = 'Linux'
else if (ua.includes('Android'))
os = 'Android'
else if (ua.includes('iPhone') || ua.includes('iPad'))
os = 'iOS'
return `${browser} op ${os}`
}