diff --git a/app/Models/User.php b/app/Models/User.php index ebaac6a5..c675b0ed 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -152,7 +152,10 @@ class User extends Authenticatable implements HasLocalePreference */ public function sendPasswordResetNotification($token) { - $this->notify(new ResetPasswordNotification($token)); + // An account that signs in through a provider has never had a + // password here, so its link sets the first one rather than + // resetting anything (PasswordController::sendLink). + $this->notify(new ResetPasswordNotification($token, firstPassword: $this->auth_source === AuthSource::Social)); } /** diff --git a/app/Modules/Identity/Notifications/ResetPasswordNotification.php b/app/Modules/Identity/Notifications/ResetPasswordNotification.php index c70821f6..6bc3aeca 100644 --- a/app/Modules/Identity/Notifications/ResetPasswordNotification.php +++ b/app/Modules/Identity/Notifications/ResetPasswordNotification.php @@ -27,6 +27,7 @@ class ResetPasswordNotification extends Notification implements ShouldQueue public function __construct( public readonly string $token, + public readonly bool $firstPassword = false, ) {} /** @@ -46,6 +47,20 @@ class ResetPasswordNotification extends Notification implements ShouldQueue $expireMinutes = (int) config('auth.passwords.'.config('auth.defaults.passwords').'.expire'); + // The same link, for an account that signs in through a provider and + // has never had a password: this is now the only way it gets one, so + // an email that speaks of a reset nobody asked for is one people + // ignore. Not taken from the customisable reset template for the + // same reason, since that text is written about resetting. + if ($this->firstPassword) { + return (new MailMessage) + ->subject(__('Set your password')) + ->line(__('Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.')) + ->action(__('Set a password'), $url) + ->line(__('This link will expire in :count minutes.', ['count' => $expireMinutes])) + ->line(__('If you did not ask for this, no further action is required: you can keep signing in the way you do now.')); + } + if (($override = $this->overrideOrNull(EmailTemplateSlot::PasswordReset)) !== null) { return $this->mailFromOverride($override, [':count' => (string) $expireMinutes]) ->action(__('Reset Password'), $url); diff --git a/tests/Feature/Identity/ProviderAccountPasswordTest.php b/tests/Feature/Identity/ProviderAccountPasswordTest.php index a177dcdf..8a178c29 100644 --- a/tests/Feature/Identity/ProviderAccountPasswordTest.php +++ b/tests/Feature/Identity/ProviderAccountPasswordTest.php @@ -106,6 +106,32 @@ test('using the link signs out the session that asked for it', function () { $this->assertGuest(); }); +test('the email to a provider account is about setting a first password, not a reset', function () { + Notification::fake(); + $user = providerAccount(); + + $this->actingAs($user)->post('/settings/password/link'); + + Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool { + $mail = $notification->toMail($user); + + return $mail->subject === __('Set your password') + && $mail->actionText === __('Set a password') + && ! str_contains(implode(' ', $mail->introLines), 'reset'); + }); +}); + +test('an account with a password still gets the reset email', function () { + Notification::fake(); + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool { + return $notification->toMail($user)->subject === __('Reset Password Notification'); + }); +}); + test('an account that already has a password uses the form, not a link', function () { Notification::fake(); $user = User::factory()->create();