diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php index a300bfaf..660e151b 100644 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use Illuminate\Auth\Events\Verified; +use App\Jobs\VerifyUserEmail; use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Http\RedirectResponse; @@ -14,16 +14,14 @@ class VerifyEmailController extends Controller */ public function __invoke(EmailVerificationRequest $request): RedirectResponse { - if ($request->user()->hasVerifiedEmail()) { + /** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */ + $user = $request->user(); + + if ($user->hasVerifiedEmail()) { return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); } - if ($request->user()->markEmailAsVerified()) { - /** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */ - $user = $request->user(); - - event(new Verified($user)); - } + dispatch(new VerifyUserEmail($user)); return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); } diff --git a/app/Jobs/VerifyUserEmail.php b/app/Jobs/VerifyUserEmail.php new file mode 100644 index 00000000..bbab6695 --- /dev/null +++ b/app/Jobs/VerifyUserEmail.php @@ -0,0 +1,69 @@ +user instanceof MustVerifyEmail) { + return; + } + + DB::transaction(function (): void { + $this->purgeConflicts(); + $this->applyVerification(); + }); + + event(new Verified($this->user)); + } + + /** + * Remove conflicting unverified users and reservations. + */ + private function purgeConflicts(): void + { + $email = $this->user->unverified_email ?? $this->user->email; + + User::query() + ->where('email', $email) + ->whereNot('id', $this->user->id) + ->whereNull('email_verified_at') + ->delete(); + + User::query() + ->where('unverified_email', $email) + ->whereNot('id', $this->user->id) + ->whereNull('email_verified_at') + ->update(['unverified_email' => null]); + } + + /** + * Apply email change or mark as verified. + */ + private function applyVerification(): void + { + if ($this->user->unverified_email) { + $this->user->forceFill([ + 'email' => $this->user->unverified_email, + 'unverified_email' => null, + 'email_verified_at' => now(), + ])->save(); + + return; + } + + $this->user->forceFill([ + 'email_verified_at' => now(), + ])->save(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3cb5ccb1..5b08c8f2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,13 +2,13 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable; @@ -21,6 +21,7 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', + 'unverified_email', 'password', ]; diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9e..a3c874a5 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -16,6 +16,7 @@ public function up(): void $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); + $table->string('unverified_email')->nullable(); $table->string('password'); $table->rememberToken(); $table->timestamps(); diff --git a/resources/views/livewire/settings/profile.blade.php b/resources/views/livewire/settings/profile.blade.php index cb088335..a92abf3d 100644 --- a/resources/views/livewire/settings/profile.blade.php +++ b/resources/views/livewire/settings/profile.blade.php @@ -8,7 +8,7 @@ new class extends Component { public string $name = ''; - public string $email = ''; + public string $unverified_email = ''; /** * Mount the component. @@ -16,7 +16,7 @@ public function mount(): void { $this->name = Auth::user()->name; - $this->email = Auth::user()->email; + $this->unverified_email = Auth::user()->unverified_email ?? Auth::user()->email; } /** @@ -29,19 +29,23 @@ public function updateProfileInformation(): void $validated = $this->validate([ 'name' => ['required', 'string', 'max:255'], - 'email' => [ + 'unverified_email' => [ 'required', 'string', 'lowercase', 'email', 'max:255', - Rule::unique(User::class)->ignore($user->id) + Rule::unique(User::class, 'email')->ignore($user->id) ], ]); + if ($validated['unverified_email'] === Auth::user()->email) { + unset($validated['unverified_email']); + } + $user->fill($validated); - if ($user->isDirty('email')) { + if ($user->isDirty('unverified_email')) { $user->email_verified_at = null; } @@ -77,7 +81,7 @@ public function resendVerificationNotification(): void
- + @if (auth()->user() instanceof \Illuminate\Contracts\Auth\MustVerifyEmail &&! auth()->user()->hasVerifiedEmail())
diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index e3f52d14..a63ae2d5 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -56,4 +56,53 @@ public function test_email_is_not_verified_with_invalid_hash(): void $this->assertFalse($user->fresh()->hasVerifiedEmail()); } + + public function test_unverified_email_resets_when_another_user_verifies_their_registration_email(): void + { + $unverifiedUser = User::factory()->unverified()->create([ + 'unverified_email' => 'unverified@example.com', + ]); + + $user = User::factory()->unverified()->create([ + 'email' => $unverifiedUser->unverified_email, + ]); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $this->assertNull($user->fresh()->unverified_email); + $this->assertNull($unverifiedUser->fresh()->unverified_email); + } + + public function test_unverified_user_deleted_when_another_user_verifies_their_new_email(): void + { + $unverifiedUser = User::factory()->unverified()->create(); + + $user = User::factory()->unverified()->create([ + 'unverified_email' => $unverifiedUser->email, + ]); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $this->assertNull($user->fresh()->unverified_email); + $this->assertEquals($user->fresh()->email, $unverifiedUser->email); + $this->assertNull($unverifiedUser->fresh()); + } } diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 3742659f..bac3aa27 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -26,7 +26,7 @@ public function test_profile_information_can_be_updated(): void $response = Volt::test('settings.profile') ->set('name', 'Test User') - ->set('email', 'test@example.com') + ->set('unverified_email', 'test@example.com') ->call('updateProfileInformation'); $response->assertHasNoErrors(); @@ -34,7 +34,7 @@ public function test_profile_information_can_be_updated(): void $user->refresh(); $this->assertEquals('Test User', $user->name); - $this->assertEquals('test@example.com', $user->email); + $this->assertEquals('test@example.com', $user->unverified_email); $this->assertNull($user->email_verified_at); } @@ -46,7 +46,7 @@ public function test_email_verification_status_is_unchanged_when_email_address_i $response = Volt::test('settings.profile') ->set('name', 'Test User') - ->set('email', $user->email) + ->set('unverified_email', $user->email) ->call('updateProfileInformation'); $response->assertHasNoErrors();