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

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { useChangePassword, useChangeEmail } from '@/composables/api/useAccount'
definePage({
meta: {
navActiveLink: 'account-settings',
},
})
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]
}
}
},
})
}
// Email change
const emailForm = ref({
new_email: '',
password: '',
})
const emailFieldErrors = ref<Record<string, string>>({})
const emailSuccess = ref('')
const showEmailPw = ref(false)
const changeEmailMutation = useChangeEmail()
async function handleEmailChange() {
emailFieldErrors.value = {}
emailSuccess.value = ''
changeEmailMutation.mutate(
{ ...emailForm.value, app: 'app' },
{
onSuccess: (data) => {
emailSuccess.value = data.message
emailForm.value = { new_email: '', password: '' }
},
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)) {
emailFieldErrors.value[key] = messages[0]
}
}
},
},
)
}
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
>
<h4 class="text-h4 mb-6">
Accountinstellingen
</h4>
<!-- Email change -->
<VCard class="mb-6">
<VCardTitle>E-mailadres wijzigen</VCardTitle>
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">
Huidig e-mailadres: <strong>{{ authStore.user?.email }}</strong>
</p>
<VForm @submit.prevent="handleEmailChange">
<VRow>
<VCol cols="12">
<AppTextField
v-model="emailForm.new_email"
label="Nieuw e-mailadres"
type="email"
:error-messages="emailFieldErrors.new_email"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="emailForm.password"
label="Huidig wachtwoord"
:type="showEmailPw ? 'text' : 'password'"
:append-inner-icon="showEmailPw ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="emailFieldErrors.password"
hint="Ter bevestiging van je identiteit"
persistent-hint
@click:append-inner="showEmailPw = !showEmailPw"
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="changeEmailMutation.isPending.value"
:disabled="!emailForm.new_email || !emailForm.password"
>
Verificatiemail versturen
</VBtn>
</div>
</VForm>
<VAlert
v-if="emailSuccess"
type="success"
variant="tonal"
density="compact"
class="mt-4"
>
{{ emailSuccess }}
</VAlert>
</VCardText>
</VCard>
<!-- Password change -->
<VCard>
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
<VCardText>
<VForm @submit.prevent="handlePasswordChange">
<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"
@click:append-inner="showCurrentPw = !showCurrentPw"
/>
</VCol>
<VCol
cols="12"
sm="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"
@click:append-inner="showNewPw = !showNewPw"
/>
</VCol>
<VCol
cols="12"
sm="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"
@click:append-inner="showConfirmPw = !showConfirmPw"
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="changePasswordMutation.isPending.value"
>
Wachtwoord wijzigen
</VBtn>
</div>
</VForm>
<VAlert
v-if="passwordSuccess"
type="success"
variant="tonal"
density="compact"
class="mt-4"
>
{{ passwordSuccess }}
</VAlert>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
import authV2ForgotPasswordIllustrationDark from '@images/pages/auth-v2-forgot-password-illustration-dark.png'
import authV2ForgotPasswordIllustrationLight from '@images/pages/auth-v2-forgot-password-illustration-light.png'
import authV2MaskDark from '@images/pages/misc-mask-dark.png'
import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
definePage({
meta: {
layout: 'blank',
public: true,
},
})
const email = ref('')
const isSubmitting = ref(false)
const done = ref(false)
const authThemeImg = useGenerateImageVariant(
authV2ForgotPasswordIllustrationLight,
authV2ForgotPasswordIllustrationDark,
)
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
async function onSubmit(): Promise<void> {
isSubmitting.value = true
try {
await apiClient.post('/auth/forgot-password', {
email: email.value.trim(),
app: 'app',
})
}
catch {
// Always show generic success (no email enumeration)
}
finally {
isSubmitting.value = false
done.value = true
}
}
</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 flip-in-rtl"
: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">
Wachtwoord vergeten?
</h4>
<p class="mb-0">
Vul je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.
</p>
</VCardText>
<VCardText>
<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>
<VCol cols="12">
<AppTextField
v-model="email"
autofocus
label="E-mailadres"
type="email"
placeholder="naam@voorbeeld.nl"
/>
</VCol>
<VCol cols="12">
<VBtn
block
type="submit"
:loading="isSubmitting"
>
Verstuur herstelmail
</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

@@ -24,6 +24,8 @@ definePage({
const route = useRoute()
const router = useRouter()
const passwordResetDone = computed(() => route.query.reset === '1')
const form = ref<LoginCredentials & { remember: boolean }>({
email: '',
password: '',
@@ -139,13 +141,23 @@ function onSubmit() {
>
<VCardText>
<h4 class="text-h4 mb-1">
Welcome to <span class="text-capitalize">{{ themeConfig.app.title }}</span>! 👋🏻
Welkom bij <span class="text-capitalize">{{ themeConfig.app.title }}</span>!
</h4>
<p class="mb-0">
Please sign-in to your account and start the adventure
Log in op je account om verder te gaan
</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"
@@ -181,14 +193,14 @@ function onSubmit() {
<div class="d-flex align-center flex-wrap justify-space-between my-6">
<VCheckbox
v-model="form.remember"
label="Remember me"
label="Onthoud mij"
/>
<a
class="text-primary"
href="javascript:void(0)"
<RouterLink
class="text-primary ms-2 mb-1"
:to="{ name: 'forgot-password' }"
>
Forgot Password?
</a>
Wachtwoord vergeten?
</RouterLink>
</div>
<VBtn

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useMemberList, useRemoveMember, useRevokeInvitation } from '@/composables/api/useMembers'
import { useAdminChangeEmail } from '@/composables/api/useAccount'
import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue'
@@ -39,6 +40,14 @@ const isRevokeDialogOpen = ref(false)
const invitationToRevoke = ref<{ id: string; email: string } | null>(null)
const { mutate: revokeInvitation, isPending: isRevoking } = useRevokeInvitation(orgId)
// Change email
const isEmailChangeDialogOpen = ref(false)
const memberToChangeEmail = ref<Member | null>(null)
const newMemberEmail = ref('')
const adminEmailErrors = ref<Record<string, string>>({})
const showEmailChangeSuccess = ref(false)
const { mutate: adminChangeEmail, isPending: isChangingMemberEmail } = useAdminChangeEmail(orgId)
const showRemoveSuccess = ref(false)
const showRevokeSuccess = ref(false)
@@ -115,6 +124,38 @@ function openRevokeDialog(invitation: { id: string; email: string }) {
isRevokeDialogOpen.value = true
}
function openEmailChangeDialog(member: Member) {
memberToChangeEmail.value = member
newMemberEmail.value = ''
adminEmailErrors.value = {}
isEmailChangeDialogOpen.value = true
}
function confirmEmailChange() {
if (!memberToChangeEmail.value) return
adminEmailErrors.value = {}
adminChangeEmail(
{ userId: memberToChangeEmail.value.id, newEmail: newMemberEmail.value },
{
onSuccess: () => {
isEmailChangeDialogOpen.value = false
memberToChangeEmail.value = null
newMemberEmail.value = ''
showEmailChangeSuccess.value = true
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
adminEmailErrors.value[key] = messages[0]
}
}
},
},
)
}
function confirmRevokeInvitation() {
if (!invitationToRevoke.value) return
@@ -223,6 +264,12 @@ function confirmRevokeInvitation() {
size="small"
@click="openEditRole(item)"
/>
<VBtn
icon="tabler-mail-forward"
variant="text"
size="small"
@click="openEmailChangeDialog(item)"
/>
<VBtn
icon="tabler-trash"
variant="text"
@@ -354,6 +401,44 @@ function confirmRevokeInvitation() {
</VCard>
</VDialog>
<!-- Change email dialog -->
<VDialog
v-model="isEmailChangeDialogOpen"
max-width="420"
>
<VCard title="E-mailadres wijzigen">
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">
Wijzig het e-mailadres van <strong>{{ memberToChangeEmail?.full_name }}</strong>.
Er wordt een verificatiemail verstuurd naar het nieuwe adres.
</p>
<AppTextField
v-model="newMemberEmail"
label="Nieuw e-mailadres"
type="email"
:error-messages="adminEmailErrors.new_email"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isEmailChangeDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isChangingMemberEmail"
:disabled="!newMemberEmail"
@click="confirmEmailChange"
>
Verificatiemail versturen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Success snackbars -->
<VSnackbar
v-model="showRemoveSuccess"
@@ -369,5 +454,12 @@ function confirmRevokeInvitation() {
>
Uitnodiging ingetrokken
</VSnackbar>
<VSnackbar
v-model="showEmailChangeSuccess"
color="success"
:timeout="4000"
>
Verificatiemail verstuurd
</VSnackbar>
</div>
</template>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
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 { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
definePage({
meta: {
layout: 'blank',
public: 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 flip-in-rtl"
: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,116 @@
<script setup lang="ts">
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
definePage({
meta: {
layout: 'blank',
public: true,
},
})
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="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>