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

@@ -55,7 +55,8 @@ apiClient.interceptors.response.use(
document.cookie = 'accessToken=; path=/; max-age=0'
document.cookie = 'userData=; path=/; max-age=0'
document.cookie = 'userAbilityRules=; path=/; max-age=0'
if (window.location.pathname !== '/login') {
const publicPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
if (!publicPaths.some(p => window.location.pathname.startsWith(p))) {
window.location.href = '/login'
}
}

View File

@@ -8,10 +8,13 @@ import authV2ForgotPasswordIllustrationLight from '@images/pages/auth-v2-forgot-
import authV2MaskDark from '@images/pages/misc-mask-dark.png'
import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { apiClient } from '@/lib/axios'
const email = ref('')
const isSubmitting = ref(false)
const done = ref(false)
const authThemeImg = useGenerateImageVariant(authV2ForgotPasswordIllustrationLight, authV2ForgotPasswordIllustrationDark)
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
definePage({
@@ -20,6 +23,23 @@ definePage({
unauthenticatedOnly: true,
},
})
async function onSubmit(): Promise<void> {
isSubmitting.value = true
try {
await apiClient.post('/auth/forgot-password', {
email: email.value.trim(),
app: 'admin',
})
}
catch {
// Always show generic success (no email enumeration)
}
finally {
isSubmitting.value = false
done.value = true
}
}
</script>
<template>
@@ -74,38 +94,48 @@ definePage({
>
<VCardText>
<h4 class="text-h4 mb-1">
Forgot Password? 🔒
Wachtwoord vergeten?
</h4>
<p class="mb-0">
Enter your email and we'll send you instructions to reset your password
Vul je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.
</p>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<VAlert
v-if="done"
type="success"
variant="tonal"
class="mb-4"
>
Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te herstellen.
</VAlert>
<VForm
v-if="!done"
@submit.prevent="onSubmit"
>
<VRow>
<!-- email -->
<VCol cols="12">
<AppTextField
v-model="email"
autofocus
label="Email"
label="E-mailadres"
type="email"
placeholder="johndoe@email.com"
placeholder="naam@voorbeeld.nl"
/>
</VCol>
<!-- Reset link -->
<VCol cols="12">
<VBtn
block
type="submit"
:loading="isSubmitting"
>
Send Reset Link
Verstuur herstelmail
</VBtn>
</VCol>
<!-- back to login -->
<VCol cols="12">
<RouterLink
class="d-flex align-center justify-center"
@@ -116,7 +146,7 @@ definePage({
size="20"
class="me-1 flip-in-rtl"
/>
<span>Back to login</span>
<span>Terug naar inloggen</span>
</RouterLink>
</VCol>
</VRow>

View File

@@ -40,6 +40,8 @@ const isPasswordVisible = ref(false)
const route = useRoute()
const router = useRouter()
const passwordResetDone = computed(() => route.query.reset === '1')
const ability = useAbility()
const errors = ref<Record<string, string | undefined>>({
@@ -170,6 +172,16 @@ const onSubmit = () => {
</p>
</VCardText>
<VCardText>
<VAlert
v-if="passwordResetDone"
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Wachtwoord gewijzigd. Je kunt nu inloggen.
</VAlert>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import authV2ResetPasswordIllustrationDark from '@images/pages/auth-v2-reset-password-illustration-dark.png'
import authV2ResetPasswordIllustrationLight from '@images/pages/auth-v2-reset-password-illustration-light.png'
import authV2MaskDark from '@images/pages/misc-mask-dark.png'
import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { apiClient } from '@/lib/axios'
definePage({
meta: {
layout: 'blank',
unauthenticatedOnly: true,
},
})
const route = useRoute()
const router = useRouter()
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
const token = ref(typeof route.query.token === 'string' ? route.query.token : '')
const password = ref('')
const passwordConfirmation = ref('')
const showPassword = ref(false)
const showPasswordConfirmation = ref(false)
const errorMessage = ref('')
const isSubmitting = ref(false)
const authThemeImg = useGenerateImageVariant(
authV2ResetPasswordIllustrationLight,
authV2ResetPasswordIllustrationDark,
)
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
async function onSubmit(): Promise<void> {
errorMessage.value = ''
if (!token.value || !email.value) {
errorMessage.value = 'Ongeldige resetlink. Vraag een nieuwe link aan.'
return
}
isSubmitting.value = true
try {
await apiClient.post('/auth/reset-password', {
email: email.value.trim(),
password: password.value,
password_confirmation: passwordConfirmation.value,
token: token.value,
})
await router.replace({ path: '/login', query: { reset: '1' } })
}
catch (error: unknown) {
const ax = error as { response?: { status?: number; data?: { message?: string } } }
if (ax.response?.status === 404 || ax.response?.status === 422)
errorMessage.value = ax.response?.data?.message ?? 'Resetlink ongeldig of verlopen. Vraag een nieuwe link aan.'
else
errorMessage.value = 'Er ging iets mis. Probeer het later opnieuw.'
}
finally {
isSubmitting.value = false
}
}
</script>
<template>
<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>
</RouterLink>
<VRow
class="auth-wrapper bg-surface"
no-gutters
>
<VCol
md="8"
class="d-none d-md-flex"
>
<div class="position-relative bg-background w-100 me-0">
<div
class="d-flex align-center justify-center w-100 h-100"
style="padding-inline: 150px;"
>
<VImg
max-width="468"
:src="authThemeImg"
class="auth-illustration mt-16 mb-2"
/>
</div>
<img
class="auth-footer-mask"
:src="authThemeMask"
alt="auth-footer-mask"
height="280"
width="100"
>
</div>
</VCol>
<VCol
cols="12"
md="4"
class="auth-card-v2 d-flex align-center justify-center"
>
<VCard
flat
:max-width="500"
class="mt-12 mt-sm-0 pa-6"
>
<VCardText>
<h4 class="text-h4 mb-1">
Nieuw wachtwoord instellen
</h4>
<p class="mb-0">
Kies een nieuw wachtwoord voor je account.
</p>
</VCardText>
<VCardText>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="email"
label="E-mailadres"
type="email"
readonly
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="password"
label="Nieuw wachtwoord"
autofocus
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'tabler-eye-off' : 'tabler-eye'"
autocomplete="new-password"
@click:append-inner="showPassword = !showPassword"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="passwordConfirmation"
label="Bevestig wachtwoord"
:type="showPasswordConfirmation ? 'text' : 'password'"
:append-inner-icon="showPasswordConfirmation ? 'tabler-eye-off' : 'tabler-eye'"
autocomplete="new-password"
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
</VCol>
<VCol cols="12">
<VBtn
block
type="submit"
:loading="isSubmitting"
>
Wachtwoord opslaan
</VBtn>
</VCol>
<VCol cols="12">
<RouterLink
class="d-flex align-center justify-center"
:to="{ name: 'login' }"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1 flip-in-rtl"
/>
<span>Terug naar inloggen</span>
</RouterLink>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
definePage({
meta: {
layout: 'blank',
unauthenticatedOnly: true,
},
})
const route = useRoute()
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
// Clear auth cookies — email changed, force re-login
document.cookie = 'accessToken=; path=/; max-age=0'
document.cookie = 'userData=; path=/; max-age=0'
document.cookie = 'userAbilityRules=; path=/; max-age=0'
}
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="auth-wrapper d-flex align-center justify-center pa-4 bg-surface" style="min-height: 100vh;">
<div class="auth-logo d-flex align-center gap-x-3 position-absolute" style="inset-block-start: 2rem; inset-inline-start: 2.5rem;">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="auth-title">
{{ themeConfig.app.title }}
</h1>
</div>
<VCard
flat
:max-width="450"
width="100%"
class="pa-6"
>
<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="{ name: '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>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>