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..a20d0bc0 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,99 @@ 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 resetEmailChangeForm(): void + { + $this->reset('newEmail', 'emailChangePassword'); + $this->resetValidation(['newEmail', 'emailChangePassword']); + } + + 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..c7c284fd 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..4a5b3e6d --- /dev/null +++ b/tests/Feature/EmailChangeTest.php @@ -0,0 +1,450 @@ +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_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(); + + 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'), + ]); + } +}