From 6fbc5a0c2ae7cc47ed37607a6f93aba0c5fc2f75 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 15 Jul 2026 15:53:48 +0100 Subject: [PATCH] Add self-service login email change with secure OAuth linking. Let users confirm a new login email from their profile while preserving activities and certificates on the same account, and match Google/OAuth logins by provider ID so email changes do not create duplicate accounts. Co-authored-by: Cursor --- app/Http/Controllers/Auth/LoginController.php | 35 +--- .../Controllers/UserEmailChangeController.php | 69 ++++++++ app/Mail/PendingEmailChangeConfirmation.php | 40 +++++ app/Mail/PendingEmailChangeNotification.php | 40 +++++ app/Services/SocialUserLoginService.php | 56 ++++++ app/Services/UserEmailChangeService.php | 163 ++++++++++++++++++ ...45027_add_pending_email_to_users_table.php | 24 +++ ..._145309_add_provider_id_to_users_table.php | 24 +++ ...ending-email-change-confirmation.blade.php | 16 ++ ...ending-email-change-notification.blade.php | 12 ++ resources/views/profile.blade.php | 61 ++++++- routes/web.php | 10 ++ tests/Feature/UserEmailChangeTest.php | 125 ++++++++++++++ tests/Unit/SocialUserLoginServiceTest.php | 114 ++++++++++++ tests/Unit/UserEmailChangeServiceTest.php | 114 ++++++++++++ 15 files changed, 869 insertions(+), 34 deletions(-) create mode 100644 app/Http/Controllers/UserEmailChangeController.php create mode 100644 app/Mail/PendingEmailChangeConfirmation.php create mode 100644 app/Mail/PendingEmailChangeNotification.php create mode 100644 app/Services/SocialUserLoginService.php create mode 100644 app/Services/UserEmailChangeService.php create mode 100644 database/migrations/2026_07_15_145027_add_pending_email_to_users_table.php create mode 100644 database/migrations/2026_07_15_145309_add_provider_id_to_users_table.php create mode 100644 resources/views/emails/en/pending-email-change-confirmation.blade.php create mode 100644 resources/views/emails/en/pending-email-change-notification.blade.php create mode 100644 tests/Feature/UserEmailChangeTest.php create mode 100644 tests/Unit/SocialUserLoginServiceTest.php create mode 100644 tests/Unit/UserEmailChangeServiceTest.php diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index eb4ca7672..f0f981ebb 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -3,14 +3,13 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Services\SocialUserLoginService; use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Str; use Laravel\Socialite\Facades\Socialite; class LoginController extends Controller @@ -40,8 +39,9 @@ class LoginController extends Controller * * @return void */ - public function __construct() - { + public function __construct( + private readonly SocialUserLoginService $socialUserLoginService, + ) { $this->middleware('guest')->except('logout'); } @@ -83,10 +83,7 @@ public function handleProviderCallback($provider): RedirectResponse */ public function loginUser($provider, $socialUser) { - $user = \App\User::where(['email' => $socialUser->getEmail()])->first(); - if (is_null($socialUser->getEmail())) { - // if ($socialUser->getEmail() == 'alainvd@gmail.com'){ Log::info('Null email detected'); Log::info(print_r($socialUser, true)); $admin = config('codeweek.administrator'); @@ -94,30 +91,8 @@ public function loginUser($provider, $socialUser) abort(500); } - if ($user == null) { - //Create user - $user = \App\User::create( - [ - 'email' => $socialUser->getEmail(), - //'avatar' => $socialUser->getAvatar(), - 'password' => bcrypt(Str::random()), - 'firstname' => ($socialUser->getName()) ? $socialUser->getName() : $socialUser->getNickName(), - 'lastname' => '', - 'username' => ($socialUser->getNickName()) ? $socialUser->getNickName() : '', - 'provider' => $provider, - 'magic_key' => random_int(1000000, 2000000) * random_int(1000, 2000), - 'email_verified_at' => Carbon::now() - ]); - - } else { - //update user - $user->provider = $provider; - $user->updated_at = Carbon::now(); - $user->email_verified_at = Carbon::now(); - $user->save(); - } + $user = $this->socialUserLoginService->resolveOrCreateUser($provider, $socialUser); - //login with user Auth::login($user, true); } } diff --git a/app/Http/Controllers/UserEmailChangeController.php b/app/Http/Controllers/UserEmailChangeController.php new file mode 100644 index 000000000..5b34f1b83 --- /dev/null +++ b/app/Http/Controllers/UserEmailChangeController.php @@ -0,0 +1,69 @@ +user(); + + $validated = $request->validate([ + 'new_email' => 'required|email|max:255', + 'current_password' => $this->emailChangeService->requiresPassword($user) + ? 'required|string|max:72' + : 'nullable|string|max:72', + ]); + + $this->emailChangeService->requestChange( + $user, + $validated['new_email'], + $validated['current_password'] ?? null, + ); + + return back()->with( + 'email_change_status', + 'We sent a confirmation link to '.$user->fresh()->pending_email.'. Click it to finish updating your login email.', + ); + } + + public function confirm(Request $request, User $user, string $token): RedirectResponse + { + try { + $updatedUser = $this->emailChangeService->confirmChange((int) $user->id, $token); + } catch (ValidationException $exception) { + return redirect() + ->route('login') + ->withErrors($exception->errors()); + } + + if (Auth::check() && Auth::id() === $updatedUser->id) { + return redirect() + ->route('profile') + ->with('flash', 'Your login email has been updated to '.$updatedUser->email.'.'); + } + + return redirect() + ->route('login') + ->with('flash', 'Your login email has been updated. Please sign in with '.$updatedUser->email.'.'); + } + + public function cancel(Request $request): RedirectResponse + { + $this->emailChangeService->cancelPending($request->user()); + + return back()->with('email_change_status', 'Your pending email change has been cancelled.'); + } +} diff --git a/app/Mail/PendingEmailChangeConfirmation.php b/app/Mail/PendingEmailChangeConfirmation.php new file mode 100644 index 000000000..3b8afe111 --- /dev/null +++ b/app/Mail/PendingEmailChangeConfirmation.php @@ -0,0 +1,40 @@ +getId(); + $oauthEmail = SupportEmailAddress::normalize((string) $socialUser->getEmail()); + + $user = User::query() + ->where('provider', $provider) + ->where('provider_id', $providerId) + ->first(); + + if ($user === null && $oauthEmail !== null) { + $user = User::query() + ->whereRaw('LOWER(email) = ?', [$oauthEmail]) + ->first(); + } + + if ($user === null) { + return User::create([ + 'email' => $oauthEmail, + 'password' => bcrypt(Str::random()), + 'firstname' => $socialUser->getName() ?: $socialUser->getNickname(), + 'lastname' => '', + 'username' => $socialUser->getNickname() ?: '', + 'provider' => $provider, + 'provider_id' => $providerId, + 'magic_key' => random_int(1000000, 2000000) * random_int(1000, 2000), + 'email_verified_at' => Carbon::now(), + ]); + } + + $user->provider = $provider; + $user->provider_id = $providerId; + $user->email_verified_at = Carbon::now(); + $user->save(); + + return $user; + } +} diff --git a/app/Services/UserEmailChangeService.php b/app/Services/UserEmailChangeService.php new file mode 100644 index 000000000..5fe1f9041 --- /dev/null +++ b/app/Services/UserEmailChangeService.php @@ -0,0 +1,163 @@ +provider); + } + + /** + * @return array{status: string} + */ + public function requestChange(User $user, string $newEmail, ?string $password): array + { + $newEmail = SupportEmailAddress::normalize($newEmail); + if ($newEmail === null) { + throw ValidationException::withMessages([ + 'new_email' => 'Please enter a valid email address.', + ]); + } + + $currentEmail = SupportEmailAddress::normalize((string) ($user->email ?? '')); + if ($currentEmail !== null && $newEmail === $currentEmail) { + throw ValidationException::withMessages([ + 'new_email' => 'That is already your login email address.', + ]); + } + + if ($this->emailInUseByAnotherUser($newEmail, (int) $user->id)) { + throw ValidationException::withMessages([ + 'new_email' => 'That email address is already in use on another CodeWeek account.', + ]); + } + + if ($this->requiresPassword($user)) { + if ($password === null || $password === '' || ! Hash::check($password, (string) $user->password)) { + throw ValidationException::withMessages([ + 'current_password' => 'Your current password is incorrect.', + ]); + } + } + + $token = Str::random(64); + + $user->pending_email = $newEmail; + $user->pending_email_token = hash('sha256', $token); + $user->pending_email_requested_at = now(); + $user->save(); + + $confirmUrl = URL::temporarySignedRoute( + 'user.email-change.confirm', + now()->addHours(48), + ['user' => $user->id, 'token' => $token], + ); + + Mail::to($newEmail)->queue(new PendingEmailChangeConfirmation($user, $confirmUrl)); + + if ($currentEmail !== null) { + Mail::to($currentEmail)->queue(new PendingEmailChangeNotification($user, $newEmail)); + } + + return ['status' => 'confirmation_sent']; + } + + public function cancelPending(User $user): void + { + $user->pending_email = null; + $user->pending_email_token = null; + $user->pending_email_requested_at = null; + $user->save(); + } + + /** + * @throws ValidationException + */ + public function confirmChange(int $userId, string $token): User + { + $user = User::findOrFail($userId); + + if ($user->pending_email === null || $user->pending_email_token === null) { + throw ValidationException::withMessages([ + 'token' => 'This email change request is no longer valid.', + ]); + } + + if (! hash_equals($user->pending_email_token, hash('sha256', $token))) { + throw ValidationException::withMessages([ + 'token' => 'This confirmation link is invalid.', + ]); + } + + $newEmail = SupportEmailAddress::normalize((string) $user->pending_email); + if ($newEmail === null) { + throw ValidationException::withMessages([ + 'token' => 'This email change request is no longer valid.', + ]); + } + + if ($this->emailInUseByAnotherUser($newEmail, (int) $user->id)) { + $this->cancelPending($user); + + throw ValidationException::withMessages([ + 'new_email' => 'That email address is already in use on another CodeWeek account.', + ]); + } + + return DB::transaction(function () use ($user, $newEmail) { + $oldEmail = SupportEmailAddress::normalize((string) ($user->email ?? '')); + + $this->applyEmailChange($user, $oldEmail, $newEmail); + + return $user->fresh(); + }); + } + + private function applyEmailChange(User $user, ?string $oldEmail, string $newEmail): void + { + $shouldUpdateDisplay = $oldEmail !== null + && SupportEmailAddress::normalize((string) ($user->email_display ?? '')) === $oldEmail; + + $user->email = $newEmail; + if ($shouldUpdateDisplay) { + $user->email_display = $newEmail; + } + $user->email_verified_at = now(); + $user->pending_email = null; + $user->pending_email_token = null; + $user->pending_email_requested_at = null; + $user->save(); + + if ($oldEmail !== null) { + Event::query() + ->where('creator_id', $user->id) + ->whereRaw('LOWER(user_email) = ?', [$oldEmail]) + ->update(['user_email' => $newEmail]); + } + } + + private function emailInUseByAnotherUser(string $email, int $exceptUserId): bool + { + return User::withTrashed() + ->where('id', '<>', $exceptUserId) + ->where(function ($query) use ($email) { + $query->whereRaw('LOWER(email) = ?', [$email]) + ->orWhereRaw('LOWER(email_display) = ?', [$email]); + }) + ->exists(); + } +} diff --git a/database/migrations/2026_07_15_145027_add_pending_email_to_users_table.php b/database/migrations/2026_07_15_145027_add_pending_email_to_users_table.php new file mode 100644 index 000000000..250372f69 --- /dev/null +++ b/database/migrations/2026_07_15_145027_add_pending_email_to_users_table.php @@ -0,0 +1,24 @@ +string('pending_email')->nullable()->after('email_display'); + $table->string('pending_email_token', 64)->nullable()->after('pending_email'); + $table->timestamp('pending_email_requested_at')->nullable()->after('pending_email_token'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['pending_email', 'pending_email_token', 'pending_email_requested_at']); + }); + } +}; diff --git a/database/migrations/2026_07_15_145309_add_provider_id_to_users_table.php b/database/migrations/2026_07_15_145309_add_provider_id_to_users_table.php new file mode 100644 index 000000000..d880c2303 --- /dev/null +++ b/database/migrations/2026_07_15_145309_add_provider_id_to_users_table.php @@ -0,0 +1,24 @@ +string('provider_id')->nullable()->after('provider'); + $table->index(['provider', 'provider_id']); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex(['provider', 'provider_id']); + $table->dropColumn('provider_id'); + }); + } +}; diff --git a/resources/views/emails/en/pending-email-change-confirmation.blade.php b/resources/views/emails/en/pending-email-change-confirmation.blade.php new file mode 100644 index 000000000..df05a77ff --- /dev/null +++ b/resources/views/emails/en/pending-email-change-confirmation.blade.php @@ -0,0 +1,16 @@ +@component('mail::message') +Hello {{ $user->firstname ?: 'there' }}, + +You requested to change the login email on your EU Code Week account to **{{ $user->pending_email }}**. + +Click the button below to confirm this change. This link expires in 48 hours. + +@component('mail::button', ['url' => $confirmUrl]) +Confirm new login email +@endcomponent + +If you did not request this change, you can ignore this email. + +Thanks,
+The EU Code Week team +@endcomponent diff --git a/resources/views/emails/en/pending-email-change-notification.blade.php b/resources/views/emails/en/pending-email-change-notification.blade.php new file mode 100644 index 000000000..e048bfdbd --- /dev/null +++ b/resources/views/emails/en/pending-email-change-notification.blade.php @@ -0,0 +1,12 @@ +@component('mail::message') +Hello {{ $user->firstname ?: 'there' }}, + +Someone requested to change the login email on your EU Code Week account from **{{ $user->email }}** to **{{ $newEmail }}**. + +If you made this request, no action is needed — the change will only take effect after the new address is confirmed. + +If you did **not** request this, please contact us immediately at info@codeweek.eu. + +Thanks,
+The EU Code Week team +@endcomponent diff --git a/resources/views/profile.blade.php b/resources/views/profile.blade.php index 666f9f6ad..afb92675c 100755 --- a/resources/views/profile.blade.php +++ b/resources/views/profile.blade.php @@ -86,15 +86,68 @@ class="border-2 border-solid border-dark-blue-200 w-full rounded-full h-12 px-4
- + +

+ Used to sign in and receive certificates and activity notifications. +

-
- @component('components.validation-errors', ['field'=>'title'])@endcomponent -
+ @if (session('email_change_status')) +
+ {{ session('email_change_status') }} +
+ @endif + @if ($profileUser->pending_email) +
+

+ Waiting for confirmation at {{ $profileUser->pending_email }}. + Check that inbox for a confirmation link (valid for 48 hours). +

+
+ @csrf + +
+
+ @else +
+ + Change login email + +
+ @csrf +
+ + + @component('components.validation-errors', ['field'=>'new_email'])@endcomponent +
+ @if (empty($profileUser->provider)) +
+ + + @component('components.validation-errors', ['field'=>'current_password'])@endcomponent +
+ @else +

+ We will email your current address to let you know about this change. + You can keep signing in with {{ ucfirst($profileUser->provider) }} after the update. +

+ @endif + +
+
+ @endif
diff --git a/routes/web.php b/routes/web.php index cd25bd73f..13e4d9f51 100644 --- a/routes/web.php +++ b/routes/web.php @@ -66,6 +66,7 @@ use App\Http\Controllers\TrainingController; use App\Http\Controllers\UnsubscribeController; use App\Http\Controllers\UserController; +use App\Http\Controllers\UserEmailChangeController; use App\Http\Controllers\VolunteerController; use App\Http\Controllers\ConsentController; use App\Http\Controllers\HomeSlideLocaleOverridesController; @@ -490,6 +491,15 @@ Route::patch('user', [UserController::class, 'update']) ->name('user.update') ->middleware('auth'); +Route::post('user/email-change', [UserEmailChangeController::class, 'request']) + ->name('user.email-change.request') + ->middleware(['auth', 'throttle:6,1']); +Route::post('user/email-change/cancel', [UserEmailChangeController::class, 'cancel']) + ->name('user.email-change.cancel') + ->middleware('auth'); +Route::get('email/change/confirm/{user}/{token}', [UserEmailChangeController::class, 'confirm']) + ->name('user.email-change.confirm') + ->middleware('signed'); Route::get('view/{event}/{slug}', [EventController::class, 'show'])->name('view_event'); Route::get('/my/reportable', [ReportController::class, 'list']) diff --git a/tests/Feature/UserEmailChangeTest.php b/tests/Feature/UserEmailChangeTest.php new file mode 100644 index 000000000..bacdb36ca --- /dev/null +++ b/tests/Feature/UserEmailChangeTest.php @@ -0,0 +1,125 @@ +create([ + 'email' => 'old@example.com', + 'provider' => null, + 'password' => bcrypt('correct-password'), + 'email_verified_at' => now(), + ]); + + $this->signIn($user); + + $response = $this->post(route('user.email-change.request'), [ + 'new_email' => 'new@example.com', + 'current_password' => 'correct-password', + ]); + + $response->assertRedirect(); + $response->assertSessionHas('email_change_status'); + + $user->refresh(); + $this->assertSame('new@example.com', $user->pending_email); + + Mail::assertQueued(PendingEmailChangeConfirmation::class, function ($mail) { + return $mail->hasTo('new@example.com'); + }); + + Mail::assertQueued(PendingEmailChangeNotification::class, function ($mail) { + return $mail->hasTo('old@example.com'); + }); + } + + public function test_user_can_confirm_email_change_via_signed_link(): void + { + Mail::fake(); + + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'email_display' => 'old@example.com', + 'provider' => null, + 'password' => bcrypt('correct-password'), + 'email_verified_at' => now(), + ]); + + $token = 'signed-link-token'; + $user->pending_email = 'new@example.com'; + $user->pending_email_token = hash('sha256', $token); + $user->pending_email_requested_at = now(); + $user->save(); + + $url = URL::temporarySignedRoute( + 'user.email-change.confirm', + now()->addHour(), + ['user' => $user->id, 'token' => $token], + ); + + $response = $this->get($url); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('flash'); + + $user->refresh(); + $this->assertSame('new@example.com', $user->email); + $this->assertNull($user->pending_email); + } + + public function test_user_can_cancel_pending_email_change(): void + { + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'pending_email' => 'new@example.com', + 'pending_email_token' => hash('sha256', 'token'), + 'pending_email_requested_at' => now(), + 'email_verified_at' => now(), + ]); + + $this->signIn($user); + + $response = $this->post(route('user.email-change.cancel')); + + $response->assertRedirect(); + $response->assertSessionHas('email_change_status'); + + $user->refresh(); + $this->assertNull($user->pending_email); + $this->assertNull($user->pending_email_token); + } + + public function test_profile_update_still_cannot_change_login_email_directly(): void + { + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'email_verified_at' => now(), + ]); + + $this->signIn($user); + + $this->patch(route('user.update'), [ + 'firstname' => $user->firstname, + 'lastname' => $user->lastname, + 'privacy' => 1, + 'receive_emails' => 1, + 'email' => 'hacked@example.com', + ]); + + $this->assertSame('old@example.com', $user->fresh()->email); + } +} diff --git a/tests/Unit/SocialUserLoginServiceTest.php b/tests/Unit/SocialUserLoginServiceTest.php new file mode 100644 index 000000000..5010a1ca6 --- /dev/null +++ b/tests/Unit/SocialUserLoginServiceTest.php @@ -0,0 +1,114 @@ +create([ + 'email' => 'new@school.cz', + 'provider' => 'google', + 'provider_id' => 'google-account-123', + 'email_verified_at' => now(), + ]); + + $socialUser = $this->mockSocialUser( + email: 'old@school.cz', + id: 'google-account-123', + ); + + $user = app(SocialUserLoginService::class)->resolveOrCreateUser('google', $socialUser); + + $this->assertSame($existing->id, $user->id); + $this->assertSame('new@school.cz', $user->email); + $this->assertSame(1, User::count()); + } + + public function test_oauth_login_backfills_provider_id_for_legacy_email_match(): void + { + $existing = User::factory()->create([ + 'email' => 'teacher@school.cz', + 'provider' => 'google', + 'provider_id' => null, + 'email_verified_at' => now(), + ]); + + $socialUser = $this->mockSocialUser( + email: 'teacher@school.cz', + id: 'google-account-456', + ); + + $user = app(SocialUserLoginService::class)->resolveOrCreateUser('google', $socialUser); + + $this->assertSame($existing->id, $user->id); + $this->assertSame('google-account-456', $user->provider_id); + $this->assertSame(1, User::count()); + } + + public function test_oauth_login_creates_user_when_no_match_exists(): void + { + $socialUser = $this->mockSocialUser( + email: 'brand-new@school.cz', + id: 'google-account-789', + name: 'Brand New', + ); + + $user = app(SocialUserLoginService::class)->resolveOrCreateUser('google', $socialUser); + + $this->assertSame('brand-new@school.cz', $user->email); + $this->assertSame('google', $user->provider); + $this->assertSame('google-account-789', $user->provider_id); + $this->assertSame(1, User::count()); + } + + public function test_email_change_then_google_login_does_not_create_duplicate(): void + { + $user = User::factory()->create([ + 'email' => 'old@school.cz', + 'provider' => 'google', + 'provider_id' => 'google-account-999', + 'email_verified_at' => now(), + ]); + + $user->email = 'gabriela.svobodova@soslp.cz'; + $user->save(); + + $socialUser = $this->mockSocialUser( + email: 'old@school.cz', + id: 'google-account-999', + ); + + $loggedIn = app(SocialUserLoginService::class)->resolveOrCreateUser('google', $socialUser); + + $this->assertSame($user->id, $loggedIn->id); + $this->assertSame('gabriela.svobodova@soslp.cz', $loggedIn->email); + $this->assertSame(1, User::count()); + } + + private function mockSocialUser(string $email, string $id, string $name = 'Test User'): SocialiteUser + { + $socialUser = Mockery::mock(SocialiteUser::class); + $socialUser->shouldReceive('getEmail')->andReturn($email); + $socialUser->shouldReceive('getId')->andReturn($id); + $socialUser->shouldReceive('getName')->andReturn($name); + $socialUser->shouldReceive('getNickname')->andReturn('test-user'); + + return $socialUser; + } +} diff --git a/tests/Unit/UserEmailChangeServiceTest.php b/tests/Unit/UserEmailChangeServiceTest.php new file mode 100644 index 000000000..97b289177 --- /dev/null +++ b/tests/Unit/UserEmailChangeServiceTest.php @@ -0,0 +1,114 @@ +create([ + 'email' => 'old@example.com', + 'email_display' => 'old@example.com', + 'provider' => null, + 'password' => bcrypt('correct-password'), + 'email_verified_at' => now(), + ]); + + app(UserEmailChangeService::class)->requestChange( + $user, + 'new@example.com', + 'correct-password', + ); + + $user->refresh(); + $this->assertSame('new@example.com', $user->pending_email); + $this->assertNotNull($user->pending_email_token); + $this->assertNotNull($user->pending_email_requested_at); + $this->assertSame('old@example.com', $user->email); + } + + public function test_request_change_rejects_incorrect_password(): void + { + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'provider' => null, + 'password' => bcrypt('correct-password'), + 'email_verified_at' => now(), + ]); + + $this->expectException(\Illuminate\Validation\ValidationException::class); + + app(UserEmailChangeService::class)->requestChange( + $user, + 'new@example.com', + 'wrong-password', + ); + } + + public function test_request_change_rejects_email_already_in_use(): void + { + User::factory()->create(['email' => 'taken@example.com']); + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'provider' => null, + 'password' => bcrypt('correct-password'), + 'email_verified_at' => now(), + ]); + + $this->expectException(\Illuminate\Validation\ValidationException::class); + + app(UserEmailChangeService::class)->requestChange( + $user, + 'taken@example.com', + 'correct-password', + ); + } + + public function test_confirm_change_updates_login_email_and_keeps_linked_records(): void + { + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'email_display' => 'old@example.com', + 'provider' => null, + 'password' => bcrypt('correct-password'), + 'email_verified_at' => now(), + ]); + + $event = Event::factory()->create([ + 'creator_id' => $user->id, + 'user_email' => 'old@example.com', + ]); + + $excellence = Excellence::factory()->create([ + 'user_id' => $user->id, + 'edition' => 2025, + 'type' => 'Excellence', + ]); + + $token = 'test-confirmation-token'; + $user->pending_email = 'new@example.com'; + $user->pending_email_token = hash('sha256', $token); + $user->pending_email_requested_at = now(); + $user->save(); + + $updated = app(UserEmailChangeService::class)->confirmChange((int) $user->id, $token); + + $this->assertSame('new@example.com', $updated->email); + $this->assertSame('new@example.com', $updated->email_display); + $this->assertNull($updated->pending_email); + $this->assertNotNull($updated->email_verified_at); + $this->assertSame($user->id, $event->fresh()->creator_id); + $this->assertSame('new@example.com', $event->fresh()->user_email); + $this->assertSame($user->id, $excellence->fresh()->user_id); + } +}