Moves "Huidig wachtwoord" to a full-width row so "Nieuw wachtwoord" and "Bevestig nieuw wachtwoord" sit together on the second row. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
679 lines
19 KiB
Vue
679 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
import { useChangePassword } from '@/composables/api/useAccount'
|
|
import {
|
|
useMfaStatus,
|
|
useTrustedDevices,
|
|
useRevokeDevice,
|
|
useRevokeAllDevices,
|
|
useRegenerateBackupCodes,
|
|
useSetPreferredMethod,
|
|
} 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'
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
// ─── Password Change ───
|
|
const passwordForm = ref({
|
|
current_password: '',
|
|
password: '',
|
|
password_confirmation: '',
|
|
})
|
|
const passwordFieldErrors = ref<Record<string, string>>({})
|
|
const passwordSuccess = ref('')
|
|
const showCurrentPw = ref(false)
|
|
const showNewPw = ref(false)
|
|
const showConfirmPw = ref(false)
|
|
|
|
const changePasswordMutation = useChangePassword()
|
|
|
|
async function handlePasswordChange() {
|
|
passwordFieldErrors.value = {}
|
|
passwordSuccess.value = ''
|
|
|
|
changePasswordMutation.mutate(passwordForm.value, {
|
|
onSuccess: (data) => {
|
|
passwordSuccess.value = data.message
|
|
passwordForm.value = {
|
|
current_password: '',
|
|
password: '',
|
|
password_confirmation: '',
|
|
}
|
|
},
|
|
onError: (err: unknown) => {
|
|
const ax = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
|
|
if (ax.response?.data?.errors) {
|
|
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
|
|
passwordFieldErrors.value[key] = messages[0]
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
// ─── MFA ───
|
|
const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
|
|
const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
|
|
const revokeDeviceMutation = useRevokeDevice()
|
|
const revokeAllMutation = useRevokeAllDevices()
|
|
const regenerateCodesMutation = useRegenerateBackupCodes()
|
|
|
|
const setPreferredMethodMutation = useSetPreferredMethod()
|
|
|
|
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 totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false)
|
|
const emailConfigured = computed(() => mfaStatus.value?.email_configured ?? false)
|
|
const preferredMethod = computed(() => mfaStatus.value?.method ?? null)
|
|
|
|
function handleSetPreferred(method: 'totp' | 'email') {
|
|
setPreferredMethodMutation.mutate(method)
|
|
}
|
|
|
|
const backupCodesRemaining = computed(() => mfaStatus.value?.backup_codes_remaining ?? 0)
|
|
const backupCodesColor = computed(() => {
|
|
if (backupCodesRemaining.value > 5) return 'success'
|
|
if (backupCodesRemaining.value >= 3) return 'warning'
|
|
return 'error'
|
|
})
|
|
|
|
function onSetupCompleted() {
|
|
// Refetch MFA status so the method cards update (enabled, method, backup codes)
|
|
refetchMfaStatus()
|
|
// Immediately clear the enforcement flag so the router guard unblocks navigation
|
|
authStore.mfaSetupRequired = false
|
|
// Refresh the full /auth/me response into the store so the flag stays
|
|
// correct on subsequent navigations (without needing a full page reload)
|
|
authStore.refreshUser()
|
|
}
|
|
|
|
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>
|
|
<!-- 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>
|
|
|
|
<!-- Card 1: Wachtwoord wijzigen -->
|
|
<VCard class="mb-6">
|
|
<VCardItem>
|
|
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
|
|
</VCardItem>
|
|
<VForm @submit.prevent="handlePasswordChange">
|
|
<VCardText class="pt-0">
|
|
<VAlert
|
|
v-if="passwordSuccess"
|
|
type="success"
|
|
variant="tonal"
|
|
density="compact"
|
|
class="mb-4"
|
|
>
|
|
{{ passwordSuccess }}
|
|
</VAlert>
|
|
|
|
<VRow>
|
|
<VCol cols="12">
|
|
<AppTextField
|
|
v-model="passwordForm.current_password"
|
|
label="Huidig wachtwoord"
|
|
:type="showCurrentPw ? 'text' : 'password'"
|
|
:append-inner-icon="showCurrentPw ? 'tabler-eye-off' : 'tabler-eye'"
|
|
:error-messages="passwordFieldErrors.current_password"
|
|
placeholder="············"
|
|
@click:append-inner="showCurrentPw = !showCurrentPw"
|
|
/>
|
|
</VCol>
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<AppTextField
|
|
v-model="passwordForm.password"
|
|
label="Nieuw wachtwoord"
|
|
:type="showNewPw ? 'text' : 'password'"
|
|
:append-inner-icon="showNewPw ? 'tabler-eye-off' : 'tabler-eye'"
|
|
:error-messages="passwordFieldErrors.password"
|
|
placeholder="············"
|
|
@click:append-inner="showNewPw = !showNewPw"
|
|
/>
|
|
</VCol>
|
|
<VCol
|
|
cols="12"
|
|
md="6"
|
|
>
|
|
<AppTextField
|
|
v-model="passwordForm.password_confirmation"
|
|
label="Bevestig nieuw wachtwoord"
|
|
:type="showConfirmPw ? 'text' : 'password'"
|
|
:append-inner-icon="showConfirmPw ? 'tabler-eye-off' : 'tabler-eye'"
|
|
:error-messages="passwordFieldErrors.password_confirmation"
|
|
placeholder="············"
|
|
@click:append-inner="showConfirmPw = !showConfirmPw"
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
|
|
<VCardText>
|
|
<h6 class="text-h6 text-medium-emphasis mb-4">
|
|
Wachtwoordvereisten:
|
|
</h6>
|
|
<VList class="card-list">
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon
|
|
size="10"
|
|
icon="tabler-circle-filled"
|
|
/>
|
|
</template>
|
|
<span class="text-medium-emphasis">Minimaal 8 tekens</span>
|
|
</VListItem>
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon
|
|
size="10"
|
|
icon="tabler-circle-filled"
|
|
/>
|
|
</template>
|
|
<span class="text-medium-emphasis">Minimaal 1 hoofdletter en 1 kleine letter</span>
|
|
</VListItem>
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon
|
|
size="10"
|
|
icon="tabler-circle-filled"
|
|
/>
|
|
</template>
|
|
<span class="text-medium-emphasis">Minimaal 1 cijfer</span>
|
|
</VListItem>
|
|
</VList>
|
|
</VCardText>
|
|
|
|
<VCardText>
|
|
<VBtn
|
|
type="submit"
|
|
:loading="changePasswordMutation.isPending.value"
|
|
>
|
|
Wachtwoord wijzigen
|
|
</VBtn>
|
|
</VCardText>
|
|
</VForm>
|
|
</VCard>
|
|
|
|
<!-- Card 2: Tweestapsverificatie -->
|
|
<VCard class="mb-6">
|
|
<VCardItem>
|
|
<VCardTitle>Tweestapsverificatie</VCardTitle>
|
|
</VCardItem>
|
|
<VCardText>
|
|
<!-- MFA method cards -->
|
|
<VRow>
|
|
<!-- TOTP card -->
|
|
<VCol
|
|
cols="12"
|
|
sm="6"
|
|
>
|
|
<VCard
|
|
variant="outlined"
|
|
:class="['mfa-method-card h-100', { 'mfa-method-card--primary': totpConfigured && preferredMethod === 'totp' }]"
|
|
>
|
|
<VCardText>
|
|
<VAvatar
|
|
color="primary"
|
|
variant="tonal"
|
|
size="44"
|
|
rounded
|
|
class="mb-3"
|
|
>
|
|
<VIcon
|
|
icon="tabler-device-mobile"
|
|
size="26"
|
|
/>
|
|
</VAvatar>
|
|
|
|
<h6 class="text-h6 mb-1">
|
|
Authenticator app
|
|
</h6>
|
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
Gebruik een authenticator app zoals Google Authenticator of 1Password
|
|
</p>
|
|
|
|
<VChip
|
|
:color="totpConfigured ? 'success' : 'default'"
|
|
variant="tonal"
|
|
size="small"
|
|
:prepend-icon="totpConfigured ? 'tabler-check' : undefined"
|
|
>
|
|
{{ totpConfigured ? 'Geconfigureerd' : 'Niet ingesteld' }}
|
|
</VChip>
|
|
</VCardText>
|
|
|
|
<VCardActions class="px-4 pb-4 pt-0">
|
|
<template v-if="totpConfigured">
|
|
<VChip
|
|
v-if="preferredMethod === 'totp'"
|
|
color="primary"
|
|
variant="tonal"
|
|
size="small"
|
|
prepend-icon="tabler-check"
|
|
>
|
|
Primaire methode
|
|
</VChip>
|
|
<VBtn
|
|
v-else
|
|
variant="tonal"
|
|
size="small"
|
|
:loading="setPreferredMethodMutation.isPending.value"
|
|
@click="handleSetPreferred('totp')"
|
|
>
|
|
Als primair instellen
|
|
</VBtn>
|
|
|
|
<VSpacer />
|
|
|
|
<VBtn
|
|
variant="text"
|
|
size="small"
|
|
@click="showTotpSetup = true"
|
|
>
|
|
Opnieuw instellen
|
|
</VBtn>
|
|
</template>
|
|
|
|
<VBtn
|
|
v-else
|
|
variant="tonal"
|
|
size="small"
|
|
@click="showTotpSetup = true"
|
|
>
|
|
Instellen
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VCol>
|
|
|
|
<!-- Email card -->
|
|
<VCol
|
|
cols="12"
|
|
sm="6"
|
|
>
|
|
<VCard
|
|
variant="outlined"
|
|
:class="['mfa-method-card h-100', { 'mfa-method-card--primary': emailConfigured && preferredMethod === 'email' }]"
|
|
>
|
|
<VCardText>
|
|
<VAvatar
|
|
color="primary"
|
|
variant="tonal"
|
|
size="44"
|
|
rounded
|
|
class="mb-3"
|
|
>
|
|
<VIcon
|
|
icon="tabler-mail"
|
|
size="26"
|
|
/>
|
|
</VAvatar>
|
|
|
|
<h6 class="text-h6 mb-1">
|
|
E-mailcode
|
|
</h6>
|
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
Ontvang een 6-cijferige code op {{ authStore.user?.email }}
|
|
</p>
|
|
|
|
<VChip
|
|
:color="emailConfigured ? 'success' : 'default'"
|
|
variant="tonal"
|
|
size="small"
|
|
:prepend-icon="emailConfigured ? 'tabler-check' : undefined"
|
|
>
|
|
{{ emailConfigured ? 'Geconfigureerd' : 'Niet ingesteld' }}
|
|
</VChip>
|
|
</VCardText>
|
|
|
|
<VCardActions class="px-4 pb-4 pt-0">
|
|
<template v-if="emailConfigured">
|
|
<VChip
|
|
v-if="preferredMethod === 'email'"
|
|
color="primary"
|
|
variant="tonal"
|
|
size="small"
|
|
prepend-icon="tabler-check"
|
|
>
|
|
Primaire methode
|
|
</VChip>
|
|
<VBtn
|
|
v-else
|
|
variant="tonal"
|
|
size="small"
|
|
:loading="setPreferredMethodMutation.isPending.value"
|
|
@click="handleSetPreferred('email')"
|
|
>
|
|
Als primair instellen
|
|
</VBtn>
|
|
|
|
<VSpacer />
|
|
|
|
<VBtn
|
|
variant="text"
|
|
size="small"
|
|
@click="showEmailSetup = true"
|
|
>
|
|
Opnieuw instellen
|
|
</VBtn>
|
|
</template>
|
|
|
|
<VBtn
|
|
v-else
|
|
variant="tonal"
|
|
size="small"
|
|
@click="showEmailSetup = true"
|
|
>
|
|
Instellen
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<!-- Backup codes -->
|
|
<VCard
|
|
v-if="isEnabled"
|
|
variant="outlined"
|
|
class="mt-4"
|
|
>
|
|
<VCardText>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="d-flex align-center gap-2 mb-1">
|
|
<VIcon
|
|
icon="tabler-key"
|
|
size="20"
|
|
class="text-medium-emphasis"
|
|
/>
|
|
<span class="text-body-1 font-weight-medium">Backup codes</span>
|
|
</div>
|
|
<span class="text-body-2 text-medium-emphasis">
|
|
{{ backupCodesRemaining }} van 10 resterend
|
|
</span>
|
|
</div>
|
|
<VBtn
|
|
variant="tonal"
|
|
size="small"
|
|
prepend-icon="tabler-refresh"
|
|
@click="showRegenerateDialog = true; regeneratedCodes = []; regenerateCode = ''; regenerateError = ''"
|
|
>
|
|
Nieuwe codes
|
|
</VBtn>
|
|
</div>
|
|
<VProgressLinear
|
|
:model-value="backupCodesRemaining * 10"
|
|
:color="backupCodesColor"
|
|
class="mt-3"
|
|
rounded
|
|
height="6"
|
|
/>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Disable MFA -->
|
|
<div
|
|
v-if="isEnabled"
|
|
class="mt-6"
|
|
>
|
|
<VBtn
|
|
variant="text"
|
|
color="error"
|
|
size="small"
|
|
prepend-icon="tabler-shield-off"
|
|
@click="showDisableDialog = true"
|
|
>
|
|
Tweestapsverificatie uitschakelen
|
|
</VBtn>
|
|
</div>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Card 3: Vertrouwde apparaten -->
|
|
<VCard
|
|
v-if="isEnabled"
|
|
class="mb-6"
|
|
>
|
|
<VCardItem>
|
|
<template #default>
|
|
<VCardTitle>Vertrouwde apparaten</VCardTitle>
|
|
</template>
|
|
<template #append>
|
|
<VBtn
|
|
v-if="trustedDevices && trustedDevices.length > 1"
|
|
variant="tonal"
|
|
size="small"
|
|
color="error"
|
|
:loading="revokeAllMutation.isPending.value"
|
|
@click="handleRevokeAllDevices"
|
|
>
|
|
Alles intrekken
|
|
</VBtn>
|
|
</template>
|
|
</VCardItem>
|
|
<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>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
.card-list {
|
|
--v-card-list-gap: 8px;
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss" scoped>
|
|
.mfa-method-card--primary {
|
|
border-color: rgb(var(--v-theme-primary));
|
|
border-width: 2px;
|
|
}
|
|
</style>
|