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,
- 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.
+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
- Forgot Password? 🔒
+ Wachtwoord vergeten?
+ Kies een nieuw wachtwoord voor je account. +
+E-mailadres wordt geverifieerd...
+ + + ++ Je e-mailadres is succesvol gewijzigd. + Log opnieuw in met je nieuwe e-mailadres. +
++ {{ errorMessage }} +
+ ++ Huidig e-mailadres: {{ authStore.user?.email }} +
+ ++ Vul je e-mailadres in en we sturen je een link om je wachtwoord te herstellen. +
+- Please sign-in to your account and start the adventure + Log in op je account om verder te gaan
+ Wijzig het e-mailadres van {{ memberToChangeEmail?.full_name }}. + Er wordt een verificatiemail verstuurd naar het nieuwe adres. +
++ Kies een nieuw wachtwoord voor je account. +
+E-mailadres wordt geverifieerd...
+ + + ++ Je e-mailadres is succesvol gewijzigd. + Log opnieuw in met je nieuwe e-mailadres. +
++ {{ errorMessage }} +
+ +- Je e-mailadres kan niet worden gewijzigd. -
++ {{ showEmailChange ? '' : 'Je kunt je e-mailadres hieronder wijzigen.' }} +
+E-mailadres wordt geverifieerd...
+ + + ++ Je e-mailadres is succesvol gewijzigd. + Log opnieuw in met je nieuwe e-mailadres. +
++ {{ errorMessage }} +
+ +