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:
283
apps/app/src/components/auth/MfaChallengeCard.vue
Normal file
283
apps/app/src/components/auth/MfaChallengeCard.vue
Normal 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ë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>
|
||||
115
apps/app/src/components/settings/MfaDisableDialog.vue
Normal file
115
apps/app/src/components/settings/MfaDisableDialog.vue
Normal 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>
|
||||
276
apps/app/src/components/settings/MfaEmailSetupDialog.vue
Normal file
276
apps/app/src/components/settings/MfaEmailSetupDialog.vue
Normal 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ë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>
|
||||
291
apps/app/src/components/settings/MfaTotpSetupDialog.vue
Normal file
291
apps/app/src/components/settings/MfaTotpSetupDialog.vue
Normal 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ë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>
|
||||
188
apps/app/src/composables/api/useMfa.ts
Normal file
188
apps/app/src/composables/api/useMfa.ts
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
398
apps/app/src/pages/account-settings/security.vue
Normal file
398
apps/app/src/pages/account-settings/security.vue
Normal 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">
|
||||
· 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
61
apps/app/src/types/mfa.ts
Normal 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
|
||||
}
|
||||
49
apps/app/src/utils/deviceFingerprint.ts
Normal file
49
apps/app/src/utils/deviceFingerprint.ts
Normal 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}`
|
||||
}
|
||||
Reference in New Issue
Block a user