diff --git a/app/Contracts/TransactionalNotification.php b/app/Contracts/TransactionalNotification.php new file mode 100644 index 00000000..556be31c --- /dev/null +++ b/app/Contracts/TransactionalNotification.php @@ -0,0 +1,16 @@ +wasRecentlyCreated) { + $user->notify(new ClaimAccount( + Password::broker()->createToken($user) + )); + } + // Create the license via job $subscription = Subscription::from($validated['subscription']); diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index 8f933b20..8b77bfe9 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -156,9 +156,17 @@ public function resetPassword(Request $request): RedirectResponse $status = Password::reset( $request->only('email', 'password', 'password_confirmation', 'token'), function ($user, $password): void { - $user->forceFill([ - 'password' => $password, - ]); + $attributes = ['password' => $password]; + + // Proving control of the inbox + setting a password is sufficient + // to consider the email verified. This also lets the same flow + // serve as the "claim your account" path for users created via + // checkout. + if (! $user->email_verified_at) { + $attributes['email_verified_at'] = now(); + } + + $user->forceFill($attributes); $user->save(); } diff --git a/app/Listeners/SuppressMailNotificationListener.php b/app/Listeners/SuppressMailNotificationListener.php index a78a54dd..9b7c5cce 100644 --- a/app/Listeners/SuppressMailNotificationListener.php +++ b/app/Listeners/SuppressMailNotificationListener.php @@ -2,7 +2,9 @@ namespace App\Listeners; +use App\Contracts\TransactionalNotification; use App\Models\User; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Notifications\Events\NotificationSending; @@ -18,8 +20,13 @@ public function handle(NotificationSending $event): bool return true; } - // System notifications like email verification should always be sent - if ($event->notification instanceof VerifyEmail) { + // Transactional notifications (account recovery, verification, + // purchase receipts, entitlement grants) must always be delivered. + // Framework notifications can't implement our marker, so they're + // listed explicitly. + if ($event->notification instanceof TransactionalNotification + || $event->notification instanceof VerifyEmail + || $event->notification instanceof ResetPassword) { return true; } diff --git a/app/Livewire/ClaimDonationLicense.php b/app/Livewire/ClaimDonationLicense.php index 96f2064c..748982d7 100644 --- a/app/Livewire/ClaimDonationLicense.php +++ b/app/Livewire/ClaimDonationLicense.php @@ -7,6 +7,7 @@ use App\Jobs\CreateAnystackLicenseJob; use App\Models\OpenCollectiveDonation; use App\Models\User; +use Illuminate\Auth\Events\Registered; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules\Password; @@ -107,6 +108,8 @@ public function claim(): void 'name' => $this->name, 'password' => Hash::make($this->password), ]); + + event(new Registered($user)); } // Parse name for first/last diff --git a/app/Livewire/MobilePricing.php b/app/Livewire/MobilePricing.php index 3d07d3bd..062da46f 100644 --- a/app/Livewire/MobilePricing.php +++ b/app/Livewire/MobilePricing.php @@ -4,9 +4,11 @@ use App\Enums\Subscription; use App\Models\User; +use App\Notifications\ClaimAccount; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Laravel\Cashier\Cashier; @@ -182,11 +184,19 @@ private function findOrCreateUser(string $email): User 'email' => 'required|email|max:255', ]); - return User::firstOrCreate([ + $user = User::firstOrCreate([ 'email' => $email, ], [ 'password' => Hash::make(Str::random(72)), ]); + + if ($user->wasRecentlyCreated) { + $user->notify(new ClaimAccount( + Password::broker()->createToken($user) + )); + } + + return $user; } private function successUrl(): string diff --git a/app/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php index a59266ed..fa28695b 100644 --- a/app/Livewire/OrderSuccess.php +++ b/app/Livewire/OrderSuccess.php @@ -3,7 +3,6 @@ namespace App\Livewire; use App\Enums\Subscription; -use App\Models\License; use Illuminate\Database\Eloquent\ModelNotFoundException; use Laravel\Cashier\Cashier; use Livewire\Attributes\Layout; @@ -17,10 +16,10 @@ class OrderSuccess extends Component { public ?string $email = null; - public ?string $licenseKey = null; - public ?Subscription $subscription = null; + public bool $isExistingUser = false; + public string $checkoutSessionId; public function mount(string $checkoutSessionId): void @@ -67,9 +66,10 @@ public function loadData(): void return; } - $this->email = $subscriptionRecord->user->email; - $this->licenseKey = License::query() - ->whereBelongsTo($subscriptionItem) - ->first()?->key; + $user = $subscriptionRecord->user; + $this->email = $user->email; + // Users created via checkout start with email_verified_at = null and + // are sent a ClaimAccount email. Verified users already have access. + $this->isExistingUser = ! is_null($user->email_verified_at); } } diff --git a/app/Notifications/BundleGranted.php b/app/Notifications/BundleGranted.php index a88b6dd3..6ce81cf9 100644 --- a/app/Notifications/BundleGranted.php +++ b/app/Notifications/BundleGranted.php @@ -2,6 +2,7 @@ namespace App\Notifications; +use App\Contracts\TransactionalNotification; use App\Models\Plugin; use App\Models\PluginBundle; use Illuminate\Bus\Queueable; @@ -10,7 +11,7 @@ use Illuminate\Notifications\Notification; use Illuminate\Support\Collection; -class BundleGranted extends Notification implements ShouldQueue +class BundleGranted extends Notification implements ShouldQueue, TransactionalNotification { use Queueable; diff --git a/app/Notifications/ClaimAccount.php b/app/Notifications/ClaimAccount.php new file mode 100644 index 00000000..9b97b332 --- /dev/null +++ b/app/Notifications/ClaimAccount.php @@ -0,0 +1,41 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $url = route('password.reset', [ + 'token' => $this->token, + 'email' => $notifiable->getEmailForPasswordReset(), + ]); + + return (new MailMessage) + ->subject('Welcome to NativePHP — Claim Your Account') + ->greeting('Welcome to NativePHP!') + ->line('Thanks for your purchase. We\'ve created an account for you so you can access your licenses and downloads.') + ->line('To finish setting up your account, please click the button below to verify your email address and set a password.') + ->action('Claim Your Account', $url) + ->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.') + ->salutation("Happy coding!\n\nThe NativePHP Team"); + } +} diff --git a/app/Notifications/LicenseKeyGenerated.php b/app/Notifications/LicenseKeyGenerated.php index 585b59f3..ac8879dd 100644 --- a/app/Notifications/LicenseKeyGenerated.php +++ b/app/Notifications/LicenseKeyGenerated.php @@ -2,13 +2,14 @@ namespace App\Notifications; +use App\Contracts\TransactionalNotification; use App\Enums\Subscription; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -class LicenseKeyGenerated extends Notification implements ShouldQueue +class LicenseKeyGenerated extends Notification implements ShouldQueue, TransactionalNotification { use Queueable; diff --git a/app/Notifications/PluginGranted.php b/app/Notifications/PluginGranted.php index 5c7721a4..86bc5825 100644 --- a/app/Notifications/PluginGranted.php +++ b/app/Notifications/PluginGranted.php @@ -2,13 +2,14 @@ namespace App\Notifications; +use App\Contracts\TransactionalNotification; use App\Models\Plugin; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -class PluginGranted extends Notification implements ShouldQueue +class PluginGranted extends Notification implements ShouldQueue, TransactionalNotification { use Queueable; diff --git a/app/Notifications/ProductGranted.php b/app/Notifications/ProductGranted.php index d95d9960..38f1a410 100644 --- a/app/Notifications/ProductGranted.php +++ b/app/Notifications/ProductGranted.php @@ -2,13 +2,14 @@ namespace App\Notifications; +use App\Contracts\TransactionalNotification; use App\Models\Product; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -class ProductGranted extends Notification implements ShouldQueue +class ProductGranted extends Notification implements ShouldQueue, TransactionalNotification { use Queueable; diff --git a/package-lock.json b/package-lock.json index a9c8c099..bdd48d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "ivory-tiger", + "name": "opal-parrot", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/views/livewire/order-success.blade.php b/resources/views/livewire/order-success.blade.php index 734d2231..6320e34b 100644 --- a/resources/views/livewire/order-success.blade.php +++ b/resources/views/livewire/order-success.blade.php @@ -1,4 +1,4 @@ -
+
{{-- Hero Section --}}
- You've purchased a license. + Welcome to NativePHP Ultra.
- @if ($licenseKey) -

- License key -

-
+ Your subscription is now active. Head to your + dashboard to manage it and access everything + included with Ultra. +

- setTimeout(() => (this.showMessage = false), 2000) - }, - }" - > -
- - -
-
{{ $licenseKey }}
-
-

- Store this somewhere safe. You'll need it later. -

- @if ($email) -

- Email + Go to Dashboard + + @else +

+ We've sent a link to + {{ $email }} + so you can claim your account and access your + dashboard.

-
-
- - -
-
{{ $email }}
-
+ Didn't get the email? Check your spam folder, or + reach out to + support@nativephp.com + and we'll sort it out. +

@endif @else
- License registration in progress + Finalising your subscription

- Please - - check your email - - shortly for a copy of your license key. This page - will also update if your license key is ready. -

- -

- Once you receive your license key, you can start - building amazing mobile apps with NativePHP! + This will only take a moment. The page will update + automatically.

@endif
- - -
- - - - - View Installation Guide -
-
- - @if ($subscription === \App\Enums\Subscription::Max) -
-
-
- -
-
-

- Repo Access -

-
-

- As a Max subscriber, you have access to - the NativePHP/mobile repository. To - access it, please log in to - - AnyStack.sh - using the same email address you used - for your purchase. -

-
-
-
-
- @endif @@ -496,38 +325,6 @@ class="size-6 text-emerald-600 dark:text-emerald-400"

- @if ($subscription === \App\Enums\Subscription::Max) -
-
- -
-

Access the Repo

-

- - Create a customer account - - on Anystack to gain access to the NativePHP for - Mobile - - repository - - where you can let us know if you find any bugs as - you build. -

-
- @endif diff --git a/tests/Feature/CustomerAuthenticationTest.php b/tests/Feature/CustomerAuthenticationTest.php index 2673937c..72d14a0f 100644 --- a/tests/Feature/CustomerAuthenticationTest.php +++ b/tests/Feature/CustomerAuthenticationTest.php @@ -4,8 +4,11 @@ use App\Features\ShowAuthButtons; use App\Models\User; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Password; use Laravel\Pennant\Feature; use Tests\TestCase; @@ -95,4 +98,72 @@ public function test_unauthenticated_customer_is_redirected_to_login(): void $response->assertRedirect('/login'); } + + public function test_password_reset_email_is_sent_for_unverified_users(): void + { + Notification::fake(); + + $user = User::factory()->unverified()->create([ + 'email' => 'unverified-customer@gmail.com', + ]); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_password_reset_email_is_sent_for_opted_out_users(): void + { + Notification::fake(); + + $user = User::factory()->create([ + 'email' => 'opted-out-customer@gmail.com', + 'receives_notification_emails' => false, + ]); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_password_reset_verifies_email_when_unverified(): void + { + $user = User::factory()->unverified()->create([ + 'email' => 'claimer@gmail.com', + ]); + $token = Password::broker()->createToken($user); + + $response = $this->post('/reset-password', [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ]); + + $response->assertRedirect('/login'); + + $user->refresh(); + $this->assertNotNull($user->email_verified_at); + $this->assertTrue(Hash::check('new-password-123', $user->password)); + } + + public function test_password_reset_preserves_existing_verification_timestamp(): void + { + $verifiedAt = now()->subMonth()->startOfDay(); + $user = User::factory()->create([ + 'email' => 'already-verified@gmail.com', + 'email_verified_at' => $verifiedAt, + ]); + $token = Password::broker()->createToken($user); + + $this->post('/reset-password', [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ]); + + $user->refresh(); + $this->assertTrue($user->email_verified_at->equalTo($verifiedAt)); + } } diff --git a/tests/Feature/Livewire/OrderSuccessTest.php b/tests/Feature/Livewire/OrderSuccessTest.php index 86593e94..aaea5860 100644 --- a/tests/Feature/Livewire/OrderSuccessTest.php +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -4,7 +4,6 @@ use App\Enums\Subscription; use App\Livewire\OrderSuccess; -use App\Models\License; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Cashier\Cashier; @@ -37,90 +36,88 @@ public function it_renders_successfully() } #[Test] - public function it_displays_loading_state_when_no_license_key_is_available() + public function it_displays_loading_state_when_subscription_record_is_not_yet_available() { Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) ->assertSet('email', null) - ->assertSet('licenseKey', null) - ->assertSee('License registration in progress') - ->assertSee('check your email'); + ->assertSet('subscription', null) + ->assertSee('Finalising your subscription') + ->assertSeeHtml('wire:poll.2s="loadData"'); } #[Test] - public function it_displays_license_key_when_available_in_database() + public function it_shows_dashboard_link_for_existing_users(): void { $user = User::factory()->create([ 'email' => 'test@example.com', 'stripe_id' => 'cus_test123', + 'email_verified_at' => now(), ]); - $subscription = Cashier::$subscriptionModel::factory() - ->for($user, 'user') - ->create([ - 'stripe_id' => 'sub_test123', - ]); + $this->buildSubscriptionRecord($user); - $subscriptionItem = Cashier::$subscriptionItemModel::factory() - ->for($subscription, 'subscription') - ->create([ - 'stripe_id' => 'si_test123', - 'stripe_price' => Subscription::Max->stripePriceId(), - ]); + Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) + ->assertSet('email', 'test@example.com') + ->assertSet('isExistingUser', true) + ->assertSee('Go to Dashboard') + ->assertSeeHtml(route('dashboard')) + ->assertDontSee('claim your account') + ->assertDontSee('Finalising your subscription'); + } - $license = License::factory() - ->for($user, 'user') - ->for($subscriptionItem, 'subscriptionItem') - ->create([ - 'key' => 'db-license-key-12345', - 'policy_name' => 'max', - ]); + #[Test] + public function it_shows_claim_message_for_new_users(): void + { + $user = User::factory()->unverified()->create([ + 'email' => 'test@example.com', + 'stripe_id' => 'cus_test123', + ]); + + $this->buildSubscriptionRecord($user); Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) ->assertSet('email', 'test@example.com') - ->assertSet('licenseKey', 'db-license-key-12345') - ->assertSee('db-license-key-12345') + ->assertSet('isExistingUser', false) + ->assertSee('claim your account') ->assertSee('test@example.com') - ->assertDontSee('License registration in progress'); + ->assertSee('support@nativephp.com') + ->assertDontSee('Go to Dashboard'); } #[Test] - public function it_polls_for_updates_from_database() + public function it_polls_until_subscription_record_appears(): void { $component = Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) - ->assertSet('licenseKey', null) - ->assertSee('License registration in progress') + ->assertSet('subscription', null) + ->assertSee('Finalising your subscription') ->assertSeeHtml('wire:poll.2s="loadData"'); $user = User::factory()->create([ 'email' => 'test@example.com', 'stripe_id' => 'cus_test123', + 'email_verified_at' => now(), ]); + $this->buildSubscriptionRecord($user); + + $component->call('loadData') + ->assertSet('subscription', Subscription::Max) + ->assertSee('Go to Dashboard') + ->assertDontSee('Finalising your subscription'); + } + + private function buildSubscriptionRecord(User $user): void + { $subscription = Cashier::$subscriptionModel::factory() ->for($user, 'user') - ->create([ - 'stripe_id' => 'sub_test123', - ]); + ->create(['stripe_id' => 'sub_test123']); - $subscriptionItem = Cashier::$subscriptionItemModel::factory() + Cashier::$subscriptionItemModel::factory() ->for($subscription, 'subscription') ->create([ 'stripe_id' => 'si_test123', 'stripe_price' => Subscription::Max->stripePriceId(), ]); - - $license = License::factory() - ->for($user, 'user') - ->for($subscriptionItem, 'subscriptionItem') - ->create([ - 'key' => 'db-polled-license-key', - 'policy_name' => 'max', - ]); - - $component->call('loadData') - ->assertSet('licenseKey', 'db-polled-license-key') - ->assertSee('db-polled-license-key') - ->assertDontSee('License registration in progress'); } #[Test] diff --git a/tests/Feature/MobilePricingTest.php b/tests/Feature/MobilePricingTest.php index 7a10d8b2..1ce2239e 100644 --- a/tests/Feature/MobilePricingTest.php +++ b/tests/Feature/MobilePricingTest.php @@ -5,8 +5,10 @@ use App\Livewire\MobilePricing; use App\Models\License; use App\Models\User; +use App\Notifications\ClaimAccount; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Notification; use Laravel\Cashier\Cashier; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; @@ -77,6 +79,40 @@ public function it_validates_email_before_creating_user() ->assertHasErrors('email'); } + #[Test] + public function purchase_with_new_email_sends_claim_account_notification() + { + Notification::fake(); + + // Invalid plan short-circuits the Stripe call but the user is still + // created — we want to assert the claim email is dispatched in that path. + Livewire::test(MobilePricing::class) + ->call('handlePurchaseRequest', [ + 'email' => 'new-customer@example.com', + 'plan' => 'not-a-real-plan', + ]); + + $user = User::where('email', 'new-customer@example.com')->first(); + $this->assertNotNull($user); + Notification::assertSentTo($user, ClaimAccount::class); + } + + #[Test] + public function purchase_with_existing_email_does_not_send_claim_account_notification() + { + Notification::fake(); + + $user = User::factory()->create(['email' => 'existing@example.com']); + + Livewire::test(MobilePricing::class) + ->call('handlePurchaseRequest', [ + 'email' => 'existing@example.com', + 'plan' => 'not-a-real-plan', + ]); + + Notification::assertNotSentTo($user, ClaimAccount::class); + } + #[Test] public function default_interval_is_month() { diff --git a/tests/Feature/SuppressMailNotificationListenerTest.php b/tests/Feature/SuppressMailNotificationListenerTest.php index 6c9b4b98..84d05329 100644 --- a/tests/Feature/SuppressMailNotificationListenerTest.php +++ b/tests/Feature/SuppressMailNotificationListenerTest.php @@ -2,10 +2,13 @@ namespace Tests\Feature; +use App\Contracts\TransactionalNotification; use App\Listeners\SuppressMailNotificationListener; use App\Models\Plugin; use App\Models\User; +use App\Notifications\LicenseKeyGenerated; use App\Notifications\PluginApproved; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Notifications\Events\NotificationSending; use Tests\TestCase; @@ -89,6 +92,74 @@ public function test_suppresses_mail_when_user_email_is_not_verified(): void $this->assertFalse($listener->handle($event)); } + public function test_allows_password_reset_for_opted_out_users(): void + { + $user = User::factory()->create(['receives_notification_emails' => false]); + + $event = new NotificationSending( + $user, + new ResetPassword('token'), + 'mail', + ); + + $listener = new SuppressMailNotificationListener; + + $this->assertTrue($listener->handle($event)); + } + + public function test_allows_password_reset_for_unverified_users(): void + { + $user = User::factory()->unverified()->create(); + + $event = new NotificationSending( + $user, + new ResetPassword('token'), + 'mail', + ); + + $listener = new SuppressMailNotificationListener; + + $this->assertTrue($listener->handle($event)); + } + + public function test_allows_transactional_notifications_for_opted_out_users(): void + { + $user = User::factory()->create(['receives_notification_emails' => false]); + + $event = new NotificationSending( + $user, + new LicenseKeyGenerated('test-key'), + 'mail', + ); + + $listener = new SuppressMailNotificationListener; + + $this->assertTrue($listener->handle($event)); + } + + public function test_allows_transactional_notifications_for_unverified_users(): void + { + $user = User::factory()->unverified()->create(); + + $event = new NotificationSending( + $user, + new LicenseKeyGenerated('test-key'), + 'mail', + ); + + $listener = new SuppressMailNotificationListener; + + $this->assertTrue($listener->handle($event)); + } + + public function test_license_key_generated_is_marked_transactional(): void + { + $this->assertInstanceOf( + TransactionalNotification::class, + new LicenseKeyGenerated('test-key'), + ); + } + public function test_allows_non_mail_channels_for_unverified_users(): void { $user = User::factory()->unverified()->create(['receives_notification_emails' => true]);