Files
crewli/apps/app/src/components/account-settings/SecurityTab.vue
bert.hausmans a9c84ee0a6 refactor: password change form layout — current password full width
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>
2026-04-15 22:54:50 +02:00

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">
&middot; Laatst gebruikt: {{ new Date(device.last_used_at).toLocaleDateString('nl-NL') }}
</span>
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
size="small"
color="error"
icon="tabler-trash"
:loading="revokeDeviceMutation.isPending.value"
@click="handleRevokeDevice(device.id)"
/>
</template>
</VListItem>
</VList>
</template>
<p
v-else
class="text-body-2 text-medium-emphasis"
>
Geen vertrouwde apparaten. Wanneer je inlogt met MFA kun je ervoor kiezen een apparaat te onthouden.
</p>
</VCardText>
</VCard>
<!-- Setup dialogs -->
<MfaTotpSetupDialog
v-model="showTotpSetup"
@completed="onSetupCompleted"
/>
<MfaEmailSetupDialog
v-model="showEmailSetup"
:user-email="authStore.user?.email ?? ''"
@completed="onSetupCompleted"
/>
<MfaDisableDialog
v-model="showDisableDialog"
:current-method="mfaStatus?.method ?? null"
@disabled="onDisabled"
/>
<!-- Regenerate backup codes dialog -->
<VDialog
v-model="showRegenerateDialog"
max-width="460"
>
<VCard>
<VCardTitle class="pt-4">
Nieuwe backup codes genereren
</VCardTitle>
<VCardText v-if="regeneratedCodes.length === 0">
<VAlert
type="info"
variant="tonal"
class="mb-4"
>
Dit vervangt al je huidige backup codes. Oude codes werken niet meer.
</VAlert>
<VAlert
v-if="regenerateError"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ regenerateError }}
</VAlert>
<p class="text-body-2 mb-2">
Voer je authenticator code in ter bevestiging
</p>
<AppTextField
v-model="regenerateCode"
placeholder="123456"
autofocus
/>
</VCardText>
<VCardText v-else>
<VAlert
type="success"
variant="tonal"
class="mb-4"
>
Nieuwe backup codes gegenereerd. Bewaar ze veilig.
</VAlert>
<div class="d-flex flex-wrap gap-2 mb-4">
<VChip
v-for="code in regeneratedCodes"
:key="code"
variant="tonal"
label
class="font-weight-bold"
style="font-family: monospace;"
>
{{ code }}
</VChip>
</div>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-copy"
@click="copyRegeneratedCodes"
>
Kopieer
</VBtn>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="showRegenerateDialog = false"
>
{{ regeneratedCodes.length > 0 ? 'Sluiten' : 'Annuleren' }}
</VBtn>
<VBtn
v-if="regeneratedCodes.length === 0"
color="primary"
:loading="regenerateCodesMutation.isPending.value"
:disabled="!regenerateCode"
@click="handleRegenerateBackupCodes"
>
Genereren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</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>