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,188 @@
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'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
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'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
// ─── 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'] })
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
},
})
}
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
},
})
}
// ─── Admin ───
export function useAdminResetMfa() {
return useMutation({
mutationFn: async (userId: string) => {
const { data } = await apiClient.post(`/admin/users/${userId}/reset-mfa`)
return data
},
})
}

View File

@@ -87,6 +87,17 @@ function handleLogout() {
<VListItemTitle>Accountinstellingen</VListItemTitle>
</VListItem>
<VListItem :to="{ name: 'account-settings-security' }">
<template #prepend>
<VIcon
class="me-2"
icon="tabler-shield-lock"
size="22"
/>
</template>
<VListItemTitle>Beveiliging</VListItemTitle>
</VListItem>
<VListItem
:disabled="isLoggingOut"
@click="handleLogout"

View File

@@ -0,0 +1,398 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import {
useMfaStatus,
useTrustedDevices,
useRevokeDevice,
useRevokeAllDevices,
useRegenerateBackupCodes,
} from '@/composables/api/useMfa'
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue'
definePage({
meta: {
navActiveLink: 'account-settings',
},
})
const authStore = useAuthStore()
const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
const revokeDeviceMutation = useRevokeDevice()
const revokeAllMutation = useRevokeAllDevices()
const regenerateCodesMutation = useRegenerateBackupCodes()
const showTotpSetup = ref(false)
const showEmailSetup = ref(false)
const showDisableDialog = ref(false)
const showRegenerateDialog = ref(false)
const regenerateCode = ref('')
const regeneratedCodes = ref<string[]>([])
const regenerateError = ref('')
const isEnabled = computed(() => mfaStatus.value?.enabled ?? false)
const methodLabel = computed(() => {
if (mfaStatus.value?.method === 'totp') return 'Authenticator app'
if (mfaStatus.value?.method === 'email') return 'E-mailcode'
return null
})
function onSetupCompleted() {
refetchMfaStatus()
}
function onDisabled() {
refetchMfaStatus()
refetchDevices()
}
async function handleRevokeDevice(id: string) {
await revokeDeviceMutation.mutateAsync(id)
refetchDevices()
}
async function handleRevokeAllDevices() {
await revokeAllMutation.mutateAsync()
refetchDevices()
}
async function handleRegenerateBackupCodes() {
regenerateError.value = ''
try {
const data = await regenerateCodesMutation.mutateAsync({ code: regenerateCode.value })
regeneratedCodes.value = data.backup_codes
refetchMfaStatus()
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } } }
regenerateError.value = ax.response?.data?.message ?? 'Kon codes niet genereren.'
regenerateCode.value = ''
}
}
function copyRegeneratedCodes() {
navigator.clipboard.writeText(regeneratedCodes.value.join('\n'))
}
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
>
<h4 class="text-h4 mb-6">
Beveiliging
</h4>
<!-- Enforcement notice -->
<VAlert
v-if="mfaStatus?.is_required && !isEnabled"
type="warning"
variant="tonal"
class="mb-6"
>
Je organisatie vereist tweestapsverificatie. Stel het nu in om het platform te kunnen gebruiken.
</VAlert>
<!-- Section 1: Tweestapsverificatie -->
<VCard class="mb-6">
<VCardTitle class="d-flex align-center">
<VIcon
icon="tabler-shield-lock"
class="me-2"
/>
Tweestapsverificatie
</VCardTitle>
<VCardText>
<!-- MFA NOT enabled -->
<template v-if="!isEnabled">
<p class="text-body-1 mb-4">
Bescherm je account met een extra beveiligingslaag. Kies een methode:
</p>
<VRow>
<VCol
cols="12"
sm="6"
>
<VCard
variant="outlined"
class="cursor-pointer pa-4"
@click="showTotpSetup = true"
>
<div class="d-flex align-center mb-2">
<VIcon
icon="tabler-device-mobile"
size="28"
color="primary"
class="me-2"
/>
<span class="text-subtitle-1 font-weight-medium">Authenticator app</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Aanbevolen. Gebruik Google Authenticator, Authy of een andere app.
</p>
</VCard>
</VCol>
<VCol
cols="12"
sm="6"
>
<VCard
variant="outlined"
class="cursor-pointer pa-4"
@click="showEmailSetup = true"
>
<div class="d-flex align-center mb-2">
<VIcon
icon="tabler-mail"
size="28"
color="primary"
class="me-2"
/>
<span class="text-subtitle-1 font-weight-medium">E-mailcode</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Ontvang een code per e-mail bij elke login.
</p>
</VCard>
</VCol>
</VRow>
</template>
<!-- MFA IS enabled -->
<template v-else>
<div class="d-flex align-center justify-space-between mb-4">
<div>
<VChip
color="success"
variant="tonal"
size="small"
class="me-2"
>
Ingeschakeld
</VChip>
<span class="text-body-1">via {{ methodLabel }}</span>
</div>
</div>
<!-- Backup codes -->
<div class="d-flex align-center justify-space-between mb-3 pa-3 rounded" style="background: rgb(var(--v-theme-surface-variant));">
<div>
<span class="text-body-1 font-weight-medium">Backup codes</span>
<br>
<span class="text-body-2 text-medium-emphasis">
{{ mfaStatus?.backup_codes_remaining ?? 0 }} van 10 resterend
</span>
</div>
<VBtn
variant="tonal"
size="small"
@click="showRegenerateDialog = true; regeneratedCodes = []; regenerateCode = ''; regenerateError = ''"
>
Nieuwe codes genereren
</VBtn>
</div>
<VBtn
color="error"
variant="tonal"
class="mt-2"
@click="showDisableDialog = true"
>
Uitschakelen
</VBtn>
</template>
</VCardText>
</VCard>
<!-- Section 2: Vertrouwde apparaten -->
<VCard
v-if="isEnabled"
class="mb-6"
>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<VIcon
icon="tabler-devices"
class="me-2"
/>
Vertrouwde apparaten
</div>
<VBtn
v-if="trustedDevices && trustedDevices.length > 1"
variant="tonal"
size="small"
color="error"
:loading="revokeAllMutation.isPending.value"
@click="handleRevokeAllDevices"
>
Alles intrekken
</VBtn>
</VCardTitle>
<VCardText>
<template v-if="trustedDevices && trustedDevices.length > 0">
<VList>
<VListItem
v-for="device in trustedDevices"
:key="device.id"
class="px-0"
>
<template #prepend>
<VIcon
icon="tabler-device-desktop"
size="24"
class="me-3"
/>
</template>
<VListItemTitle>{{ device.device_name ?? 'Onbekend apparaat' }}</VListItemTitle>
<VListItemSubtitle>
IP: {{ device.ip_address }}
<span v-if="device.last_used_at">
&middot; Laatst gebruikt: {{ new Date(device.last_used_at).toLocaleDateString('nl-NL') }}
</span>
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
size="small"
color="error"
icon="tabler-trash"
:loading="revokeDeviceMutation.isPending.value"
@click="handleRevokeDevice(device.id)"
/>
</template>
</VListItem>
</VList>
</template>
<p
v-else
class="text-body-2 text-medium-emphasis"
>
Geen vertrouwde apparaten. Wanneer je inlogt met MFA kun je ervoor kiezen een apparaat te onthouden.
</p>
</VCardText>
</VCard>
<!-- Setup dialogs -->
<MfaTotpSetupDialog
v-model="showTotpSetup"
@completed="onSetupCompleted"
/>
<MfaEmailSetupDialog
v-model="showEmailSetup"
:user-email="authStore.user?.email ?? ''"
@completed="onSetupCompleted"
/>
<MfaDisableDialog
v-model="showDisableDialog"
:current-method="mfaStatus?.method ?? null"
@disabled="onDisabled"
/>
<!-- Regenerate backup codes dialog -->
<VDialog
v-model="showRegenerateDialog"
max-width="460"
>
<VCard>
<VCardTitle class="pt-4">
Nieuwe backup codes genereren
</VCardTitle>
<VCardText v-if="regeneratedCodes.length === 0">
<VAlert
type="info"
variant="tonal"
class="mb-4"
>
Dit vervangt al je huidige backup codes. Oude codes werken niet meer.
</VAlert>
<VAlert
v-if="regenerateError"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ regenerateError }}
</VAlert>
<p class="text-body-2 mb-2">
Voer je authenticator code in ter bevestiging
</p>
<AppTextField
v-model="regenerateCode"
placeholder="123456"
autofocus
/>
</VCardText>
<VCardText v-else>
<VAlert
type="success"
variant="tonal"
class="mb-4"
>
Nieuwe backup codes gegenereerd. Bewaar ze veilig.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in regeneratedCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyRegeneratedCodes"
>
Kopieer
</VBtn>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="showRegenerateDialog = false"
>
{{ regeneratedCodes.length > 0 ? 'Sluiten' : 'Annuleren' }}
</VBtn>
<VBtn
v-if="regeneratedCodes.length === 0"
color="primary"
:loading="regenerateCodesMutation.isPending.value"
:disabled="!regenerateCode"
@click="handleRegenerateBackupCodes"
>
Genereren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</VCol>
</VRow>
</template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import AuthProvider from '@/views/pages/authentication/AuthProvider.vue'
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
import authV2LoginIllustrationBorderedDark from '@images/pages/auth-v2-login-illustration-bordered-dark.png'
import authV2LoginIllustrationBorderedLight from '@images/pages/auth-v2-login-illustration-bordered-light.png'
@@ -10,9 +9,12 @@ import authV2MaskDark from '@images/pages/misc-mask-dark.png'
import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { useLogin } from '@/composables/api/useAuth'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { emailValidator, requiredValidator } from '@core/utils/validators'
import type { LoginCredentials } from '@/types/auth'
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
import type { LoginCredentials, LoginResponse } from '@/types/auth'
import type { MfaMethod } from '@/types/mfa'
definePage({
meta: {
@@ -23,6 +25,7 @@ definePage({
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const passwordResetDone = computed(() => route.query.reset === '1')
@@ -35,8 +38,14 @@ const form = ref<LoginCredentials & { remember: boolean }>({
const isPasswordVisible = ref(false)
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const isPending = ref(false)
const { mutate: login, isPending } = useLogin()
// MFA challenge state
const showMfaChallenge = ref(false)
const mfaSessionToken = ref('')
const mfaMethods = ref<MfaMethod[]>([])
const mfaPreferredMethod = ref<string>('')
const mfaExpiresIn = ref(0)
const authThemeImg = useGenerateImageVariant(
authV2LoginIllustrationLight,
@@ -47,36 +56,77 @@ const authThemeImg = useGenerateImageVariant(
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
function handleLogin() {
async function handleLogin() {
errors.value = {}
isPending.value = true
login(
{ email: form.value.email, password: form.value.password },
{
onSuccess: () => {
const rawTo = route.query.to ? String(route.query.to) : ''
const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
try {
const fingerprint = generateDeviceFingerprint()
router.replace(redirectTo)
},
onError: (err: any) => {
const errorData = err.response?.data
const { data } = await apiClient.post<LoginResponse>('/auth/login', {
email: form.value.email,
password: form.value.password,
}, {
headers: { 'X-Device-Fingerprint': fingerprint },
})
if (errorData?.errors) {
errors.value = {
email: errorData.errors.email?.[0] ?? '',
password: errorData.errors.password?.[0] ?? '',
}
}
else if (errorData?.message) {
errors.value = { email: errorData.message }
}
else {
errors.value = { email: 'An error occurred. Please try again.' }
}
},
},
)
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 success
authStore.setUser(data.data.user)
if (data.mfa_setup_required) {
router.replace('/account-settings/security')
return
}
const rawTo = route.query.to ? String(route.query.to) : ''
const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
router.replace(redirectTo)
}
catch (err: unknown) {
const errorData = (err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }).response?.data
if (errorData?.errors) {
errors.value = {
email: errorData.errors.email?.[0] ?? '',
password: errorData.errors.password?.[0] ?? '',
}
}
else if (errorData?.message) {
errors.value = { email: errorData.message }
}
else {
errors.value = { email: 'Er is een fout opgetreden. Probeer het opnieuw.' }
}
}
finally {
isPending.value = false
}
}
function onMfaVerified() {
authStore.initialize().then(() => {
const rawTo = route.query.to ? String(route.query.to) : ''
const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
router.replace(redirectTo)
})
}
function onMfaCancelled() {
showMfaChallenge.value = false
mfaSessionToken.value = ''
}
function onSubmit() {
@@ -90,16 +140,33 @@ function onSubmit() {
</script>
<template>
<a href="javascript:void(0)">
<RouterLink to="/">
<div class="auth-logo d-flex align-center gap-x-3">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="auth-title">
{{ themeConfig.app.title }}
</h1>
</div>
</a>
</RouterLink>
<!-- MFA Challenge Screen -->
<div
v-if="showMfaChallenge"
class="auth-wrapper d-flex align-center justify-center bg-surface"
>
<MfaChallengeCard
:mfa-session-token="mfaSessionToken"
:methods="mfaMethods"
:preferred-method="mfaPreferredMethod"
:expires-in="mfaExpiresIn"
@verified="onMfaVerified"
@cancelled="onMfaCancelled"
/>
</div>
<!-- Login Form -->
<VRow
v-else
no-gutters
class="auth-wrapper bg-surface"
>
@@ -163,27 +230,25 @@ function onSubmit() {
@submit.prevent="onSubmit"
>
<VRow>
<!-- email -->
<VCol cols="12">
<AppTextField
v-model="form.email"
autofocus
label="Email"
label="E-mailadres"
type="email"
placeholder="johndoe@email.com"
placeholder="naam@voorbeeld.nl"
:rules="[requiredValidator, emailValidator]"
:error-messages="errors.email"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<AppTextField
v-model="form.password"
label="Password"
label="Wachtwoord"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
autocomplete="password"
autocomplete="current-password"
:rules="[requiredValidator]"
:error-messages="errors.password"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
@@ -208,41 +273,17 @@ function onSubmit() {
type="submit"
:loading="isPending"
>
Login
Inloggen
</VBtn>
</VCol>
<!-- create account -->
<VCol
cols="12"
class="text-body-1 text-center"
>
<span class="d-inline-block">
New on our platform?
<span class="d-inline-block text-medium-emphasis">
Nog geen account? Neem contact op met je organisatie.
</span>
<a
class="text-primary ms-1 d-inline-block text-body-1"
href="javascript:void(0)"
>
Create an account
</a>
</VCol>
<VCol
cols="12"
class="d-flex align-center"
>
<VDivider />
<span class="mx-4">or</span>
<VDivider />
</VCol>
<!-- auth providers -->
<VCol
cols="12"
class="text-center"
>
<AuthProvider />
</VCol>
</VRow>
</VForm>

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>

View File

@@ -44,6 +44,12 @@ export function setupGuards(router: Router) {
return { path: '/login', query: { to: to.fullPath } }
}
// MFA enforcement — redirect to security settings if MFA setup is required
if (authStore.mfaSetupRequired && to.path !== '/account-settings/security') {
if (import.meta.env.DEV) console.log('🔒 MFA setup required, redirecting to security settings')
return { path: '/account-settings/security' }
}
// Platform admin routes — require super_admin role
if (to.path.startsWith('/platform')) {
if (!authStore.isSuperAdmin) {

View File

@@ -11,6 +11,8 @@ export const useAuthStore = defineStore('auth', () => {
const permissions = ref<string[]>([])
const isInitialized = ref(false)
const mfaSetupRequired = ref(false)
const isAuthenticated = computed(() => !!user.value)
const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
@@ -35,6 +37,7 @@ export const useAuthStore = defineStore('auth', () => {
organisations.value = me.organisations
appRoles.value = me.app_roles
permissions.value = me.permissions
mfaSetupRequired.value = me.mfa?.setup_required ?? false
// Auto-select first organisation if none is active
const orgStore = useOrganisationStore()
@@ -53,6 +56,7 @@ export const useAuthStore = defineStore('auth', () => {
organisations.value = []
appRoles.value = []
permissions.value = []
mfaSetupRequired.value = false
const orgStore = useOrganisationStore()
orgStore.clear()
@@ -120,6 +124,7 @@ export const useAuthStore = defineStore('auth', () => {
isInitialized,
isSuperAdmin,
currentOrganisation,
mfaSetupRequired,
setUser,
setActiveOrganisation,
logout,

View File

@@ -27,6 +27,7 @@ export interface AdminUser {
email_verified_at: string | null
created_at: string
is_super_admin: boolean
mfa_enabled: boolean
roles: string[]
organisations: Array<{
id: string

View File

@@ -16,6 +16,13 @@ export interface Organisation {
role: string
}
export interface MfaUserInfo {
enabled: boolean
method: 'totp' | 'email' | null
confirmed_at: string | null
setup_required: boolean
}
export interface MeResponse {
id: string
first_name: string
@@ -28,6 +35,7 @@ export interface MeResponse {
organisations: Organisation[]
app_roles: string[]
permissions: string[]
mfa?: MfaUserInfo
}
export interface LoginCredentials {
@@ -41,6 +49,12 @@ export interface LoginResponse {
user: MeResponse
}
message: string
mfa_required?: boolean
mfa_session_token?: string
methods?: string[]
preferred_method?: string
expires_in?: number
mfa_setup_required?: boolean
}
export interface ApiErrorResponse {

61
apps/app/src/types/mfa.ts Normal file
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

@@ -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}`
}