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:
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
204
apps/admin/src/pages/reset-password.vue
Normal file
204
apps/admin/src/pages/reset-password.vue
Normal 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>
|
||||
117
apps/admin/src/pages/verify-email-change.vue
Normal file
117
apps/admin/src/pages/verify-email-change.vue
Normal 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>
|
||||
Reference in New Issue
Block a user