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:
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>
|
||||
Reference in New Issue
Block a user