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 + } +} 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 @@ + + + + + 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 @@ + + + + + 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 + +
+ + + + + + + + {{ 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 + } +}) + + + 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.