feat: password reset, email change with verification, and password change

Password reset: multi-app support with custom notification linking to correct
frontend (app/portal/admin). Email change: self-service with password
confirmation and admin-initiated, both sending verification to new address
with 24h expiry. Confirmation sent to old email on completion. Password
change: authenticated endpoint revoking other sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 15:38:54 +02:00
parent 53100d4f6d
commit 836cffa232
42 changed files with 2643 additions and 67 deletions

View File

@@ -2,6 +2,7 @@
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
import { apiClient } from '@/lib/axios'
definePage({
name: 'portal-profiel',
@@ -33,6 +34,49 @@ const profileForm = ref({
})
const profileError = ref<string | null>(null)
// Email change form
const showEmailChange = ref(false)
const emailForm = ref({
new_email: '',
password: '',
})
const emailError = ref<string | null>(null)
const emailFieldErrors = ref<Record<string, string>>({})
const emailSuccess = ref('')
const showEmailPw = ref(false)
const isChangingEmail = ref(false)
async function saveEmail() {
emailError.value = null
emailFieldErrors.value = {}
emailSuccess.value = ''
isChangingEmail.value = true
try {
const { data } = await apiClient.post<{ success: boolean; message: string }>(
'/me/change-email',
{ ...emailForm.value, app: 'portal' },
)
emailSuccess.value = data.message
emailForm.value = { new_email: '', password: '' }
showEmailChange.value = false
}
catch (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)) {
emailFieldErrors.value[key] = (messages as string[])[0]
}
}
else {
emailError.value = ax.response?.data?.message ?? 'Er is een fout opgetreden.'
}
}
finally {
isChangingEmail.value = false
}
}
// Password form
const passwordForm = ref({
current_password: '',
@@ -224,11 +268,90 @@ async function savePassword() {
density="comfortable"
hide-details="auto"
readonly
prepend-inner-icon="tabler-lock"
prepend-inner-icon="tabler-mail"
/>
<p class="text-caption text-medium-emphasis mt-1 mb-0">
Je e-mailadres kan niet worden gewijzigd.
</p>
<div class="d-flex align-center justify-space-between mt-1">
<p class="text-caption text-medium-emphasis mb-0">
{{ showEmailChange ? '' : 'Je kunt je e-mailadres hieronder wijzigen.' }}
</p>
<VBtn
v-if="!showEmailChange"
variant="text"
color="primary"
size="small"
@click="showEmailChange = true"
>
E-mail wijzigen
</VBtn>
</div>
</VCol>
<!-- Email change form -->
<template v-if="showEmailChange">
<VCol cols="12">
<VAlert
v-if="emailError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
>
{{ emailError }}
</VAlert>
<VTextField
v-model="emailForm.new_email"
label="Nieuw e-mailadres"
type="email"
variant="outlined"
density="comfortable"
hide-details="auto"
:error-messages="emailFieldErrors.new_email"
class="mb-3"
/>
<VTextField
v-model="emailForm.password"
label="Huidig wachtwoord"
:type="showEmailPw ? 'text' : 'password'"
:append-inner-icon="showEmailPw ? 'tabler-eye-off' : 'tabler-eye'"
variant="outlined"
density="comfortable"
hide-details="auto"
:error-messages="emailFieldErrors.password"
@click:append-inner="showEmailPw = !showEmailPw"
/>
<div class="d-flex gap-2 mt-3">
<VBtn
color="primary"
size="small"
:loading="isChangingEmail"
:disabled="!emailForm.new_email || !emailForm.password"
@click="saveEmail"
>
Verificatiemail versturen
</VBtn>
<VBtn
variant="tonal"
size="small"
@click="showEmailChange = false; emailForm = { new_email: '', password: '' }"
>
Annuleren
</VBtn>
</div>
</VCol>
</template>
<VCol
v-if="emailSuccess"
cols="12"
>
<VAlert
type="success"
variant="tonal"
density="compact"
>
{{ emailSuccess }}
</VAlert>
</VCol>
<VCol
cols="12"

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
definePage({
name: 'verify-email-change',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const route = useRoute()
const authStore = useAuthStore()
const isVerifying = ref(true)
const success = ref(false)
const errorMessage = ref('')
onMounted(async () => {
const token = route.query.token as string
if (!token) {
errorMessage.value = 'Geen verificatietoken gevonden.'
isVerifying.value = false
return
}
try {
await apiClient.post('/verify-email-change', { token })
success.value = true
authStore.logout()
}
catch (error: unknown) {
const ax = error as { response?: { data?: { errors?: Record<string, string[]>; message?: string } } }
errorMessage.value = ax.response?.data?.errors?.token?.[0]
?? ax.response?.data?.errors?.new_email?.[0]
?? ax.response?.data?.message
?? 'Er is een fout opgetreden bij de verificatie.'
}
finally {
isVerifying.value = false
}
})
</script>
<template>
<div class="d-flex align-center justify-center pa-4" style="min-height: 100vh; background: #f5f5f5;">
<VCard
:max-width="450"
width="100%"
class="pa-6"
>
<div class="text-center mb-4">
<VIcon
icon="tabler-users-group"
size="32"
color="primary"
/>
<span class="text-h5 font-weight-bold text-high-emphasis ms-2">
Crewli
</span>
</div>
<VCardText class="text-center">
<template v-if="isVerifying">
<VProgressCircular
indeterminate
color="primary"
class="mb-4"
/>
<p>E-mailadres wordt geverifieerd...</p>
</template>
<template v-else-if="success">
<VIcon
size="64"
color="success"
class="mb-4"
>
tabler-circle-check
</VIcon>
<h4 class="text-h5 mb-2">
E-mailadres gewijzigd!
</h4>
<p class="text-body-2 text-medium-emphasis mb-4">
Je e-mailadres is succesvol gewijzigd.
Log opnieuw in met je nieuwe e-mailadres.
</p>
<VBtn
color="primary"
to="/login"
>
Ga naar inloggen
</VBtn>
</template>
<template v-else>
<VIcon
size="64"
color="error"
class="mb-4"
>
tabler-circle-x
</VIcon>
<h4 class="text-h5 mb-2">
Verificatie mislukt
</h4>
<p class="text-body-2 text-medium-emphasis">
{{ errorMessage }}
</p>
</template>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -16,7 +16,7 @@ const done = ref(false)
async function onSubmit(): Promise<void> {
isSubmitting.value = true
try {
await apiClient.post('/auth/forgot-password', { email: email.value.trim() })
await apiClient.post('/auth/forgot-password', { email: email.value.trim(), app: 'portal' })
}
catch {
// Endpoint may not exist yet — still show generic success (no email enumeration)