From 836cffa23251446afa2fceb73fb52c1b10201709 Mon Sep 17 00:00:00 2001
From: "bert.hausmans"
Date: Tue, 14 Apr 2026 15:38:54 +0200
Subject: [PATCH] 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)
---
api/app/Enums/EmailChangeStatus.php | 13 +
.../Controllers/Api/V1/AccountController.php | 55 ++++
.../Api/V1/EmailChangeController.php | 71 ++++
.../Controllers/Api/V1/MemberController.php | 28 ++
.../Api/V1/PasswordResetController.php | 39 ++-
api/app/Mail/EmailChangedConfirmationMail.php | 43 +++
api/app/Mail/VerifyEmailChangeMail.php | 50 +++
api/app/Models/EmailChangeRequest.php | 58 ++++
.../ResetPasswordNotification.php | 40 +++
api/app/Services/EmailChangeService.php | 157 +++++++++
api/config/app.php | 1 +
...000_create_email_change_requests_table.php | 36 +++
.../email-changed-confirmation.blade.php | 12 +
.../emails/verify-email-change.blade.php | 22 ++
api/routes/api.php | 10 +
api/tests/Feature/Api/V1/EmailChangeTest.php | 303 ++++++++++++++++++
.../Feature/Api/V1/PasswordChangeTest.php | 118 +++++++
.../Feature/Api/V1/PasswordResetTest.php | 80 +++--
.../Security/AuthenticationSecurityTest.php | 2 +
apps/admin/src/lib/axios.ts | 3 +-
apps/admin/src/pages/forgot-password.vue | 52 ++-
apps/admin/src/pages/login.vue | 12 +
apps/admin/src/pages/reset-password.vue | 204 ++++++++++++
apps/admin/src/pages/verify-email-change.vue | 117 +++++++
apps/app/src/composables/api/useAccount.ts | 61 ++++
.../src/layouts/components/UserProfile.vue | 11 +
apps/app/src/pages/account-settings.vue | 221 +++++++++++++
apps/app/src/pages/forgot-password.vue | 163 ++++++++++
apps/app/src/pages/login.vue | 28 +-
apps/app/src/pages/organisation/members.vue | 92 ++++++
apps/app/src/pages/reset-password.vue | 202 ++++++++++++
apps/app/src/pages/verify-email-change.vue | 116 +++++++
apps/app/src/plugins/1.router/guards.ts | 5 +-
apps/app/typed-router.d.ts | 4 +
apps/portal/src/lib/axios.ts | 2 +-
apps/portal/src/pages/profiel.vue | 131 +++++++-
apps/portal/src/pages/verify-email-change.vue | 114 +++++++
apps/portal/src/pages/wachtwoord-vergeten.vue | 2 +-
apps/portal/src/plugins/1.router/guards.ts | 2 +-
apps/portal/typed-router.d.ts | 1 +
dev-docs/API.md | 9 +
dev-docs/SCHEMA.md | 20 ++
42 files changed, 2643 insertions(+), 67 deletions(-)
create mode 100644 api/app/Enums/EmailChangeStatus.php
create mode 100644 api/app/Http/Controllers/Api/V1/AccountController.php
create mode 100644 api/app/Http/Controllers/Api/V1/EmailChangeController.php
create mode 100644 api/app/Mail/EmailChangedConfirmationMail.php
create mode 100644 api/app/Mail/VerifyEmailChangeMail.php
create mode 100644 api/app/Models/EmailChangeRequest.php
create mode 100644 api/app/Notifications/ResetPasswordNotification.php
create mode 100644 api/app/Services/EmailChangeService.php
create mode 100644 api/database/migrations/2026_04_14_300000_create_email_change_requests_table.php
create mode 100644 api/resources/views/emails/email-changed-confirmation.blade.php
create mode 100644 api/resources/views/emails/verify-email-change.blade.php
create mode 100644 api/tests/Feature/Api/V1/EmailChangeTest.php
create mode 100644 api/tests/Feature/Api/V1/PasswordChangeTest.php
create mode 100644 apps/admin/src/pages/reset-password.vue
create mode 100644 apps/admin/src/pages/verify-email-change.vue
create mode 100644 apps/app/src/composables/api/useAccount.ts
create mode 100644 apps/app/src/pages/account-settings.vue
create mode 100644 apps/app/src/pages/forgot-password.vue
create mode 100644 apps/app/src/pages/reset-password.vue
create mode 100644 apps/app/src/pages/verify-email-change.vue
create mode 100644 apps/portal/src/pages/verify-email-change.vue
diff --git a/api/app/Enums/EmailChangeStatus.php b/api/app/Enums/EmailChangeStatus.php
new file mode 100644
index 00000000..009d3539
--- /dev/null
+++ b/api/app/Enums/EmailChangeStatus.php
@@ -0,0 +1,13 @@
+validate([
+ 'current_password' => ['required'],
+ 'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
+ ]);
+
+ $user = $request->user();
+
+ if (! Hash::check($validated['current_password'], $user->password)) {
+ throw ValidationException::withMessages([
+ 'current_password' => ['Het huidige wachtwoord is onjuist.'],
+ ]);
+ }
+
+ $user->update([
+ 'password' => Hash::make($validated['password']),
+ ]);
+
+ // Revoke all OTHER tokens (keep current session)
+ $currentToken = $user->currentAccessToken();
+ if ($currentToken instanceof \Laravel\Sanctum\PersonalAccessToken) {
+ $user->tokens()->where('id', '!=', $currentToken->id)->delete();
+ } else {
+ // TransientToken (test) or no token — revoke all
+ $user->tokens()->delete();
+ }
+
+ activity()
+ ->causedBy($user)
+ ->performedOn($user)
+ ->log('user.password_changed');
+
+ return $this->success(message: 'Je wachtwoord is succesvol gewijzigd.');
+ }
+}
diff --git a/api/app/Http/Controllers/Api/V1/EmailChangeController.php b/api/app/Http/Controllers/Api/V1/EmailChangeController.php
new file mode 100644
index 00000000..5e73ad63
--- /dev/null
+++ b/api/app/Http/Controllers/Api/V1/EmailChangeController.php
@@ -0,0 +1,71 @@
+validate([
+ 'new_email' => ['required', 'email', 'max:255'],
+ 'password' => ['required'],
+ 'app' => ['required', 'in:app,portal,admin'],
+ ]);
+
+ // Verify current password
+ if (! Hash::check($validated['password'], $request->user()->password)) {
+ throw ValidationException::withMessages([
+ 'password' => ['Het huidige wachtwoord is onjuist.'],
+ ]);
+ }
+
+ $frontendUrls = [
+ 'admin' => config('app.frontend_admin_url'),
+ 'app' => config('app.frontend_app_url'),
+ 'portal' => config('app.frontend_portal_url'),
+ ];
+
+ $this->service->requestChange(
+ $request->user(),
+ $validated['new_email'],
+ $request->user(),
+ $frontendUrls[$validated['app']],
+ );
+
+ return $this->success(
+ message: 'Er is een verificatiemail verstuurd naar ' . $validated['new_email'] . '.',
+ );
+ }
+
+ /**
+ * POST /api/v1/verify-email-change
+ * Public endpoint — verifies the token from the email link.
+ */
+ public function verify(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'token' => ['required', 'string'],
+ ]);
+
+ $this->service->verifyChange($validated['token']);
+
+ return $this->success(
+ message: 'Je e-mailadres is succesvol gewijzigd.',
+ );
+ }
+}
diff --git a/api/app/Http/Controllers/Api/V1/MemberController.php b/api/app/Http/Controllers/Api/V1/MemberController.php
index 62789c54..127bd1ba 100644
--- a/api/app/Http/Controllers/Api/V1/MemberController.php
+++ b/api/app/Http/Controllers/Api/V1/MemberController.php
@@ -10,7 +10,9 @@ use App\Http\Resources\Api\V1\MemberCollection;
use App\Http\Resources\Api\V1\MemberResource;
use App\Models\Organisation;
use App\Models\User;
+use App\Services\EmailChangeService;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
final class MemberController extends Controller
@@ -81,4 +83,30 @@ final class MemberController extends Controller
return response()->json(null, 204);
}
+
+ /**
+ * POST /api/v1/organisations/{organisation}/members/{user}/change-email
+ * Admin changes a member's email (sends verification to new address).
+ */
+ public function changeEmail(Request $request, Organisation $organisation, User $user): JsonResponse
+ {
+ Gate::authorize('invite', $organisation);
+
+ $validated = $request->validate([
+ 'new_email' => ['required', 'email', 'max:255'],
+ ]);
+
+ $frontendUrl = config('app.frontend_app_url');
+
+ app(EmailChangeService::class)->requestChange(
+ $user,
+ $validated['new_email'],
+ $request->user(),
+ $frontendUrl,
+ );
+
+ return $this->success(
+ message: 'Er is een verificatiemail verstuurd naar ' . $validated['new_email'] . '.',
+ );
+ }
}
diff --git a/api/app/Http/Controllers/Api/V1/PasswordResetController.php b/api/app/Http/Controllers/Api/V1/PasswordResetController.php
index a852f83e..6173c347 100644
--- a/api/app/Http/Controllers/Api/V1/PasswordResetController.php
+++ b/api/app/Http/Controllers/Api/V1/PasswordResetController.php
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
+use App\Models\User;
+use App\Notifications\ResetPasswordNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -15,34 +17,57 @@ final class PasswordResetController extends Controller
{
public function sendResetLink(Request $request): JsonResponse
{
- $request->validate(['email' => 'required|email']);
+ $request->validate([
+ 'email' => ['required', 'email'],
+ 'app' => ['required', 'in:app,portal,admin'],
+ ]);
- Password::sendResetLink(['email' => strtolower($request->email)]);
+ $frontendUrls = [
+ 'admin' => config('app.frontend_admin_url'),
+ 'app' => config('app.frontend_app_url'),
+ 'portal' => config('app.frontend_portal_url'),
+ ];
+
+ $frontendUrl = $frontendUrls[$request->input('app')];
+
+ Password::sendResetLink(
+ ['email' => strtolower($request->email)],
+ function (User $user, string $token) use ($frontendUrl) {
+ $user->notify(new ResetPasswordNotification($token, $frontendUrl));
+ }
+ );
// Always return success (don't leak whether email exists)
return $this->success(
- message: 'Als dit emailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.'
+ message: 'Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te herstellen.'
);
}
public function resetPassword(Request $request): JsonResponse
{
$request->validate([
- 'token' => 'required',
- 'email' => 'required|email',
+ 'token' => ['required'],
+ 'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(8)->mixedCase()->numbers()],
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
- function ($user, $password) {
+ function (User $user, string $password) {
$user->forceFill(['password' => Hash::make($password)])->save();
+
+ // Revoke all existing tokens (force re-login everywhere)
$user->tokens()->delete();
+
+ activity()
+ ->causedBy($user)
+ ->performedOn($user)
+ ->log('user.password_reset');
}
);
if ($status === Password::PASSWORD_RESET) {
- return $this->success(message: 'Wachtwoord succesvol gewijzigd.');
+ return $this->success(message: 'Je wachtwoord is succesvol gewijzigd. Je kunt nu inloggen.');
}
return $this->error(__($status), 422);
diff --git a/api/app/Mail/EmailChangedConfirmationMail.php b/api/app/Mail/EmailChangedConfirmationMail.php
new file mode 100644
index 00000000..afe5e168
--- /dev/null
+++ b/api/app/Mail/EmailChangedConfirmationMail.php
@@ -0,0 +1,43 @@
+ $this->user->first_name,
+ 'newEmail' => $this->newEmail,
+ ],
+ );
+ }
+}
diff --git a/api/app/Mail/VerifyEmailChangeMail.php b/api/app/Mail/VerifyEmailChangeMail.php
new file mode 100644
index 00000000..60804e66
--- /dev/null
+++ b/api/app/Mail/VerifyEmailChangeMail.php
@@ -0,0 +1,50 @@
+frontendUrl . '/verify-email-change?token=' . $this->token;
+ $isSelfChange = $this->user->id === $this->requestedBy->id;
+
+ return new Content(
+ markdown: 'emails.verify-email-change',
+ with: [
+ 'verifyUrl' => $verifyUrl,
+ 'userName' => $this->user->first_name,
+ 'isSelfChange' => $isSelfChange,
+ 'requestedByName' => $this->requestedBy->full_name,
+ ],
+ );
+ }
+}
diff --git a/api/app/Models/EmailChangeRequest.php b/api/app/Models/EmailChangeRequest.php
new file mode 100644
index 00000000..77ef5aae
--- /dev/null
+++ b/api/app/Models/EmailChangeRequest.php
@@ -0,0 +1,58 @@
+ EmailChangeStatus::class,
+ 'expires_at' => 'datetime',
+ 'verified_at' => 'datetime',
+ ];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function requestedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'requested_by_user_id');
+ }
+
+ public function isExpired(): bool
+ {
+ return $this->expires_at->isPast();
+ }
+
+ public function scopePending($query)
+ {
+ return $query->where('status', EmailChangeStatus::PENDING)
+ ->where('expires_at', '>', now());
+ }
+}
diff --git a/api/app/Notifications/ResetPasswordNotification.php b/api/app/Notifications/ResetPasswordNotification.php
new file mode 100644
index 00000000..76196b76
--- /dev/null
+++ b/api/app/Notifications/ResetPasswordNotification.php
@@ -0,0 +1,40 @@
+frontendUrl . '/reset-password?token=' . $this->token
+ . '&email=' . urlencode($notifiable->email);
+
+ return (new MailMessage)
+ ->subject('Wachtwoord herstellen — Crewli')
+ ->greeting('Hallo ' . $notifiable->first_name . ',')
+ ->line('Je ontvangt deze e-mail omdat we een verzoek hebben ontvangen om je wachtwoord te herstellen.')
+ ->action('Wachtwoord herstellen', $resetUrl)
+ ->line('Deze link is 60 minuten geldig.')
+ ->line('Als je geen wachtwoordherstel hebt aangevraagd, kun je deze e-mail negeren.')
+ ->salutation('Groeten, Crewli');
+ }
+}
diff --git a/api/app/Services/EmailChangeService.php b/api/app/Services/EmailChangeService.php
new file mode 100644
index 00000000..f4ae3479
--- /dev/null
+++ b/api/app/Services/EmailChangeService.php
@@ -0,0 +1,157 @@
+where('id', '!=', $user->id)->exists()) {
+ throw ValidationException::withMessages([
+ 'new_email' => ['Dit e-mailadres is al in gebruik door een ander account.'],
+ ]);
+ }
+
+ // Cancel any existing pending requests for this user
+ EmailChangeRequest::where('user_id', $user->id)
+ ->where('status', EmailChangeStatus::PENDING)
+ ->update(['status' => EmailChangeStatus::CANCELLED->value]);
+
+ // Generate secure token
+ $plainToken = Str::random(64);
+
+ $request = EmailChangeRequest::create([
+ 'user_id' => $user->id,
+ 'current_email' => $user->email,
+ 'new_email' => $newEmail,
+ 'token' => hash('sha256', $plainToken),
+ 'requested_by_user_id' => $requestedBy->id,
+ 'status' => EmailChangeStatus::PENDING,
+ 'expires_at' => now()->addHours(24),
+ ]);
+
+ // Send verification email to the NEW address
+ Mail::to($newEmail)->send(new VerifyEmailChangeMail(
+ user: $user,
+ newEmail: $newEmail,
+ token: $plainToken,
+ frontendUrl: $frontendUrl,
+ requestedBy: $requestedBy,
+ ));
+
+ activity()
+ ->causedBy($requestedBy)
+ ->performedOn($user)
+ ->withProperties([
+ 'current_email' => $user->email,
+ 'new_email' => $newEmail,
+ 'is_self_change' => $user->id === $requestedBy->id,
+ ])
+ ->log('user.email_change_requested');
+
+ return $request;
+ }
+
+ /**
+ * Verify and execute the email change.
+ */
+ public function verifyChange(string $plainToken): EmailChangeRequest
+ {
+ $hashedToken = hash('sha256', $plainToken);
+
+ $request = EmailChangeRequest::where('token', $hashedToken)
+ ->where('status', EmailChangeStatus::PENDING)
+ ->first();
+
+ if (! $request) {
+ throw ValidationException::withMessages([
+ 'token' => ['Ongeldige of verlopen verificatielink.'],
+ ]);
+ }
+
+ if ($request->isExpired()) {
+ $request->update(['status' => EmailChangeStatus::EXPIRED]);
+ throw ValidationException::withMessages([
+ 'token' => ['Deze verificatielink is verlopen. Vraag opnieuw een e-mailwijziging aan.'],
+ ]);
+ }
+
+ // Final check: new email still not in use
+ if (User::where('email', $request->new_email)
+ ->where('id', '!=', $request->user_id)->exists()) {
+ $request->update(['status' => EmailChangeStatus::CANCELLED]);
+ throw ValidationException::withMessages([
+ 'new_email' => ['Dit e-mailadres is inmiddels in gebruik door een ander account.'],
+ ]);
+ }
+
+ $user = $request->user;
+ $oldEmail = $user->email;
+
+ DB::transaction(function () use ($request, $user) {
+ // Update user email
+ $user->update(['email' => $request->new_email]);
+
+ // Mark request as verified
+ $request->update([
+ 'status' => EmailChangeStatus::VERIFIED,
+ 'verified_at' => now(),
+ ]);
+ });
+
+ // Send confirmation to the OLD email address
+ Mail::to($oldEmail)->send(new EmailChangedConfirmationMail(
+ user: $user,
+ oldEmail: $oldEmail,
+ newEmail: $request->new_email,
+ ));
+
+ // Revoke all tokens (force re-login with new email)
+ $user->tokens()->delete();
+
+ // Log linked person email context
+ $persons = Person::where('user_id', $user->id)->get();
+ foreach ($persons as $person) {
+ activity()
+ ->causedBy($user)
+ ->performedOn($person)
+ ->withProperties([
+ 'old_user_email' => $oldEmail,
+ 'new_user_email' => $request->new_email,
+ 'person_email_unchanged' => $person->email,
+ ])
+ ->log('person.linked_user_email_changed');
+ }
+
+ activity()
+ ->performedOn($user)
+ ->withProperties([
+ 'old_email' => $oldEmail,
+ 'new_email' => $request->new_email,
+ ])
+ ->log('user.email_changed');
+
+ return $request;
+ }
+}
diff --git a/api/config/app.php b/api/config/app.php
index 4e44f4bb..d37ad7ba 100644
--- a/api/config/app.php
+++ b/api/config/app.php
@@ -123,6 +123,7 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
+ 'frontend_admin_url' => env('FRONTEND_ADMIN_URL', 'http://localhost:5173'),
'frontend_app_url' => env('FRONTEND_APP_URL', 'http://localhost:5174'),
'frontend_portal_url' => env('FRONTEND_PORTAL_URL', 'http://localhost:5175'),
diff --git a/api/database/migrations/2026_04_14_300000_create_email_change_requests_table.php b/api/database/migrations/2026_04_14_300000_create_email_change_requests_table.php
new file mode 100644
index 00000000..f394fc34
--- /dev/null
+++ b/api/database/migrations/2026_04_14_300000_create_email_change_requests_table.php
@@ -0,0 +1,36 @@
+ulid('id')->primary();
+ $table->ulid('user_id');
+ $table->string('current_email');
+ $table->string('new_email');
+ $table->string('token');
+ $table->ulid('requested_by_user_id')->nullable();
+ $table->string('status')->default('pending');
+ $table->timestamp('expires_at');
+ $table->timestamp('verified_at')->nullable();
+ $table->timestamps();
+
+ $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
+ $table->foreign('requested_by_user_id')->references('id')->on('users')->nullOnDelete();
+ $table->index(['user_id', 'status']);
+ $table->index(['token']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('email_change_requests');
+ }
+};
diff --git a/api/resources/views/emails/email-changed-confirmation.blade.php b/api/resources/views/emails/email-changed-confirmation.blade.php
new file mode 100644
index 00000000..159ff70c
--- /dev/null
+++ b/api/resources/views/emails/email-changed-confirmation.blade.php
@@ -0,0 +1,12 @@
+@component('mail::message')
+# Je e-mailadres is gewijzigd
+
+Hallo {{ $userName }},
+
+Het e-mailadres van je Crewli-account is zojuist gewijzigd naar **{{ $newEmail }}**.
+
+Als je deze wijziging niet hebt aangevraagd, neem dan onmiddellijk contact met ons op.
+
+Groeten,
+Crewli
+@endcomponent
diff --git a/api/resources/views/emails/verify-email-change.blade.php b/api/resources/views/emails/verify-email-change.blade.php
new file mode 100644
index 00000000..17479e2f
--- /dev/null
+++ b/api/resources/views/emails/verify-email-change.blade.php
@@ -0,0 +1,22 @@
+@component('mail::message')
+# Bevestig je nieuwe e-mailadres
+
+Hallo {{ $userName }},
+
+@if($isSelfChange)
+Je hebt verzocht om je e-mailadres te wijzigen naar dit adres.
+@else
+{{ $requestedByName }} heeft verzocht om je e-mailadres te wijzigen naar dit adres.
+@endif
+
+Klik op de knop hieronder om te bevestigen dat dit e-mailadres van jou is.
+
+@component('mail::button', ['url' => $verifyUrl])
+E-mailadres bevestigen
+@endcomponent
+
+Deze link is 24 uur geldig. Als je dit niet hebt aangevraagd, kun je deze e-mail negeren.
+
+Groeten,
+Crewli
+@endcomponent
diff --git a/api/routes/api.php b/api/routes/api.php
index 15e45a9c..71572858 100644
--- a/api/routes/api.php
+++ b/api/routes/api.php
@@ -29,6 +29,8 @@ use App\Http\Controllers\Api\V1\VolunteerAvailabilityController;
use App\Http\Controllers\Api\V1\VolunteerRegistrationController;
use App\Http\Controllers\Api\V1\PublicRegistrationDataController;
use App\Http\Controllers\Api\V1\PortalTokenController;
+use App\Http\Controllers\Api\V1\AccountController;
+use App\Http\Controllers\Api\V1\EmailChangeController;
use App\Http\Controllers\Api\V1\PasswordResetController;
use App\Http\Controllers\Api\V1\PortalMeController;
use App\Http\Controllers\Api\V1\Portal\PortalShiftController;
@@ -66,6 +68,9 @@ Route::post('auth/forgot-password', [PasswordResetController::class, 'sendResetL
->middleware('throttle:5,1');
Route::post('auth/reset-password', [PasswordResetController::class, 'resetPassword']);
+// Email change verification (public — token from email link)
+Route::post('verify-email-change', [EmailChangeController::class, 'verify']);
+
// Public portal routes
Route::get('public/events/{slug}/registration-data', PublicRegistrationDataController::class);
Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1');
@@ -78,6 +83,10 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('auth/me', MeController::class);
Route::post('auth/logout', LogoutController::class);
+ // Account management (self-service)
+ Route::post('me/change-password', [AccountController::class, 'changePassword']);
+ Route::post('me/change-email', [EmailChangeController::class, 'request']);
+
// Portal (authenticated)
Route::get('portal/me', [PortalMeController::class, 'index']);
Route::put('portal/profile', [PortalMeController::class, 'updateProfile']);
@@ -148,6 +157,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('members', [MemberController::class, 'index']);
Route::put('members/{user}', [MemberController::class, 'update']);
Route::delete('members/{user}', [MemberController::class, 'destroy']);
+ Route::post('members/{user}/change-email', [MemberController::class, 'changeEmail']);
// Event sub-resources (all nested under organisation prefix — A01-13)
Route::prefix('events/{event}')->group(function () {
diff --git a/api/tests/Feature/Api/V1/EmailChangeTest.php b/api/tests/Feature/Api/V1/EmailChangeTest.php
new file mode 100644
index 00000000..01dd1ab2
--- /dev/null
+++ b/api/tests/Feature/Api/V1/EmailChangeTest.php
@@ -0,0 +1,303 @@
+create([
+ 'email' => 'oud@voorbeeld.nl',
+ 'password' => bcrypt('wachtwoord123'),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'password' => 'wachtwoord123',
+ 'app' => 'app',
+ ]);
+
+ $response->assertOk();
+
+ $this->assertDatabaseHas('email_change_requests', [
+ 'user_id' => $user->id,
+ 'current_email' => 'oud@voorbeeld.nl',
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'status' => 'pending',
+ ]);
+
+ Mail::assertQueued(VerifyEmailChangeMail::class, function ($mail) {
+ return $mail->hasTo('nieuw@voorbeeld.nl');
+ });
+ }
+
+ public function test_email_change_requires_correct_password(): void
+ {
+ $user = User::factory()->create([
+ 'password' => bcrypt('wachtwoord123'),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'password' => 'foutwachtwoord',
+ 'app' => 'app',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('password');
+ }
+
+ public function test_email_change_rejects_already_used_email(): void
+ {
+ User::factory()->create(['email' => 'bezet@voorbeeld.nl']);
+
+ $user = User::factory()->create([
+ 'password' => bcrypt('wachtwoord123'),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
+ 'new_email' => 'bezet@voorbeeld.nl',
+ 'password' => 'wachtwoord123',
+ 'app' => 'app',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('new_email');
+ }
+
+ public function test_email_change_requires_authentication(): void
+ {
+ $response = $this->postJson('/api/v1/me/change-email', [
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'password' => 'wachtwoord123',
+ 'app' => 'app',
+ ]);
+
+ $response->assertStatus(401);
+ }
+
+ public function test_duplicate_pending_request_cancels_old_one(): void
+ {
+ Mail::fake();
+
+ $user = User::factory()->create([
+ 'email' => 'oud@voorbeeld.nl',
+ 'password' => bcrypt('wachtwoord123'),
+ ]);
+
+ // First request
+ $this->actingAs($user)->postJson('/api/v1/me/change-email', [
+ 'new_email' => 'eerste@voorbeeld.nl',
+ 'password' => 'wachtwoord123',
+ 'app' => 'app',
+ ]);
+
+ $firstRequest = EmailChangeRequest::where('new_email', 'eerste@voorbeeld.nl')->first();
+ $this->assertEquals('pending', $firstRequest->status->value);
+
+ // Second request
+ $this->actingAs($user)->postJson('/api/v1/me/change-email', [
+ 'new_email' => 'tweede@voorbeeld.nl',
+ 'password' => 'wachtwoord123',
+ 'app' => 'app',
+ ]);
+
+ $firstRequest->refresh();
+ $this->assertEquals('cancelled', $firstRequest->status->value);
+
+ $secondRequest = EmailChangeRequest::where('new_email', 'tweede@voorbeeld.nl')->first();
+ $this->assertEquals('pending', $secondRequest->status->value);
+ }
+
+ // ─── Email Change Verification ──────────────────────────────────────
+
+ public function test_verify_email_change_with_valid_token(): void
+ {
+ Mail::fake();
+
+ $user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
+ $plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
+
+ EmailChangeRequest::create([
+ 'user_id' => $user->id,
+ 'current_email' => 'oud@voorbeeld.nl',
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'token' => hash('sha256', $plainToken),
+ 'requested_by_user_id' => $user->id,
+ 'status' => EmailChangeStatus::PENDING,
+ 'expires_at' => now()->addHours(24),
+ ]);
+
+ $response = $this->postJson('/api/v1/verify-email-change', [
+ 'token' => $plainToken,
+ ]);
+
+ $response->assertOk();
+
+ $user->refresh();
+ $this->assertEquals('nieuw@voorbeeld.nl', $user->email);
+
+ Mail::assertQueued(EmailChangedConfirmationMail::class, function ($mail) {
+ return $mail->hasTo('oud@voorbeeld.nl');
+ });
+ }
+
+ public function test_verify_email_change_revokes_all_tokens(): void
+ {
+ Mail::fake();
+
+ $user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
+ $user->createToken('test-token');
+
+ $plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
+
+ EmailChangeRequest::create([
+ 'user_id' => $user->id,
+ 'current_email' => 'oud@voorbeeld.nl',
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'token' => hash('sha256', $plainToken),
+ 'requested_by_user_id' => $user->id,
+ 'status' => EmailChangeStatus::PENDING,
+ 'expires_at' => now()->addHours(24),
+ ]);
+
+ $this->postJson('/api/v1/verify-email-change', [
+ 'token' => $plainToken,
+ ]);
+
+ $this->assertCount(0, $user->tokens()->get());
+ }
+
+ public function test_verify_email_change_with_expired_token(): void
+ {
+ $user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
+ $plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
+
+ EmailChangeRequest::create([
+ 'user_id' => $user->id,
+ 'current_email' => 'oud@voorbeeld.nl',
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'token' => hash('sha256', $plainToken),
+ 'requested_by_user_id' => $user->id,
+ 'status' => EmailChangeStatus::PENDING,
+ 'expires_at' => now()->subHour(),
+ ]);
+
+ $response = $this->postJson('/api/v1/verify-email-change', [
+ 'token' => $plainToken,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('token');
+ }
+
+ public function test_verify_email_change_with_invalid_token(): void
+ {
+ $response = $this->postJson('/api/v1/verify-email-change', [
+ 'token' => 'completely-invalid-token',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('token');
+ }
+
+ public function test_verify_email_change_fails_if_email_taken_between_request_and_verify(): void
+ {
+ $user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
+ $plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
+
+ EmailChangeRequest::create([
+ 'user_id' => $user->id,
+ 'current_email' => 'oud@voorbeeld.nl',
+ 'new_email' => 'nieuw@voorbeeld.nl',
+ 'token' => hash('sha256', $plainToken),
+ 'requested_by_user_id' => $user->id,
+ 'status' => EmailChangeStatus::PENDING,
+ 'expires_at' => now()->addHours(24),
+ ]);
+
+ // Another user takes the email
+ User::factory()->create(['email' => 'nieuw@voorbeeld.nl']);
+
+ $response = $this->postJson('/api/v1/verify-email-change', [
+ 'token' => $plainToken,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('new_email');
+ }
+
+ // ─── Admin Email Change ─────────────────────────────────────────────
+
+ public function test_admin_can_change_member_email(): void
+ {
+ Mail::fake();
+
+ $organisation = Organisation::factory()->create();
+ $admin = User::factory()->create();
+ $organisation->users()->attach($admin, ['role' => 'org_admin']);
+
+ $member = User::factory()->create(['email' => 'lid@voorbeeld.nl']);
+ $organisation->users()->attach($member, ['role' => 'org_member']);
+
+ $response = $this->actingAs($admin)->postJson(
+ "/api/v1/organisations/{$organisation->id}/members/{$member->id}/change-email",
+ ['new_email' => 'nieuw-lid@voorbeeld.nl'],
+ );
+
+ $response->assertOk();
+
+ Mail::assertQueued(VerifyEmailChangeMail::class, function ($mail) {
+ return $mail->hasTo('nieuw-lid@voorbeeld.nl');
+ });
+ }
+
+ public function test_non_admin_cannot_change_member_email(): void
+ {
+ $organisation = Organisation::factory()->create();
+ $member = User::factory()->create();
+ $organisation->users()->attach($member, ['role' => 'org_member']);
+
+ $otherMember = User::factory()->create(['email' => 'ander@voorbeeld.nl']);
+ $organisation->users()->attach($otherMember, ['role' => 'org_member']);
+
+ $response = $this->actingAs($member)->postJson(
+ "/api/v1/organisations/{$organisation->id}/members/{$otherMember->id}/change-email",
+ ['new_email' => 'nieuw@voorbeeld.nl'],
+ );
+
+ $response->assertStatus(403);
+ }
+
+ public function test_unauthenticated_cannot_change_member_email(): void
+ {
+ $organisation = Organisation::factory()->create();
+ $member = User::factory()->create();
+ $organisation->users()->attach($member, ['role' => 'org_member']);
+
+ $response = $this->postJson(
+ "/api/v1/organisations/{$organisation->id}/members/{$member->id}/change-email",
+ ['new_email' => 'nieuw@voorbeeld.nl'],
+ );
+
+ $response->assertStatus(401);
+ }
+}
diff --git a/api/tests/Feature/Api/V1/PasswordChangeTest.php b/api/tests/Feature/Api/V1/PasswordChangeTest.php
new file mode 100644
index 00000000..8989bda5
--- /dev/null
+++ b/api/tests/Feature/Api/V1/PasswordChangeTest.php
@@ -0,0 +1,118 @@
+create([
+ 'password' => bcrypt('OudWachtwoord1'),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/me/change-password', [
+ 'current_password' => 'OudWachtwoord1',
+ 'password' => 'NieuwWachtwoord1',
+ 'password_confirmation' => 'NieuwWachtwoord1',
+ ]);
+
+ $response->assertOk();
+
+ $user->refresh();
+ $this->assertTrue(Hash::check('NieuwWachtwoord1', $user->password));
+ }
+
+ public function test_password_change_requires_correct_current_password(): void
+ {
+ $user = User::factory()->create([
+ 'password' => bcrypt('OudWachtwoord1'),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/me/change-password', [
+ 'current_password' => 'FoutWachtwoord1',
+ 'password' => 'NieuwWachtwoord1',
+ 'password_confirmation' => 'NieuwWachtwoord1',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('current_password');
+ }
+
+ public function test_password_change_revokes_other_tokens(): void
+ {
+ $user = User::factory()->create([
+ 'password' => bcrypt('OudWachtwoord1'),
+ ]);
+
+ // Create two tokens
+ $currentToken = $user->createToken('current-session');
+ $otherToken = $user->createToken('other-session');
+
+ $this->assertCount(2, $user->tokens()->get());
+
+ // Act as user with the current token
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $currentToken->plainTextToken,
+ ])->postJson('/api/v1/me/change-password', [
+ 'current_password' => 'OudWachtwoord1',
+ 'password' => 'NieuwWachtwoord1',
+ 'password_confirmation' => 'NieuwWachtwoord1',
+ ]);
+
+ $response->assertOk();
+
+ // Only the current session token should remain
+ $this->assertCount(1, $user->tokens()->get());
+ }
+
+ public function test_password_change_requires_confirmation(): void
+ {
+ $user = User::factory()->create([
+ 'password' => bcrypt('OudWachtwoord1'),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/me/change-password', [
+ 'current_password' => 'OudWachtwoord1',
+ 'password' => 'NieuwWachtwoord1',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('password');
+ }
+
+ public function test_password_change_requires_authentication(): void
+ {
+ $response = $this->postJson('/api/v1/me/change-password', [
+ 'current_password' => 'OudWachtwoord1',
+ 'password' => 'NieuwWachtwoord1',
+ 'password_confirmation' => 'NieuwWachtwoord1',
+ ]);
+
+ $response->assertStatus(401);
+ }
+
+ public function test_password_change_enforces_password_rules(): void
+ {
+ $user = User::factory()->create([
+ 'password' => bcrypt('OudWachtwoord1'),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/me/change-password', [
+ 'current_password' => 'OudWachtwoord1',
+ 'password' => 'short',
+ 'password_confirmation' => 'short',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('password');
+ }
+}
diff --git a/api/tests/Feature/Api/V1/PasswordResetTest.php b/api/tests/Feature/Api/V1/PasswordResetTest.php
index ebbd1ac4..5fbbc420 100644
--- a/api/tests/Feature/Api/V1/PasswordResetTest.php
+++ b/api/tests/Feature/Api/V1/PasswordResetTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Models\User;
+use App\Notifications\ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
@@ -21,38 +22,53 @@ class PasswordResetTest extends TestCase
{
Notification::fake();
- User::factory()->create(['email' => 'jan@voorbeeld.nl']);
+ $user = User::factory()->create(['email' => 'jan@voorbeeld.nl']);
$response = $this->postJson('/api/v1/auth/forgot-password', [
'email' => 'jan@voorbeeld.nl',
+ 'app' => 'app',
]);
$response->assertOk();
- $response->assertJsonPath('message', 'Als dit emailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.');
+
+ Notification::assertSentTo($user, ResetPasswordNotification::class);
}
public function test_forgot_password_returns_same_success_for_nonexisting_email(): void
{
$response = $this->postJson('/api/v1/auth/forgot-password', [
'email' => 'onbekend@voorbeeld.nl',
+ 'app' => 'app',
]);
$response->assertOk();
- $response->assertJsonPath('message', 'Als dit emailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.');
+ }
+
+ public function test_forgot_password_requires_app_parameter(): void
+ {
+ $response = $this->postJson('/api/v1/auth/forgot-password', [
+ 'email' => 'jan@voorbeeld.nl',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('app');
+ }
+
+ public function test_forgot_password_validates_app_values(): void
+ {
+ $response = $this->postJson('/api/v1/auth/forgot-password', [
+ 'email' => 'jan@voorbeeld.nl',
+ 'app' => 'invalid',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors('app');
}
public function test_forgot_password_validates_email_required(): void
- {
- $response = $this->postJson('/api/v1/auth/forgot-password', []);
-
- $response->assertStatus(422);
- $response->assertJsonValidationErrors('email');
- }
-
- public function test_forgot_password_validates_email_format(): void
{
$response = $this->postJson('/api/v1/auth/forgot-password', [
- 'email' => 'not-an-email',
+ 'app' => 'app',
]);
$response->assertStatus(422);
@@ -64,11 +80,13 @@ class PasswordResetTest extends TestCase
for ($i = 0; $i < 5; $i++) {
$this->postJson('/api/v1/auth/forgot-password', [
'email' => 'test@voorbeeld.nl',
+ 'app' => 'portal',
]);
}
$response = $this->postJson('/api/v1/auth/forgot-password', [
'email' => 'test@voorbeeld.nl',
+ 'app' => 'portal',
]);
$response->assertStatus(429);
@@ -90,13 +108,30 @@ class PasswordResetTest extends TestCase
]);
$response->assertOk();
- $response->assertJsonPath('message', 'Wachtwoord succesvol gewijzigd.');
- // Verify password was actually changed
$user->refresh();
$this->assertTrue(Hash::check('NieuwWachtwoord1', $user->password));
}
+ public function test_reset_password_revokes_all_tokens(): void
+ {
+ $user = User::factory()->create(['email' => 'jan@voorbeeld.nl']);
+ $user->createToken('test-token');
+
+ $this->assertCount(1, $user->tokens);
+
+ $token = Password::createToken($user);
+
+ $this->postJson('/api/v1/auth/reset-password', [
+ 'token' => $token,
+ 'email' => 'jan@voorbeeld.nl',
+ 'password' => 'NieuwWachtwoord1',
+ 'password_confirmation' => 'NieuwWachtwoord1',
+ ]);
+
+ $this->assertCount(0, $user->tokens()->get());
+ }
+
public function test_reset_password_with_invalid_token_returns_422(): void
{
User::factory()->create(['email' => 'jan@voorbeeld.nl']);
@@ -127,23 +162,6 @@ class PasswordResetTest extends TestCase
$response->assertJsonValidationErrors('password');
}
- public function test_reset_password_requires_minimum_length(): void
- {
- $user = User::factory()->create(['email' => 'jan@voorbeeld.nl']);
-
- $token = Password::createToken($user);
-
- $response = $this->postJson('/api/v1/auth/reset-password', [
- 'token' => $token,
- 'email' => 'jan@voorbeeld.nl',
- 'password' => 'short',
- 'password_confirmation' => 'short',
- ]);
-
- $response->assertStatus(422);
- $response->assertJsonValidationErrors('password');
- }
-
public function test_reset_password_validates_required_fields(): void
{
$response = $this->postJson('/api/v1/auth/reset-password', []);
diff --git a/api/tests/Feature/Security/AuthenticationSecurityTest.php b/api/tests/Feature/Security/AuthenticationSecurityTest.php
index 1340a698..ec693e3d 100644
--- a/api/tests/Feature/Security/AuthenticationSecurityTest.php
+++ b/api/tests/Feature/Security/AuthenticationSecurityTest.php
@@ -100,6 +100,7 @@ final class AuthenticationSecurityTest extends TestCase
{
$response = $this->postJson('/api/v1/auth/forgot-password', [
'email' => 'nonexistent@example.com',
+ 'app' => 'app',
]);
// Must return 200 regardless — don't leak whether email exists
@@ -112,6 +113,7 @@ final class AuthenticationSecurityTest extends TestCase
$response = $this->postJson('/api/v1/auth/forgot-password', [
'email' => $user->email,
+ 'app' => 'app',
]);
$response->assertOk();
diff --git a/apps/admin/src/lib/axios.ts b/apps/admin/src/lib/axios.ts
index c29eeb79..d0560ed0 100644
--- a/apps/admin/src/lib/axios.ts
+++ b/apps/admin/src/lib/axios.ts
@@ -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'
}
}
diff --git a/apps/admin/src/pages/forgot-password.vue b/apps/admin/src/pages/forgot-password.vue
index 93550534..910d3ffa 100644
--- a/apps/admin/src/pages/forgot-password.vue
+++ b/apps/admin/src/pages/forgot-password.vue
@@ -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 {
+ 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
+ }
+}
@@ -74,38 +94,48 @@ definePage({
>
- Forgot Password? 🔒
+ Wachtwoord vergeten?
- 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.
- {}">
+
+ Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te herstellen.
+
+
+
-
-
- Send Reset Link
+ Verstuur herstelmail
-
- Back to login
+ Terug naar inloggen
diff --git a/apps/admin/src/pages/login.vue b/apps/admin/src/pages/login.vue
index 2582946d..7a6682b6 100644
--- a/apps/admin/src/pages/login.vue
+++ b/apps/admin/src/pages/login.vue
@@ -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>({
@@ -170,6 +172,16 @@ const onSubmit = () => {
+
+ Wachtwoord gewijzigd. Je kunt nu inloggen.
+
+
+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 {
+ 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
+ }
+}
+
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nieuw wachtwoord instellen
+
+
+ Kies een nieuw wachtwoord voor je account.
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wachtwoord opslaan
+
+
+
+
+
+
+ Terug naar inloggen
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/pages/verify-email-change.vue b/apps/admin/src/pages/verify-email-change.vue
new file mode 100644
index 00000000..cca7e47e
--- /dev/null
+++ b/apps/admin/src/pages/verify-email-change.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
+
+
+ E-mailadres wordt geverifieerd...
+
+
+
+
+ tabler-circle-check
+
+
+ E-mailadres gewijzigd!
+
+
+ Je e-mailadres is succesvol gewijzigd.
+ Log opnieuw in met je nieuwe e-mailadres.
+
+
+ Ga naar inloggen
+
+
+
+
+
+ tabler-circle-x
+
+
+ Verificatie mislukt
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/composables/api/useAccount.ts b/apps/app/src/composables/api/useAccount.ts
new file mode 100644
index 00000000..c8096788
--- /dev/null
+++ b/apps/app/src/composables/api/useAccount.ts
@@ -0,0 +1,61 @@
+import { useMutation } from '@tanstack/vue-query'
+import type { Ref } from 'vue'
+import { apiClient } from '@/lib/axios'
+
+interface ApiResponse {
+ 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>(
+ '/me/change-password',
+ payload,
+ )
+ return data
+ },
+ })
+}
+
+export function useChangeEmail() {
+ return useMutation({
+ mutationFn: async (payload: ChangeEmailPayload) => {
+ const { data } = await apiClient.post>(
+ '/me/change-email',
+ payload,
+ )
+ return data
+ },
+ })
+}
+
+export function useAdminChangeEmail(orgId: Ref) {
+ return useMutation({
+ mutationFn: async ({ userId, newEmail }: { userId: string; newEmail: string }) => {
+ const { data } = await apiClient.post>(
+ `/organisations/${orgId.value}/members/${userId}/change-email`,
+ { new_email: newEmail },
+ )
+ return data
+ },
+ })
+}
diff --git a/apps/app/src/layouts/components/UserProfile.vue b/apps/app/src/layouts/components/UserProfile.vue
index 9c9d001c..148bebfa 100644
--- a/apps/app/src/layouts/components/UserProfile.vue
+++ b/apps/app/src/layouts/components/UserProfile.vue
@@ -76,6 +76,17 @@ function handleLogout() {
+
+
+
+
+ Accountinstellingen
+
+
+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>({})
+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 } } }
+ 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>({})
+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 } } }
+ if (ax.response?.data?.errors) {
+ for (const [key, messages] of Object.entries(ax.response.data.errors)) {
+ emailFieldErrors.value[key] = messages[0]
+ }
+ }
+ },
+ },
+ )
+}
+
+
+
+
+
+
+ Accountinstellingen
+
+
+
+
+ E-mailadres wijzigen
+
+
+ Huidig e-mailadres: {{ authStore.user?.email }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Verificatiemail versturen
+
+
+
+
+
+ {{ emailSuccess }}
+
+
+
+
+
+
+ Wachtwoord wijzigen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wachtwoord wijzigen
+
+
+
+
+
+ {{ passwordSuccess }}
+
+
+
+
+
+
diff --git a/apps/app/src/pages/forgot-password.vue b/apps/app/src/pages/forgot-password.vue
new file mode 100644
index 00000000..8e02d472
--- /dev/null
+++ b/apps/app/src/pages/forgot-password.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wachtwoord vergeten?
+
+
+ Vul je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.
+
+
+
+
+
+ Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te herstellen.
+
+
+
+
+
+
+
+
+
+
+ Verstuur herstelmail
+
+
+
+
+
+
+ Terug naar inloggen
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue
index fd96cae0..8d7e81fd 100644
--- a/apps/app/src/pages/login.vue
+++ b/apps/app/src/pages/login.vue
@@ -24,6 +24,8 @@ definePage({
const route = useRoute()
const router = useRouter()
+const passwordResetDone = computed(() => route.query.reset === '1')
+
const form = ref({
email: '',
password: '',
@@ -139,13 +141,23 @@ function onSubmit() {
>
- Welcome to {{ themeConfig.app.title }}! 👋🏻
+ Welkom bij {{ themeConfig.app.title }}!
- Please sign-in to your account and start the adventure
+ Log in op je account om verder te gaan
+
+ Wachtwoord gewijzigd. Je kunt nu inloggen.
+
+
-
- Forgot Password?
-
+ Wachtwoord vergeten?
+
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(null)
+const newMemberEmail = ref('')
+const adminEmailErrors = ref>({})
+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 } } }
+ 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)"
/>
+
+
+
+
+
+
+ Wijzig het e-mailadres van {{ memberToChangeEmail?.full_name }}.
+ Er wordt een verificatiemail verstuurd naar het nieuwe adres.
+
+
+
+
+
+
+ Annuleren
+
+
+ Verificatiemail versturen
+
+
+
+
+
Uitnodiging ingetrokken
+
+ Verificatiemail verstuurd
+
diff --git a/apps/app/src/pages/reset-password.vue b/apps/app/src/pages/reset-password.vue
new file mode 100644
index 00000000..7096b38a
--- /dev/null
+++ b/apps/app/src/pages/reset-password.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nieuw wachtwoord instellen
+
+
+ Kies een nieuw wachtwoord voor je account.
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wachtwoord opslaan
+
+
+
+
+
+
+ Terug naar inloggen
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/pages/verify-email-change.vue b/apps/app/src/pages/verify-email-change.vue
new file mode 100644
index 00000000..3caf8a17
--- /dev/null
+++ b/apps/app/src/pages/verify-email-change.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+ {{ themeConfig.app.title }}
+
+
+
+
+
+
+
+ E-mailadres wordt geverifieerd...
+
+
+
+
+ tabler-circle-check
+
+
+ E-mailadres gewijzigd!
+
+
+ Je e-mailadres is succesvol gewijzigd.
+ Log opnieuw in met je nieuwe e-mailadres.
+
+
+ Ga naar inloggen
+
+
+
+
+
+ tabler-circle-x
+
+
+ Verificatie mislukt
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/plugins/1.router/guards.ts b/apps/app/src/plugins/1.router/guards.ts
index 9d11b131..a05f23ce 100644
--- a/apps/app/src/plugins/1.router/guards.ts
+++ b/apps/app/src/plugins/1.router/guards.ts
@@ -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
diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts
index 6cacb786..1b9ac509 100644
--- a/apps/app/typed-router.d.ts
+++ b/apps/app/typed-router.d.ts
@@ -20,6 +20,7 @@ declare module 'vue-router/auto-routes' {
export interface RouteNamedMap {
'root': RouteRecordInfo<'root', '/', Record, Record>,
'$error': RouteRecordInfo<'$error', '/:error(.*)', { error: ParamValue }, { error: ParamValue }>,
+ 'account-settings': RouteRecordInfo<'account-settings', '/account-settings', Record, Record>,
'dashboard': RouteRecordInfo<'dashboard', '/dashboard', Record, Record>,
'events': RouteRecordInfo<'events', '/events', Record, Record>,
'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue }, { id: ParamValue }>,
@@ -32,12 +33,15 @@ declare module 'vue-router/auto-routes' {
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue }, { id: ParamValue }>,
'events-id-settings-registration-fields': RouteRecordInfo<'events-id-settings-registration-fields', '/events/:id/settings/registration-fields', { id: ParamValue }, { id: ParamValue }>,
'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue }, { id: ParamValue }>,
+ 'forgot-password': RouteRecordInfo<'forgot-password', '/forgot-password', Record, Record>,
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue }, { token: ParamValue }>,
'login': RouteRecordInfo<'login', '/login', Record, Record>,
'organisation': RouteRecordInfo<'organisation', '/organisation', Record, Record>,
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record, Record>,
'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record, Record>,
'organisation-settings': RouteRecordInfo<'organisation-settings', '/organisation/settings', Record, Record>,
+ 'reset-password': RouteRecordInfo<'reset-password', '/reset-password', Record, Record>,
'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record, Record>,
+ 'verify-email-change': RouteRecordInfo<'verify-email-change', '/verify-email-change', Record, Record>,
}
}
diff --git a/apps/portal/src/lib/axios.ts b/apps/portal/src/lib/axios.ts
index ddb5b35a..bc3bb0fc 100644
--- a/apps/portal/src/lib/axios.ts
+++ b/apps/portal/src/lib/axios.ts
@@ -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'
}
diff --git a/apps/portal/src/pages/profiel.vue b/apps/portal/src/pages/profiel.vue
index 77a4d075..ed0df2bc 100644
--- a/apps/portal/src/pages/profiel.vue
+++ b/apps/portal/src/pages/profiel.vue
@@ -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(null)
+// Email change form
+const showEmailChange = ref(false)
+const emailForm = ref({
+ new_email: '',
+ password: '',
+})
+const emailError = ref(null)
+const emailFieldErrors = ref>({})
+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 } } }
+ 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"
/>
-
- Je e-mailadres kan niet worden gewijzigd.
-
+
+
+ {{ showEmailChange ? '' : 'Je kunt je e-mailadres hieronder wijzigen.' }}
+
+
+ E-mail wijzigen
+
+
+
+
+
+
+
+
+ {{ emailError }}
+
+
+
+
+
+
+ Verificatiemail versturen
+
+
+ Annuleren
+
+
+
+
+
+
+
+ {{ emailSuccess }}
+
+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; 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
+ }
+})
+
+
+
+
+
+
+
+
+ Crewli
+
+
+
+
+
+
+ E-mailadres wordt geverifieerd...
+
+
+
+
+ tabler-circle-check
+
+
+ E-mailadres gewijzigd!
+
+
+ Je e-mailadres is succesvol gewijzigd.
+ Log opnieuw in met je nieuwe e-mailadres.
+
+
+ Ga naar inloggen
+
+
+
+
+
+ tabler-circle-x
+
+
+ Verificatie mislukt
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
diff --git a/apps/portal/src/pages/wachtwoord-vergeten.vue b/apps/portal/src/pages/wachtwoord-vergeten.vue
index 2cc13757..51eeb2ed 100644
--- a/apps/portal/src/pages/wachtwoord-vergeten.vue
+++ b/apps/portal/src/pages/wachtwoord-vergeten.vue
@@ -16,7 +16,7 @@ const done = ref(false)
async function onSubmit(): Promise {
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)
diff --git a/apps/portal/src/plugins/1.router/guards.ts b/apps/portal/src/plugins/1.router/guards.ts
index 03fa00ec..084b5ca6 100644
--- a/apps/portal/src/plugins/1.router/guards.ts
+++ b/apps/portal/src/plugins/1.router/guards.ts
@@ -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 = {
diff --git a/apps/portal/typed-router.d.ts b/apps/portal/typed-router.d.ts
index 1c9f7328..0ff7002b 100644
--- a/apps/portal/typed-router.d.ts
+++ b/apps/portal/typed-router.d.ts
@@ -29,6 +29,7 @@ declare module 'vue-router/auto-routes' {
'register-success': RouteRecordInfo<'register-success', '/register/success', Record, Record>,
'volunteer-register-info': RouteRecordInfo<'volunteer-register-info', '/registreren', Record, Record>,
'portal-shifts': RouteRecordInfo<'portal-shifts', '/shifts', Record, Record>,
+ 'verify-email-change': RouteRecordInfo<'verify-email-change', '/verify-email-change', Record, Record>,
'reset-password': RouteRecordInfo<'reset-password', '/wachtwoord-resetten', Record, Record>,
'forgot-password': RouteRecordInfo<'forgot-password', '/wachtwoord-vergeten', Record, Record>,
}
diff --git a/dev-docs/API.md b/dev-docs/API.md
index c0054b4a..871048c4 100644
--- a/dev-docs/API.md
+++ b/dev-docs/API.md
@@ -9,6 +9,14 @@ Auth: Bearer token (Sanctum)
- `POST /auth/login`
- `POST /auth/logout`
- `GET /auth/me`
+- `POST /auth/forgot-password` — request password reset (public, rate-limited). Body: `{ email, app: "app"|"portal"|"admin" }`. Always returns 200 (no email enumeration).
+- `POST /auth/reset-password` — reset password with token (public). Body: `{ token, email, password, password_confirmation }`.
+
+## Account Management (authenticated)
+
+- `POST /me/change-password` — change own password. Body: `{ current_password, password, password_confirmation }`. Revokes other sessions.
+- `POST /me/change-email` — request email change (sends verification to new address). Body: `{ new_email, password, app: "app"|"portal"|"admin" }`.
+- `POST /verify-email-change` — verify email change token (public). Body: `{ token }`. Revokes all sessions.
## Organisations
@@ -18,6 +26,7 @@ Auth: Bearer token (Sanctum)
- `PUT /organisations/{org}` — update
- `GET /organisations/{org}/members` — members
- `POST /organisations/{org}/invite` — invite user
+- `POST /organisations/{org}/members/{user}/change-email` — admin initiates email change for a member (org_admin only). Body: `{ new_email }`.
## Events
diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md
index 27c01a46..7a3c8b7f 100644
--- a/dev-docs/SCHEMA.md
+++ b/dev-docs/SCHEMA.md
@@ -243,6 +243,26 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
---
+### `email_change_requests`
+
+| Column | Type | Notes |
+| ---------------------- | ------------ | --------------------------------- |
+| `id` | ULID PK | |
+| `user_id` | ULID FK | → users (cascade delete) |
+| `current_email` | string | Email at time of request |
+| `new_email` | string | Requested new email |
+| `token` | string | SHA-256 hashed verification token |
+| `requested_by_user_id` | ULID FK null | → users (null on delete) — self or admin |
+| `status` | string | pending / verified / expired / cancelled |
+| `expires_at` | timestamp | 24h from request |
+| `verified_at` | timestamp? | When verification completed |
+| `created_at` | timestamp | |
+| `updated_at` | timestamp | |
+
+**Indexes:** `(user_id, status)`, `(token)`
+
+---
+
## 3.5.2 Locations
> Locations are event-scoped and reusable across sections within an event.