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>

View File

@@ -0,0 +1,61 @@
import { useMutation } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
interface ApiResponse<T> {
success: boolean
data: T
message: string
}
export interface ChangePasswordPayload {
current_password: string
password: string
password_confirmation: string
}
export interface ChangeEmailPayload {
new_email: string
password: string
app: 'app' | 'portal' | 'admin'
}
export interface AdminChangeEmailPayload {
new_email: string
}
export function useChangePassword() {
return useMutation({
mutationFn: async (payload: ChangePasswordPayload) => {
const { data } = await apiClient.post<ApiResponse<null>>(
'/me/change-password',
payload,
)
return data
},
})
}
export function useChangeEmail() {
return useMutation({
mutationFn: async (payload: ChangeEmailPayload) => {
const { data } = await apiClient.post<ApiResponse<null>>(
'/me/change-email',
payload,
)
return data
},
})
}
export function useAdminChangeEmail(orgId: Ref<string>) {
return useMutation({
mutationFn: async ({ userId, newEmail }: { userId: string; newEmail: string }) => {
const { data } = await apiClient.post<ApiResponse<null>>(
`/organisations/${orgId.value}/members/${userId}/change-email`,
{ new_email: newEmail },
)
return data
},
})
}

View File

@@ -76,6 +76,17 @@ function handleLogout() {
<VDivider class="my-2" />
<VListItem :to="{ name: 'account-settings' }">
<template #prepend>
<VIcon
class="me-2"
icon="tabler-settings"
size="22"
/>
</template>
<VListItemTitle>Accountinstellingen</VListItemTitle>
</VListItem>
<VListItem
:disabled="isLoggingOut"
@click="handleLogout"

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>

View File

@@ -13,9 +13,10 @@ export function setupGuards(router: Router) {
const isPublic = to.meta.public === true
// Allow public routes (login, 404) — but redirect authenticated users away from login
// Allow public routes (login, auth pages, 404) — but redirect authenticated users away from login
if (isPublic) {
if (authStore.isAuthenticated && to.path === '/login') {
const guestOnlyPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
if (authStore.isAuthenticated && guestOnlyPaths.some(p => to.path === p)) {
return { name: 'dashboard' }
}
return

View File

@@ -20,6 +20,7 @@ declare module 'vue-router/auto-routes' {
export interface RouteNamedMap {
'root': RouteRecordInfo<'root', '/', Record<never, never>, Record<never, never>>,
'$error': RouteRecordInfo<'$error', '/:error(.*)', { error: ParamValue<true> }, { error: ParamValue<false> }>,
'account-settings': RouteRecordInfo<'account-settings', '/account-settings', Record<never, never>, Record<never, never>>,
'dashboard': RouteRecordInfo<'dashboard', '/dashboard', Record<never, never>, Record<never, never>>,
'events': RouteRecordInfo<'events', '/events', Record<never, never>, Record<never, never>>,
'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
@@ -32,12 +33,15 @@ declare module 'vue-router/auto-routes' {
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-settings-registration-fields': RouteRecordInfo<'events-id-settings-registration-fields', '/events/:id/settings/registration-fields', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'forgot-password': RouteRecordInfo<'forgot-password', '/forgot-password', Record<never, never>, Record<never, never>>,
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
'organisation': RouteRecordInfo<'organisation', '/organisation', Record<never, never>, Record<never, never>>,
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record<never, never>, Record<never, never>>,
'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record<never, never>, Record<never, never>>,
'organisation-settings': RouteRecordInfo<'organisation-settings', '/organisation/settings', Record<never, never>, Record<never, never>>,
'reset-password': RouteRecordInfo<'reset-password', '/reset-password', Record<never, never>, Record<never, never>>,
'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record<never, never>, Record<never, never>>,
'verify-email-change': RouteRecordInfo<'verify-email-change', '/verify-email-change', Record<never, never>, Record<never, never>>,
}
}

View File

@@ -53,7 +53,7 @@ apiClient.interceptors.response.use(
if (typeof window !== 'undefined') {
const path = window.location.pathname
const publicPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten']
const publicPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten', '/verify-email-change']
if (!publicPaths.some(p => path.startsWith(p)) && !path.startsWith('/register')) {
window.location.href = '/login'
}

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)

View File

@@ -2,7 +2,7 @@ import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten']
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten', '/verify-email-change']
// Old dashboard routes that need backward-compat redirects
const dashboardRedirects: Record<string, string> = {

View File

@@ -29,6 +29,7 @@ declare module 'vue-router/auto-routes' {
'register-success': RouteRecordInfo<'register-success', '/register/success', Record<never, never>, Record<never, never>>,
'volunteer-register-info': RouteRecordInfo<'volunteer-register-info', '/registreren', Record<never, never>, Record<never, never>>,
'portal-shifts': RouteRecordInfo<'portal-shifts', '/shifts', Record<never, never>, Record<never, never>>,
'verify-email-change': RouteRecordInfo<'verify-email-change', '/verify-email-change', Record<never, never>, Record<never, never>>,
'reset-password': RouteRecordInfo<'reset-password', '/wachtwoord-resetten', Record<never, never>, Record<never, never>>,
'forgot-password': RouteRecordInfo<'forgot-password', '/wachtwoord-vergeten', Record<never, never>, Record<never, never>>,
}