From 52d8372dc2bd1826c0d56615d4aeaa7e7eec3370 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 15 Jul 2026 16:10:39 +0100 Subject: [PATCH] edit to online course page --- .../Controllers/UserEmailChangeController.php | 11 + app/Services/UserEmailChangeService.php | 52 +- ...ending-email-change-notification.blade.php | 4 +- resources/views/profile.blade.php | 509 +++++++++--------- routes/web.php | 7 +- tests/Feature/UserEmailChangeTest.php | 35 ++ 6 files changed, 363 insertions(+), 255 deletions(-) diff --git a/app/Http/Controllers/UserEmailChangeController.php b/app/Http/Controllers/UserEmailChangeController.php index 5b34f1b83..89976ccc0 100644 --- a/app/Http/Controllers/UserEmailChangeController.php +++ b/app/Http/Controllers/UserEmailChangeController.php @@ -66,4 +66,15 @@ public function cancel(Request $request): RedirectResponse return back()->with('email_change_status', 'Your pending email change has been cancelled.'); } + + public function resend(Request $request): RedirectResponse + { + $user = $request->user(); + $this->emailChangeService->resendConfirmation($user); + + return back()->with( + 'email_change_status', + 'We sent another confirmation link to '.$user->fresh()->pending_email.'. Check that inbox (and spam folder).', + ); + } } diff --git a/app/Services/UserEmailChangeService.php b/app/Services/UserEmailChangeService.php index 5fe1f9041..04fd0ff3a 100644 --- a/app/Services/UserEmailChangeService.php +++ b/app/Services/UserEmailChangeService.php @@ -54,13 +54,52 @@ public function requestChange(User $user, string $newEmail, ?string $password): } } - $token = Str::random(64); - $user->pending_email = $newEmail; + $token = $this->issuePendingToken($user); + $this->dispatchConfirmationEmail($user, $newEmail, $token); + $this->sendChangeNotification($user, $newEmail, $currentEmail); + + return ['status' => 'confirmation_sent']; + } + + /** + * @return array{status: string} + */ + public function resendConfirmation(User $user): array + { + $pendingEmail = SupportEmailAddress::normalize((string) ($user->pending_email ?? '')); + if ($pendingEmail === null || $user->pending_email_token === null) { + throw ValidationException::withMessages([ + 'pending_email' => 'There is no pending email change to confirm.', + ]); + } + + if ($this->emailInUseByAnotherUser($pendingEmail, (int) $user->id)) { + $this->cancelPending($user); + + throw ValidationException::withMessages([ + 'new_email' => 'That email address is already in use on another CodeWeek account.', + ]); + } + + $token = $this->issuePendingToken($user); + $this->dispatchConfirmationEmail($user->fresh(), $pendingEmail, $token); + + return ['status' => 'confirmation_resent']; + } + + private function issuePendingToken(User $user): string + { + $token = Str::random(64); $user->pending_email_token = hash('sha256', $token); $user->pending_email_requested_at = now(); $user->save(); + return $token; + } + + private function dispatchConfirmationEmail(User $user, string $newEmail, string $token): void + { $confirmUrl = URL::temporarySignedRoute( 'user.email-change.confirm', now()->addHours(48), @@ -68,12 +107,15 @@ public function requestChange(User $user, string $newEmail, ?string $password): ); Mail::to($newEmail)->queue(new PendingEmailChangeConfirmation($user, $confirmUrl)); + } - if ($currentEmail !== null) { - Mail::to($currentEmail)->queue(new PendingEmailChangeNotification($user, $newEmail)); + private function sendChangeNotification(User $user, string $newEmail, ?string $currentEmail): void + { + if ($currentEmail === null) { + return; } - return ['status' => 'confirmation_sent']; + Mail::to($currentEmail)->queue(new PendingEmailChangeNotification($user, $newEmail)); } public function cancelPending(User $user): void diff --git a/resources/views/emails/en/pending-email-change-notification.blade.php b/resources/views/emails/en/pending-email-change-notification.blade.php index e048bfdbd..047fad2b3 100644 --- a/resources/views/emails/en/pending-email-change-notification.blade.php +++ b/resources/views/emails/en/pending-email-change-notification.blade.php @@ -3,7 +3,9 @@ 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. +**This is only a security notice.** The confirmation link was sent separately to **{{ $newEmail }}**. Open that inbox and click **Confirm new login email** to finish the change. + +If you made this request, you can ignore this email and confirm from **{{ $newEmail }}** instead. If you did **not** request this, please contact us immediately at info@codeweek.eu. diff --git a/resources/views/profile.blade.php b/resources/views/profile.blade.php index d4b54dcbd..8101d5d0c 100755 --- a/resources/views/profile.blade.php +++ b/resources/views/profile.blade.php @@ -37,291 +37,304 @@ class="w-full md:w-fit flex justify-center items-center gap-2 text-white border- + +@endsection -
-
-

- Your details -

+@section('non-vue-content') +
+
+

+ Your details +

-
-
- - -

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

+
+
+ + +

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

+
+ @if (session('email_change_status')) +
+ {{ session('email_change_status') }}
- @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). -

+ @endif + @if ($profileUser->pending_email) +
+

+ Almost done. We sent a confirmation link to + {{ $profileUser->pending_email }}. + Open that inbox and click Confirm new login email (check spam too). + The link is valid for 48 hours. +

+

+ A separate notice was sent to {{ $profileUser->email }} — that email does not contain the confirmation link. +

+
+
+ {{ csrf_field() }} + +
- @csrf -
- @else -
- - Change login email - -
- @csrf +
+ @else +
+

Change login email

+ + {{ csrf_field() }} +
+ + + @component('components.validation-errors', ['field'=>'new_email'])@endcomponent +
+ @if (empty($profileUser->provider))
- - Current password * + - @component('components.validation-errors', ['field'=>'new_email'])@endcomponent + required autocomplete="current-password"> + @component('components.validation-errors', ['field'=>'current_password'])@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 -
+ @else +

+ We will send the confirmation link to your new address, and a security notice to {{ $profileUser->email }}. + You can keep signing in with {{ ucfirst($profileUser->provider) }} after the update. +

+ @endif + + +
+ @endif +
-
- @if ($errors->any()) -
-
    - @foreach ($errors->all() as $error) -
  • {{ $error }}
  • - @endforeach -
-
- @endif + + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif - {{ method_field('PATCH') }} - {{ csrf_field() }} -
-
- - -
-
- @component('components.validation-errors', ['field'=>'firstname'])@endcomponent -
+ {{ method_field('PATCH') }} + {{ csrf_field() }} +
+
+ +
+
+ @component('components.validation-errors', ['field'=>'firstname'])@endcomponent +
+
-
-
- - -
-
- @component('components.validation-errors', ['field'=>'lastname'])@endcomponent -
+
+
+ + +
+
+ @component('components.validation-errors', ['field'=>'lastname'])@endcomponent
+
-
-
- - -
-
- @component('components.validation-errors', ['field'=>'email_display'])@endcomponent -
+
+
+ + +
+
+ @component('components.validation-errors', ['field'=>'email_display'])@endcomponent
+
- @role('ambassador|super admin|leading teacher') -
-
- - -
-
- @component('components.validation-errors', ['field'=>'tag'])@endcomponent -
+ @role('ambassador|super admin|leading teacher') +
+
+ +
+
+ @component('components.validation-errors', ['field'=>'tag'])@endcomponent +
+
-
-
- - -
-
- @component('components.validation-errors', ['field'=>'twitter'])@endcomponent -
+
+
+ +
- @endrole +
+ @component('components.validation-errors', ['field'=>'twitter'])@endcomponent +
+
+ @endrole - @role('ambassador|super admin') -
-
- - -
-
- @component('components.validation-errors', ['field'=>'website'])@endcomponent -
+ @role('ambassador|super admin') +
+
+ + +
+
+ @component('components.validation-errors', ['field'=>'website'])@endcomponent
- @endrole +
+ @endrole -
-
- - -
-
- @component('components.validation-errors', ['field'=>'country'])@endcomponent -
+
+
+ +
+
+ @component('components.validation-errors', ['field'=>'country'])@endcomponent +
+
-
-
- - -
-
- @component('components.validation-errors', ['field'=>'city_id'])@endcomponent -
+
+
+ + +
+
+ @component('components.validation-errors', ['field'=>'city_id'])@endcomponent
+
-
-
- - -
-
- @component('components.validation-errors', ['field'=>'biography'])@endcomponent -
+
+
+ + +
+
+ @component('components.validation-errors', ['field'=>'biography'])@endcomponent
+
-
-
- -
-
- receive_emails === 1 ? 'checked' : ''}} - class="w-8 h-8 text-slate-600 border-2 border-dark-blue-200" - > - -
+
+
+ +
+
+ receive_emails === 1 ? 'checked' : ''}} + class="w-8 h-8 text-slate-600 border-2 border-dark-blue-200" + > + +
-
- receive_emails === 0 ? 'checked' : ''}} - class="w-8 h-8 text-slate-600 border-2 border-dark-blue-200" - > - -
+
+ receive_emails === 0 ? 'checked' : ''}} + class="w-8 h-8 text-slate-600 border-2 border-dark-blue-200" + > +
-
-
- privacy ? 'checked' : '' }} - > - -
-
-
+
+
+
privacy ? 'checked' : '' }} > +
- -
-
+
+
+ +
+ +
@endsection diff --git a/routes/web.php b/routes/web.php index 13e4d9f51..bb60cfb65 100644 --- a/routes/web.php +++ b/routes/web.php @@ -497,6 +497,9 @@ Route::post('user/email-change/cancel', [UserEmailChangeController::class, 'cancel']) ->name('user.email-change.cancel') ->middleware('auth'); +Route::post('user/email-change/resend', [UserEmailChangeController::class, 'resend']) + ->name('user.email-change.resend') + ->middleware(['auth', 'throttle:6,1']); Route::get('email/change/confirm/{user}/{token}', [UserEmailChangeController::class, 'confirm']) ->name('user.email-change.confirm') ->middleware('signed'); @@ -733,7 +736,9 @@ Route::get('/profile', function () { $data = ['profileUser' => Auth()->user()]; - return view('profile', $data); + return response() + ->view('profile', $data) + ->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); }) ->name('profile') ->middleware(['auth','verified']); diff --git a/tests/Feature/UserEmailChangeTest.php b/tests/Feature/UserEmailChangeTest.php index bacdb36ca..87025b1f6 100644 --- a/tests/Feature/UserEmailChangeTest.php +++ b/tests/Feature/UserEmailChangeTest.php @@ -23,11 +23,20 @@ public function test_user_can_request_email_change_from_profile(): void 'provider' => null, 'password' => bcrypt('correct-password'), 'email_verified_at' => now(), + 'privacy' => 1, ]); $this->signIn($user); + $profileResponse = $this->get(route('profile')); + $profileResponse->assertOk(); + $this->assertStringContainsString('no-store', (string) $profileResponse->headers->get('Cache-Control')); + + preg_match('/name="_token" value="([^"]+)"/', $profileResponse->getContent(), $matches); + $this->assertNotEmpty($matches[1] ?? null); + $response = $this->post(route('user.email-change.request'), [ + '_token' => $matches[1], 'new_email' => 'new@example.com', 'current_password' => 'correct-password', ]); @@ -103,6 +112,32 @@ public function test_user_can_cancel_pending_email_change(): void $this->assertNull($user->pending_email_token); } + public function test_user_can_resend_pending_confirmation_email(): void + { + Mail::fake(); + + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'pending_email' => 'new@example.com', + 'pending_email_token' => hash('sha256', 'old-token'), + 'pending_email_requested_at' => now()->subHour(), + 'email_verified_at' => now(), + ]); + + $this->signIn($user); + + $response = $this->post(route('user.email-change.resend')); + + $response->assertRedirect(); + $response->assertSessionHas('email_change_status'); + + Mail::assertQueued(PendingEmailChangeConfirmation::class, function ($mail) { + return $mail->hasTo('new@example.com'); + }); + + Mail::assertNotQueued(PendingEmailChangeNotification::class); + } + public function test_profile_update_still_cannot_change_login_email_directly(): void { $user = User::factory()->create([