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:
61
apps/app/src/composables/api/useAccount.ts
Normal file
61
apps/app/src/composables/api/useAccount.ts
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
221
apps/app/src/pages/account-settings.vue
Normal file
221
apps/app/src/pages/account-settings.vue
Normal 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>
|
||||
163
apps/app/src/pages/forgot-password.vue
Normal file
163
apps/app/src/pages/forgot-password.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
202
apps/app/src/pages/reset-password.vue
Normal file
202
apps/app/src/pages/reset-password.vue
Normal 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>
|
||||
116
apps/app/src/pages/verify-email-change.vue
Normal file
116
apps/app/src/pages/verify-email-change.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user