From 6d7d619eab8d49a4acc8854bca5c766ef43d9e0e Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 23 Jul 2026 19:44:07 +0100 Subject: [PATCH 1/2] Add verified email change flow for customer accounts Let customers change their account email with a confirmation step: a signed, expiring link to the new address must be clicked before the change takes effect. On confirmation the email is swapped atomically, Stripe customer details are synced, and the old address is notified. - email_changes table records old/new email, IP and timestamps, giving a permanent audit trail for addresses that have been reassigned - Composer (plugin repo) credentials hard cut over to the new email; the UI warns about auth.json / CI secrets up front and after the change - Plugin access API now returns a stable user id alongside email Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Api/PluginAccessController.php | 1 + .../Auth/EmailChangeController.php | 79 ++++ app/Jobs/SyncStripeCustomerDetailsJob.php | 28 ++ app/Livewire/Customer/Settings.php | 98 ++++ app/Models/EmailChange.php | 54 +++ app/Models/User.php | 8 + app/Notifications/EmailChangeCompleted.php | 54 +++ app/Notifications/EmailChangeRequested.php | 54 +++ app/Notifications/VerifyNewEmailAddress.php | 56 +++ database/factories/EmailChangeFactory.php | 50 ++ ...7_23_171514_create_email_changes_table.php | 36 ++ .../livewire/customer/settings.blade.php | 90 +++- routes/web.php | 2 + tests/Feature/EmailChangeTest.php | 434 ++++++++++++++++++ 14 files changed, 1043 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Auth/EmailChangeController.php create mode 100644 app/Jobs/SyncStripeCustomerDetailsJob.php create mode 100644 app/Models/EmailChange.php create mode 100644 app/Notifications/EmailChangeCompleted.php create mode 100644 app/Notifications/EmailChangeRequested.php create mode 100644 app/Notifications/VerifyNewEmailAddress.php create mode 100644 database/factories/EmailChangeFactory.php create mode 100644 database/migrations/2026_07_23_171514_create_email_changes_table.php create mode 100644 tests/Feature/EmailChangeTest.php diff --git a/app/Http/Controllers/Api/PluginAccessController.php b/app/Http/Controllers/Api/PluginAccessController.php index 40ba873f..b0bb03c6 100644 --- a/app/Http/Controllers/Api/PluginAccessController.php +++ b/app/Http/Controllers/Api/PluginAccessController.php @@ -46,6 +46,7 @@ public function index(Request $request): JsonResponse return response()->json([ 'success' => true, 'user' => [ + 'id' => $user->id, 'email' => $user->email, ], 'plugins' => $accessiblePlugins, diff --git a/app/Http/Controllers/Auth/EmailChangeController.php b/app/Http/Controllers/Auth/EmailChangeController.php new file mode 100644 index 00000000..dc07eb36 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailChangeController.php @@ -0,0 +1,79 @@ +user(); + + abort_if($emailChange->user_id !== $user->id, 403); + + if ($emailChange->isConfirmed()) { + return to_route('customer.settings', ['tab' => 'account']) + ->with('email-changed', 'Your email address has already been updated.'); + } + + abort_if($emailChange->isExpired(), 403); + + if (User::query()->where('email', $emailChange->new_email)->exists()) { + return to_route('customer.settings', ['tab' => 'account']) + ->with('email-change-failed', 'That email address is no longer available. Please request a new change.'); + } + + $oldEmail = $user->email; + + DB::transaction(function () use ($user, $emailChange, $oldEmail): void { + $user->update([ + 'email' => $emailChange->new_email, + 'email_verified_at' => now(), + ]); + + $emailChange->update(['confirmed_at' => now()]); + + $this->updateTeamMemberships($user, $oldEmail, $emailChange->new_email); + }); + + SyncStripeCustomerDetailsJob::dispatch($user); + + Notification::route('mail', $oldEmail)->notify(new EmailChangeCompleted($emailChange)); + + return to_route('customer.settings', ['tab' => 'account']) + ->with('email-changed', 'Your email address has been updated. Remember to update your Composer credentials (auth.json and any CI secrets) to use the new address, or plugin installs will fail.'); + } + + /** + * Keep team membership records in sync with the user's new email, + * skipping any team that already has a record for the new address. + */ + protected function updateTeamMemberships(User $user, string $oldEmail, string $newEmail): void + { + $memberships = TeamUser::query() + ->where('user_id', $user->id) + ->where('email', $oldEmail) + ->get(); + + foreach ($memberships as $membership) { + $emailTaken = TeamUser::query() + ->where('team_id', $membership->team_id) + ->where('email', $newEmail) + ->exists(); + + if (! $emailTaken) { + $membership->update(['email' => $newEmail]); + } + } + } +} diff --git a/app/Jobs/SyncStripeCustomerDetailsJob.php b/app/Jobs/SyncStripeCustomerDetailsJob.php new file mode 100644 index 00000000..3a9bc9d8 --- /dev/null +++ b/app/Jobs/SyncStripeCustomerDetailsJob.php @@ -0,0 +1,28 @@ +user->hasStripeId()) { + return; + } + + $this->user->syncStripeCustomerDetails(); + } +} diff --git a/app/Livewire/Customer/Settings.php b/app/Livewire/Customer/Settings.php index 44bf0584..162e75c3 100644 --- a/app/Livewire/Customer/Settings.php +++ b/app/Livewire/Customer/Settings.php @@ -2,8 +2,15 @@ namespace App\Livewire\Customer; +use App\Models\EmailChange; +use App\Notifications\EmailChangeRequested; +use App\Notifications\VerifyNewEmailAddress; +use Flux; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\RateLimiter; +use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Attributes\Url; @@ -18,6 +25,10 @@ class Settings extends Component public string $name = ''; + public string $newEmail = ''; + + public string $emailChangePassword = ''; + public string $currentPassword = ''; public string $newPassword = ''; @@ -47,6 +58,93 @@ public function updatedReceivesNewPluginNotifications(bool $value): void auth()->user()->update(['receives_new_plugin_notifications' => $value]); } + #[Computed] + public function pendingEmailChange(): ?EmailChange + { + return auth()->user()->emailChanges()->pending()->latest()->first(); + } + + public function requestEmailChange(): void + { + $this->validate([ + 'newEmail' => [ + 'required', + 'string', + 'email', + 'max:255', + function (string $attribute, mixed $value, \Closure $fail): void { + if (strcasecmp($value, auth()->user()->email) === 0) { + $fail('This is already your email address.'); + } + }, + 'unique:users,email', + ], + 'emailChangePassword' => ['required', 'current_password'], + ]); + + $user = auth()->user(); + $rateLimiterKey = 'email-change:'.$user->id; + + if (RateLimiter::tooManyAttempts($rateLimiterKey, 3)) { + $this->addError('newEmail', 'Too many email change requests. Please try again later.'); + + return; + } + + RateLimiter::hit($rateLimiterKey, 600); + + $user->emailChanges()->whereNull('confirmed_at')->delete(); + + $emailChange = $user->emailChanges()->create([ + 'old_email' => $user->email, + 'new_email' => $this->newEmail, + 'ip_address' => request()->ip(), + 'expires_at' => now()->addHour(), + ]); + + Notification::route('mail', $emailChange->new_email)->notify(new VerifyNewEmailAddress($emailChange)); + $user->notify(new EmailChangeRequested($emailChange)); + + $this->reset('newEmail', 'emailChangePassword'); + unset($this->pendingEmailChange); + + Flux::modal('change-email')->close(); + + session()->flash('email-change-requested', "We've sent a confirmation link to {$emailChange->new_email}. Your email will change once you click it."); + } + + public function resendEmailChange(): void + { + $emailChange = $this->pendingEmailChange; + + if (! $emailChange) { + return; + } + + $rateLimiterKey = 'email-change:'.auth()->id(); + + if (RateLimiter::tooManyAttempts($rateLimiterKey, 3)) { + $this->addError('newEmail', 'Too many email change requests. Please try again later.'); + + return; + } + + RateLimiter::hit($rateLimiterKey, 600); + + Notification::route('mail', $emailChange->new_email)->notify(new VerifyNewEmailAddress($emailChange)); + + session()->flash('email-change-requested', "We've re-sent the confirmation link to {$emailChange->new_email}."); + } + + public function cancelEmailChange(): void + { + auth()->user()->emailChanges()->whereNull('confirmed_at')->delete(); + + unset($this->pendingEmailChange); + + session()->flash('email-change-cancelled', 'The email change has been cancelled.'); + } + public function updateName(): void { $this->validate([ diff --git a/app/Models/EmailChange.php b/app/Models/EmailChange.php new file mode 100644 index 00000000..0d13c95a --- /dev/null +++ b/app/Models/EmailChange.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + protected $guarded = []; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopePending(Builder $query): Builder + { + return $query->whereNull('confirmed_at')->where('expires_at', '>', now()); + } + + public function isConfirmed(): bool + { + return $this->confirmed_at !== null; + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function confirmationUrl(): string + { + return URL::temporarySignedRoute('email-change.confirm', $this->expires_at, ['emailChange' => $this->id]); + } + + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + 'confirmed_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 455778bd..dd144d68 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -76,6 +76,14 @@ public function licenses(): HasMany return $this->hasMany(License::class); } + /** + * @return HasMany + */ + public function emailChanges(): HasMany + { + return $this->hasMany(EmailChange::class); + } + /** * @return HasMany */ diff --git a/app/Notifications/EmailChangeCompleted.php b/app/Notifications/EmailChangeCompleted.php new file mode 100644 index 00000000..0796278f --- /dev/null +++ b/app/Notifications/EmailChangeCompleted.php @@ -0,0 +1,54 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Your Account Email Has Been Changed') + ->greeting('Hello!') + ->line("The email address on your NativePHP account has been changed from {$this->emailChange->old_email} to {$this->emailChange->new_email}.") + ->line('From now on, use the new address to log in.') + ->line('If this was not you, contact our support team immediately.'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'email_change_id' => $this->emailChange->id, + 'new_email' => $this->emailChange->new_email, + ]; + } +} diff --git a/app/Notifications/EmailChangeRequested.php b/app/Notifications/EmailChangeRequested.php new file mode 100644 index 00000000..f117cfb6 --- /dev/null +++ b/app/Notifications/EmailChangeRequested.php @@ -0,0 +1,54 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Email Change Requested') + ->greeting('Hello!') + ->line("We received a request to change the email address on your NativePHP account from {$this->emailChange->old_email} to {$this->emailChange->new_email}.") + ->line('A confirmation link has been sent to the new address. Your email will only change once that link is clicked.') + ->line('If this was not you, reset your password immediately and contact our support team.'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'email_change_id' => $this->emailChange->id, + 'new_email' => $this->emailChange->new_email, + ]; + } +} diff --git a/app/Notifications/VerifyNewEmailAddress.php b/app/Notifications/VerifyNewEmailAddress.php new file mode 100644 index 00000000..606b5f2a --- /dev/null +++ b/app/Notifications/VerifyNewEmailAddress.php @@ -0,0 +1,56 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Confirm Your New Email Address') + ->greeting('Hello!') + ->line('A request was made to change the email address on your NativePHP account to this address.') + ->line('Nothing will change until you confirm by clicking the button below. This link expires in 60 minutes.') + ->action('Confirm Email Change', $this->emailChange->confirmationUrl()) + ->line('**Heads up:** once confirmed, your plugin repository (Composer) credentials will use this new email address. Any `auth.json` files or CI secrets configured with your old address will stop working until you update them.') + ->line('If you did not request this change, you can safely ignore this email.'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'email_change_id' => $this->emailChange->id, + 'new_email' => $this->emailChange->new_email, + ]; + } +} diff --git a/database/factories/EmailChangeFactory.php b/database/factories/EmailChangeFactory.php new file mode 100644 index 00000000..1db94dd8 --- /dev/null +++ b/database/factories/EmailChangeFactory.php @@ -0,0 +1,50 @@ + + */ +class EmailChangeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'old_email' => fake()->unique()->safeEmail(), + 'new_email' => fake()->unique()->safeEmail(), + 'ip_address' => fake()->ipv4(), + 'expires_at' => now()->addHour(), + 'confirmed_at' => null, + ]; + } + + /** + * Indicate that the email change has been confirmed. + */ + public function confirmed(): static + { + return $this->state(fn (array $attributes) => [ + 'confirmed_at' => now(), + ]); + } + + /** + * Indicate that the email change request has expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subMinute(), + ]); + } +} diff --git a/database/migrations/2026_07_23_171514_create_email_changes_table.php b/database/migrations/2026_07_23_171514_create_email_changes_table.php new file mode 100644 index 00000000..cf97e8a4 --- /dev/null +++ b/database/migrations/2026_07_23_171514_create_email_changes_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('old_email'); + $table->string('new_email'); + $table->string('ip_address', 45)->nullable(); + $table->timestamp('expires_at'); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'confirmed_at']); + $table->index('old_email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('email_changes'); + } +}; diff --git a/resources/views/livewire/customer/settings.blade.php b/resources/views/livewire/customer/settings.blade.php index 85323134..6f27bde3 100644 --- a/resources/views/livewire/customer/settings.blade.php +++ b/resources/views/livewire/customer/settings.blade.php @@ -45,9 +45,97 @@ class="{{ $tab === 'notifications' ? 'border-b-2 border-zinc-800 dark:border-whi Email Address The email address associated with your account. - + @if (session('email-changed')) + + {{ session('email-changed') }} + + @endif + + @if (session('email-change-failed')) + + {{ session('email-change-failed') }} + + @endif + + @if (session('email-change-requested')) + + {{ session('email-change-requested') }} + + @endif + + @if (session('email-change-cancelled')) + + {{ session('email-change-cancelled') }} + + @endif + +
+ +
+ + @if (auth()->user()->github_id && ! auth()->user()->password) + + + Your account uses GitHub for authentication. To change your email address, first set a password + using the password reset flow. + + + @elseif ($this->pendingEmailChange) + + + We've sent a confirmation link to {{ $this->pendingEmailChange->new_email }}. + Your email address will only change once you click it. The link expires + {{ $this->pendingEmailChange->expires_at->diffForHumans() }}. + + + +
+ Resend Link + Cancel Change +
+ @else + + Change Email Address + + @endif + {{-- Change Email Confirmation Modal --}} + +
+ Change Email Address + + + + Changing your email affects more than just logging in: + + + • Your plugin repository (Composer) credentials use your email address. Any + auth.json files or CI secrets configured with your current address will stop + working until you update them to the new one. + + + • Your billing email will be updated, so receipts and invoices go to the new address. + + + We'll send a confirmation link to the new address — nothing changes until you click it. + + + +
+ + +
+ +
+ + Cancel + + Send Confirmation Link +
+
+
+ {{-- Change Password --}} Password diff --git a/routes/web.php b/routes/web.php index d2c51260..9c42f000 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Features\ShowPlugins; use App\Http\Controllers\ApplinksController; use App\Http\Controllers\Auth\CustomerAuthController; +use App\Http\Controllers\Auth\EmailChangeController; use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\BundleController; use App\Http\Controllers\CartController; @@ -345,6 +346,7 @@ Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice'); Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); + Route::get('email/change/{emailChange}/confirm', [EmailChangeController::class, 'confirm'])->middleware(['signed', 'throttle:6,1'])->name('email-change.confirm'); }); Route::post('logout', [CustomerAuthController::class, 'logout']) diff --git a/tests/Feature/EmailChangeTest.php b/tests/Feature/EmailChangeTest.php new file mode 100644 index 00000000..1b42850d --- /dev/null +++ b/tests/Feature/EmailChangeTest.php @@ -0,0 +1,434 @@ +create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('newEmail', 'new@example.com') + ->set('emailChangePassword', 'password') + ->call('requestEmailChange') + ->assertHasNoErrors() + ->assertSee('new@example.com'); + + $emailChange = EmailChange::query()->where('user_id', $user->id)->first(); + + $this->assertNotNull($emailChange); + $this->assertSame($user->email, $emailChange->old_email); + $this->assertSame('new@example.com', $emailChange->new_email); + $this->assertNull($emailChange->confirmed_at); + $this->assertTrue($emailChange->expires_at->between(now()->addMinutes(59), now()->addMinutes(61))); + + $this->assertSame($user->email, $user->fresh()->email); + + Notification::assertSentOnDemand( + VerifyNewEmailAddress::class, + fn (VerifyNewEmailAddress $notification, array $channels, AnonymousNotifiable $notifiable) => $notifiable->routes['mail'] === 'new@example.com' + ); + + Notification::assertSentTo($user, EmailChangeRequested::class); + } + + public function test_email_change_request_requires_correct_password(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('newEmail', 'new@example.com') + ->set('emailChangePassword', 'wrong-password') + ->call('requestEmailChange') + ->assertHasErrors(['emailChangePassword']); + + $this->assertDatabaseCount('email_changes', 0); + Notification::assertNothingSent(); + } + + public function test_email_change_request_rejects_taken_email(): void + { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('newEmail', $otherUser->email) + ->set('emailChangePassword', 'password') + ->call('requestEmailChange') + ->assertHasErrors(['newEmail']); + + $this->assertDatabaseCount('email_changes', 0); + } + + public function test_email_change_request_rejects_current_email(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('newEmail', strtoupper($user->email)) + ->set('emailChangePassword', 'password') + ->call('requestEmailChange') + ->assertHasErrors(['newEmail']); + + $this->assertDatabaseCount('email_changes', 0); + } + + public function test_email_change_request_rejects_invalid_email(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('newEmail', 'not-an-email') + ->set('emailChangePassword', 'password') + ->call('requestEmailChange') + ->assertHasErrors(['newEmail']); + + $this->assertDatabaseCount('email_changes', 0); + } + + public function test_new_request_supersedes_previous_pending_request(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $previous = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'first@example.com', + ]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('newEmail', 'second@example.com') + ->set('emailChangePassword', 'password') + ->call('requestEmailChange') + ->assertHasNoErrors(); + + $this->assertDatabaseMissing('email_changes', ['id' => $previous->id]); + $this->assertDatabaseCount('email_changes', 1); + $this->assertDatabaseHas('email_changes', [ + 'user_id' => $user->id, + 'new_email' => 'second@example.com', + ]); + } + + public function test_email_change_requests_are_rate_limited(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $component = Livewire::actingAs($user)->test(Settings::class); + + foreach (['one@example.com', 'two@example.com', 'three@example.com'] as $email) { + $component + ->set('newEmail', $email) + ->set('emailChangePassword', 'password') + ->call('requestEmailChange') + ->assertHasNoErrors(); + } + + $component + ->set('newEmail', 'four@example.com') + ->set('emailChangePassword', 'password') + ->call('requestEmailChange') + ->assertHasErrors(['newEmail']); + + $this->assertDatabaseMissing('email_changes', ['new_email' => 'four@example.com']); + } + + public function test_user_can_cancel_a_pending_email_change(): void + { + $user = User::factory()->create(); + + EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->call('cancelEmailChange'); + + $this->assertDatabaseCount('email_changes', 0); + } + + public function test_user_can_resend_a_pending_confirmation_link(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->call('resendEmailChange'); + + Notification::assertSentOnDemand( + VerifyNewEmailAddress::class, + fn (VerifyNewEmailAddress $notification, array $channels, AnonymousNotifiable $notifiable) => $notifiable->routes['mail'] === 'new@example.com' + ); + } + + public function test_confirming_updates_email_and_syncs_stripe(): void + { + Notification::fake(); + Queue::fake(); + + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + $oldEmail = $user->email; + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $oldEmail, + 'new_email' => 'new@example.com', + ]); + + $response = $this->actingAs($user)->get($emailChange->confirmationUrl()); + + $response->assertRedirect(route('customer.settings', ['tab' => 'account'])); + $response->assertSessionHas('email-changed'); + + $user->refresh(); + $this->assertSame('new@example.com', $user->email); + $this->assertNotNull($user->email_verified_at); + $this->assertNotNull($emailChange->fresh()->confirmed_at); + + Queue::assertPushed(SyncStripeCustomerDetailsJob::class, fn (SyncStripeCustomerDetailsJob $job) => $job->user->is($user)); + + Notification::assertSentOnDemand( + EmailChangeCompleted::class, + fn (EmailChangeCompleted $notification, array $channels, AnonymousNotifiable $notifiable) => $notifiable->routes['mail'] === $oldEmail + ); + } + + public function test_confirming_sets_email_verified_at_for_unverified_user(): void + { + Notification::fake(); + + $user = User::factory()->unverified()->create(); + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + $this->actingAs($user)->get($emailChange->confirmationUrl()); + + $this->assertNotNull($user->fresh()->email_verified_at); + } + + public function test_confirmation_requires_authentication(): void + { + $user = User::factory()->create(); + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + $this->get($emailChange->confirmationUrl())->assertRedirect(); + + $this->assertSame($user->email, $user->fresh()->email); + } + + public function test_confirmation_rejects_other_users(): void + { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + $this->actingAs($otherUser)->get($emailChange->confirmationUrl())->assertForbidden(); + + $this->assertSame($user->email, $user->fresh()->email); + } + + public function test_confirmation_rejects_unsigned_url(): void + { + $user = User::factory()->create(); + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + $this->actingAs($user) + ->get(route('email-change.confirm', ['emailChange' => $emailChange->id])) + ->assertForbidden(); + + $this->assertSame($user->email, $user->fresh()->email); + } + + public function test_confirmation_rejects_expired_link(): void + { + $user = User::factory()->create(); + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + $url = $emailChange->confirmationUrl(); + + $this->travel(2)->hours(); + + $this->actingAs($user)->get($url)->assertForbidden(); + + $this->assertSame($user->email, $user->fresh()->email); + } + + public function test_confirmation_is_idempotent_once_confirmed(): void + { + Notification::fake(); + Queue::fake(); + + $user = User::factory()->create(); + + $emailChange = EmailChange::factory()->for($user)->confirmed()->create([ + 'old_email' => 'previous@example.com', + 'new_email' => $user->email, + ]); + + $this->actingAs($user) + ->get($emailChange->confirmationUrl()) + ->assertRedirect(route('customer.settings', ['tab' => 'account'])); + + Queue::assertNothingPushed(); + Notification::assertNothingSent(); + } + + public function test_confirmation_fails_when_email_taken_after_request(): void + { + Notification::fake(); + Queue::fake(); + + $user = User::factory()->create(); + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + User::factory()->create(['email' => 'new@example.com']); + + $response = $this->actingAs($user)->get($emailChange->confirmationUrl()); + + $response->assertRedirect(route('customer.settings', ['tab' => 'account'])); + $response->assertSessionHas('email-change-failed'); + + $this->assertSame($user->email, $user->fresh()->email); + $this->assertNull($emailChange->fresh()->confirmed_at); + Queue::assertNothingPushed(); + } + + public function test_confirming_updates_team_membership_emails(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $membership = TeamUser::factory()->create([ + 'user_id' => $user->id, + 'email' => $user->email, + ]); + + $conflictingMembership = TeamUser::factory()->create([ + 'user_id' => $user->id, + 'email' => $user->email, + ]); + + TeamUser::factory()->create([ + 'team_id' => $conflictingMembership->team_id, + 'email' => 'new@example.com', + ]); + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $user->email, + 'new_email' => 'new@example.com', + ]); + + $this->actingAs($user)->get($emailChange->confirmationUrl()); + + $this->assertSame('new@example.com', $membership->fresh()->email); + $this->assertSame($emailChange->old_email, $conflictingMembership->fresh()->email); + } + + public function test_composer_credentials_cut_over_to_new_email(): void + { + Notification::fake(); + + $user = User::factory()->create(['plugin_license_key' => 'test-license-key-123']); + $oldEmail = $user->email; + + $emailChange = EmailChange::factory()->for($user)->create([ + 'old_email' => $oldEmail, + 'new_email' => 'new@example.com', + ]); + + $this->actingAs($user)->get($emailChange->confirmationUrl()); + + $this->withApiKey() + ->asBasicAuth($oldEmail, 'test-license-key-123') + ->getJson('/api/plugins/access') + ->assertStatus(401); + + $this->withApiKey() + ->asBasicAuth('new@example.com', 'test-license-key-123') + ->getJson('/api/plugins/access') + ->assertStatus(200) + ->assertJson([ + 'success' => true, + 'user' => [ + 'id' => $user->id, + 'email' => 'new@example.com', + ], + ]); + } + + protected function asBasicAuth(string $username, string $password): static + { + return $this->withHeaders([ + 'Authorization' => 'Basic '.base64_encode("{$username}:{$password}"), + ]); + } + + protected function withApiKey(): static + { + return $this->withHeaders([ + 'X-API-Key' => config('services.bifrost.api_key'), + ]); + } +} From 9f2ed3f6d6318e85e15934b8fc9f786b4c085b37 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 24 Jul 2026 10:44:08 +0100 Subject: [PATCH 2/2] Clear email change form fields on cancel Reset the new email and password inputs, along with any validation errors, when the change-email modal is dismissed via Cancel. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Customer/Settings.php | 6 ++++++ .../views/livewire/customer/settings.blade.php | 2 +- tests/Feature/EmailChangeTest.php | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Customer/Settings.php b/app/Livewire/Customer/Settings.php index 162e75c3..a20d0bc0 100644 --- a/app/Livewire/Customer/Settings.php +++ b/app/Livewire/Customer/Settings.php @@ -64,6 +64,12 @@ public function pendingEmailChange(): ?EmailChange return auth()->user()->emailChanges()->pending()->latest()->first(); } + public function resetEmailChangeForm(): void + { + $this->reset('newEmail', 'emailChangePassword'); + $this->resetValidation(['newEmail', 'emailChangePassword']); + } + public function requestEmailChange(): void { $this->validate([ diff --git a/resources/views/livewire/customer/settings.blade.php b/resources/views/livewire/customer/settings.blade.php index 6f27bde3..c7c284fd 100644 --- a/resources/views/livewire/customer/settings.blade.php +++ b/resources/views/livewire/customer/settings.blade.php @@ -129,7 +129,7 @@ class="{{ $tab === 'notifications' ? 'border-b-2 border-zinc-800 dark:border-whi
- Cancel + Cancel Send Confirmation Link
diff --git a/tests/Feature/EmailChangeTest.php b/tests/Feature/EmailChangeTest.php index 1b42850d..4a5b3e6d 100644 --- a/tests/Feature/EmailChangeTest.php +++ b/tests/Feature/EmailChangeTest.php @@ -163,6 +163,22 @@ public function test_email_change_requests_are_rate_limited(): void $this->assertDatabaseMissing('email_changes', ['new_email' => 'four@example.com']); } + public function test_cancelling_the_form_clears_fields_and_validation_errors(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('newEmail', 'not-an-email') + ->set('emailChangePassword', 'some-password') + ->call('requestEmailChange') + ->assertHasErrors(['newEmail']) + ->call('resetEmailChangeForm') + ->assertHasNoErrors() + ->assertSet('newEmail', '') + ->assertSet('emailChangePassword', ''); + } + public function test_user_can_cancel_a_pending_email_change(): void { $user = User::factory()->create();