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:
@@ -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"
|
||||
|
||||
114
apps/portal/src/pages/verify-email-change.vue
Normal file
114
apps/portal/src/pages/verify-email-change.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user