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. +
+ Waiting for confirmation at {{ $profileUser->pending_email }}. + Check that inbox for a confirmation link (valid for 48 hours). +
+ +