Skip to content

Commit 06fbf4a

Browse files
simonhampclaude
andcommitted
Fix password reset for unverified users and rework checkout-driven account creation
Password reset emails (and other transactional mail) were being silently dropped for users with email_verified_at = null or receives_notification_emails = false, because SuppressMailNotificationListener only carved out VerifyEmail. Customers who purchased Ultra via the pricing flow were created with a random password and never sent a verification email (no Registered event fired), so they had no way into their account: the post-purchase license email was suppressed, and password reset was suppressed too. Changes: - Introduce App\Contracts\TransactionalNotification marker so transactional mail (account recovery, entitlements, receipts) bypasses the listener. - Tag LicenseKeyGenerated, BundleGranted, PluginGranted, ProductGranted. - Carve out framework ResetPassword + VerifyEmail explicitly in the listener. - Add ClaimAccount notification (uses the password broker token + existing reset-password UI) so checkout-created users get one email that verifies email and lets them set a password. - Dispatch ClaimAccount from MobilePricing and Api\LicenseController when wasRecentlyCreated. Fire Registered from ClaimDonationLicense (user already chose their own password there). - On password reset, set email_verified_at = now() when null so the same flow serves both real resets and account claims. - Rework OrderSuccess: drop license-key copy entirely. For Ultra, show "Go to Dashboard" for verified users, or a "check your inbox / contact support@nativephp.com" message for newly-created users. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7f8d512 commit 06fbf4a

18 files changed

Lines changed: 377 additions & 308 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Contracts;
4+
5+
use App\Listeners\SuppressMailNotificationListener;
6+
7+
/**
8+
* Marker interface for notifications that must always be delivered,
9+
* regardless of the user's email preferences or verification state.
10+
*
11+
* Account recovery, purchase receipts, and license/entitlement
12+
* notifications fall into this category.
13+
*
14+
* @see SuppressMailNotificationListener
15+
*/
16+
interface TransactionalNotification {}

app/Http/Controllers/Api/LicenseController.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
use App\Jobs\CreateAnystackLicenseJob;
1010
use App\Models\License;
1111
use App\Models\User;
12+
use App\Notifications\ClaimAccount;
1213
use Illuminate\Http\Request;
1314
use Illuminate\Support\Facades\Hash;
15+
use Illuminate\Support\Facades\Password;
1416
use Illuminate\Support\Str;
1517
use Illuminate\Validation\Rules\Enum;
1618

@@ -46,6 +48,12 @@ public function store(Request $request)
4648
]
4749
);
4850

51+
if ($user->wasRecentlyCreated) {
52+
$user->notify(new ClaimAccount(
53+
Password::broker()->createToken($user)
54+
));
55+
}
56+
4957
// Create the license via job
5058
$subscription = Subscription::from($validated['subscription']);
5159

app/Http/Controllers/Auth/CustomerAuthController.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,17 @@ public function resetPassword(Request $request): RedirectResponse
156156
$status = Password::reset(
157157
$request->only('email', 'password', 'password_confirmation', 'token'),
158158
function ($user, $password): void {
159-
$user->forceFill([
160-
'password' => $password,
161-
]);
159+
$attributes = ['password' => $password];
160+
161+
// Proving control of the inbox + setting a password is sufficient
162+
// to consider the email verified. This also lets the same flow
163+
// serve as the "claim your account" path for users created via
164+
// checkout.
165+
if (! $user->email_verified_at) {
166+
$attributes['email_verified_at'] = now();
167+
}
168+
169+
$user->forceFill($attributes);
162170

163171
$user->save();
164172
}

app/Listeners/SuppressMailNotificationListener.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace App\Listeners;
44

5+
use App\Contracts\TransactionalNotification;
56
use App\Models\User;
7+
use Illuminate\Auth\Notifications\ResetPassword;
68
use Illuminate\Auth\Notifications\VerifyEmail;
79
use Illuminate\Notifications\Events\NotificationSending;
810

@@ -18,8 +20,13 @@ public function handle(NotificationSending $event): bool
1820
return true;
1921
}
2022

21-
// System notifications like email verification should always be sent
22-
if ($event->notification instanceof VerifyEmail) {
23+
// Transactional notifications (account recovery, verification,
24+
// purchase receipts, entitlement grants) must always be delivered.
25+
// Framework notifications can't implement our marker, so they're
26+
// listed explicitly.
27+
if ($event->notification instanceof TransactionalNotification
28+
|| $event->notification instanceof VerifyEmail
29+
|| $event->notification instanceof ResetPassword) {
2330
return true;
2431
}
2532

app/Livewire/ClaimDonationLicense.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Jobs\CreateAnystackLicenseJob;
88
use App\Models\OpenCollectiveDonation;
99
use App\Models\User;
10+
use Illuminate\Auth\Events\Registered;
1011
use Illuminate\Support\Facades\Auth;
1112
use Illuminate\Support\Facades\Hash;
1213
use Illuminate\Validation\Rules\Password;
@@ -107,6 +108,8 @@ public function claim(): void
107108
'name' => $this->name,
108109
'password' => Hash::make($this->password),
109110
]);
111+
112+
event(new Registered($user));
110113
}
111114

112115
// Parse name for first/last

app/Livewire/MobilePricing.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
use App\Enums\Subscription;
66
use App\Models\User;
7+
use App\Notifications\ClaimAccount;
78
use Illuminate\Support\Facades\Auth;
89
use Illuminate\Support\Facades\Hash;
910
use Illuminate\Support\Facades\Log;
11+
use Illuminate\Support\Facades\Password;
1012
use Illuminate\Support\Facades\Validator;
1113
use Illuminate\Support\Str;
1214
use Laravel\Cashier\Cashier;
@@ -182,11 +184,19 @@ private function findOrCreateUser(string $email): User
182184
'email' => 'required|email|max:255',
183185
]);
184186

185-
return User::firstOrCreate([
187+
$user = User::firstOrCreate([
186188
'email' => $email,
187189
], [
188190
'password' => Hash::make(Str::random(72)),
189191
]);
192+
193+
if ($user->wasRecentlyCreated) {
194+
$user->notify(new ClaimAccount(
195+
Password::broker()->createToken($user)
196+
));
197+
}
198+
199+
return $user;
190200
}
191201

192202
private function successUrl(): string

app/Livewire/OrderSuccess.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace App\Livewire;
44

55
use App\Enums\Subscription;
6-
use App\Models\License;
76
use Illuminate\Database\Eloquent\ModelNotFoundException;
87
use Laravel\Cashier\Cashier;
98
use Livewire\Attributes\Layout;
@@ -17,10 +16,10 @@ class OrderSuccess extends Component
1716
{
1817
public ?string $email = null;
1918

20-
public ?string $licenseKey = null;
21-
2219
public ?Subscription $subscription = null;
2320

21+
public bool $isExistingUser = false;
22+
2423
public string $checkoutSessionId;
2524

2625
public function mount(string $checkoutSessionId): void
@@ -67,9 +66,10 @@ public function loadData(): void
6766
return;
6867
}
6968

70-
$this->email = $subscriptionRecord->user->email;
71-
$this->licenseKey = License::query()
72-
->whereBelongsTo($subscriptionItem)
73-
->first()?->key;
69+
$user = $subscriptionRecord->user;
70+
$this->email = $user->email;
71+
// Users created via checkout start with email_verified_at = null and
72+
// are sent a ClaimAccount email. Verified users already have access.
73+
$this->isExistingUser = ! is_null($user->email_verified_at);
7474
}
7575
}

app/Notifications/BundleGranted.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Notifications;
44

5+
use App\Contracts\TransactionalNotification;
56
use App\Models\Plugin;
67
use App\Models\PluginBundle;
78
use Illuminate\Bus\Queueable;
@@ -10,7 +11,7 @@
1011
use Illuminate\Notifications\Notification;
1112
use Illuminate\Support\Collection;
1213

13-
class BundleGranted extends Notification implements ShouldQueue
14+
class BundleGranted extends Notification implements ShouldQueue, TransactionalNotification
1415
{
1516
use Queueable;
1617

app/Notifications/ClaimAccount.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace App\Notifications;
4+
5+
use App\Contracts\TransactionalNotification;
6+
use Illuminate\Bus\Queueable;
7+
use Illuminate\Contracts\Queue\ShouldQueue;
8+
use Illuminate\Notifications\Messages\MailMessage;
9+
use Illuminate\Notifications\Notification;
10+
11+
class ClaimAccount extends Notification implements ShouldQueue, TransactionalNotification
12+
{
13+
use Queueable;
14+
15+
public function __construct(public string $token) {}
16+
17+
/**
18+
* @return array<int, string>
19+
*/
20+
public function via(object $notifiable): array
21+
{
22+
return ['mail'];
23+
}
24+
25+
public function toMail(object $notifiable): MailMessage
26+
{
27+
$url = route('password.reset', [
28+
'token' => $this->token,
29+
'email' => $notifiable->getEmailForPasswordReset(),
30+
]);
31+
32+
return (new MailMessage)
33+
->subject('Welcome to NativePHP — Claim Your Account')
34+
->greeting('Welcome to NativePHP!')
35+
->line('Thanks for your purchase. We\'ve created an account for you so you can access your licenses and downloads.')
36+
->line('To finish setting up your account, please click the button below to verify your email address and set a password.')
37+
->action('Claim Your Account', $url)
38+
->line('This link will expire in '.config('auth.passwords.users.expire').' minutes. If it expires, you can request a new one from the password reset page.')
39+
->salutation("Happy coding!\n\nThe NativePHP Team");
40+
}
41+
}

app/Notifications/LicenseKeyGenerated.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
namespace App\Notifications;
44

5+
use App\Contracts\TransactionalNotification;
56
use App\Enums\Subscription;
67
use Illuminate\Bus\Queueable;
78
use Illuminate\Contracts\Queue\ShouldQueue;
89
use Illuminate\Notifications\Messages\MailMessage;
910
use Illuminate\Notifications\Notification;
1011

11-
class LicenseKeyGenerated extends Notification implements ShouldQueue
12+
class LicenseKeyGenerated extends Notification implements ShouldQueue, TransactionalNotification
1213
{
1314
use Queueable;
1415

0 commit comments

Comments
 (0)