Skip to content

Commit f2c4b45

Browse files
simonhampclaude
andcommitted
Add email verification and one-click notification unsubscribe
- Re-enable MustVerifyEmail on User model and fire Registered event on registration - Add EmailVerificationController with verification routes (notice, verify, resend) - Add persistent dashboard banner prompting unverified users to verify email - Add one-click unsubscribe/resubscribe via signed URLs in notification emails - Update SuppressMailNotificationListener to always allow system emails (e.g. VerifyEmail) - Remove email_verified_at field from admin user edit form - Add comprehensive tests for email verification and notification unsubscribe flows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b69fd86 commit f2c4b45

17 files changed

Lines changed: 684 additions & 15 deletions

app/Filament/Resources/UserResource.php

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

33
namespace App\Filament\Resources;
44

5-
use App\Enums\StripeConnectStatus;
65
use App\Filament\Resources\UserResource\Pages;
76
use App\Filament\Resources\UserResource\RelationManagers;
87
use App\Models\User;
@@ -38,11 +37,11 @@ public static function form(Schema $schema): Schema
3837
->email()
3938
->required()
4039
->maxLength(255),
41-
Forms\Components\DateTimePicker::make('email_verified_at'),
4240
Forms\Components\TextInput::make('password')
4341
->password()
4442
->dehydrated(fn ($state) => filled($state))
4543
->required(fn (string $context): bool => $context === 'create')
44+
->hidden(fn (string $context): bool => $context === 'edit')
4645
->maxLength(255),
4746
]),
4847
Schemas\Components\Section::make('Billing Information')
@@ -64,15 +63,26 @@ public static function form(Schema $schema): Schema
6463
->maxLength(255)
6564
->disabled(),
6665
]),
66+
Schemas\Components\Section::make('Notifications')
67+
->description('Once these are disabled, they cannot be re-enabled by an admin.')
68+
->inlineLabel()
69+
->columns(1)
70+
->schema([
71+
Forms\Components\Toggle::make('receives_notification_emails')
72+
->label('Email notifications')
73+
->disabled(fn (?User $record) => $record && ! $record->receives_notification_emails),
74+
Forms\Components\Toggle::make('receives_new_plugin_notifications')
75+
->label('New plugin notifications')
76+
->disabled(fn (?User $record) => $record && ! $record->receives_new_plugin_notifications),
77+
]),
6778
Schemas\Components\Section::make('Developer Account')
6879
->inlineLabel()
6980
->columns(1)
7081
->visible(fn (?User $record) => $record?->developerAccount !== null)
7182
->schema([
72-
Forms\Components\Select::make('developerAccount.stripe_connect_status')
83+
Forms\Components\Placeholder::make('developerAccount.stripe_connect_status')
7384
->label('Stripe Connect Status')
74-
->options(StripeConnectStatus::class)
75-
->disabled(),
85+
->content(fn (User $record) => $record->developerAccount->stripe_connect_status?->label() ?? ''),
7686
Forms\Components\Placeholder::make('developerAccount.stripe_connect_account_id')
7787
->label('Stripe Connect Account')
7888
->content(fn (User $record) => new HtmlString(

app/Http/Controllers/Auth/CustomerAuthController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use App\Models\TeamUser;
1010
use App\Models\User;
1111
use App\Services\CartService;
12+
use Illuminate\Auth\Events\Registered;
1213
use Illuminate\Auth\Passwords\PasswordBroker;
1314
use Illuminate\Http\RedirectResponse;
1415
use Illuminate\Http\Request;
@@ -45,6 +46,8 @@ public function register(Request $request): RedirectResponse
4546
'password' => Hash::make($request->password),
4647
]);
4748

49+
event(new Registered($user));
50+
4851
Auth::login($user);
4952

5053
// Transfer guest cart to user
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Foundation\Auth\EmailVerificationRequest;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
10+
class EmailVerificationController extends Controller
11+
{
12+
public function notice(): RedirectResponse
13+
{
14+
return to_route('dashboard');
15+
}
16+
17+
public function verify(EmailVerificationRequest $request): RedirectResponse
18+
{
19+
$request->fulfill();
20+
21+
return to_route('dashboard')->with('success', 'Your email address has been verified.');
22+
}
23+
24+
public function resend(Request $request): RedirectResponse
25+
{
26+
$request->user()->sendEmailVerificationNotification();
27+
28+
return back()->with('status', 'A new verification link has been sent to your email address.');
29+
}
30+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\User;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\View\View;
9+
10+
class NotificationUnsubscribeController extends Controller
11+
{
12+
public function unsubscribe(Request $request, User $user): RedirectResponse|View
13+
{
14+
$user->update(['receives_new_plugin_notifications' => false]);
15+
16+
if ($request->user()?->is($user)) {
17+
return redirect()
18+
->route('customer.settings', ['tab' => 'notifications'])
19+
->with('new-plugin-notifications-disabled', true);
20+
}
21+
22+
return view('notifications.unsubscribed', [
23+
'maskedEmail' => $this->maskEmail($user->email),
24+
'resubscribeUrl' => $this->signedResubscribeUrl($user),
25+
]);
26+
}
27+
28+
public function resubscribe(Request $request, User $user): RedirectResponse|View
29+
{
30+
$user->update(['receives_new_plugin_notifications' => true]);
31+
32+
if ($request->user()?->is($user)) {
33+
return redirect()
34+
->route('customer.settings', ['tab' => 'notifications'])
35+
->with('new-plugin-notifications-enabled', true);
36+
}
37+
38+
return view('notifications.resubscribed', [
39+
'maskedEmail' => $this->maskEmail($user->email),
40+
]);
41+
}
42+
43+
public static function signedUnsubscribeUrl(User $user): string
44+
{
45+
return url()->signedRoute('notifications.unsubscribe', ['user' => $user]);
46+
}
47+
48+
private function signedResubscribeUrl(User $user): string
49+
{
50+
return url()->signedRoute('notifications.resubscribe', ['user' => $user]);
51+
}
52+
53+
private function maskEmail(string $email): string
54+
{
55+
[$local, $domain] = explode('@', $email);
56+
57+
if (strlen($local) <= 2) {
58+
$maskedLocal = $local[0].str_repeat('*', max(1, strlen($local) - 1));
59+
} else {
60+
$maskedLocal = $local[0].str_repeat('*', strlen($local) - 2).$local[strlen($local) - 1];
61+
}
62+
63+
return $maskedLocal.'@'.$domain;
64+
}
65+
}

app/Listeners/SuppressMailNotificationListener.php

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

55
use App\Models\User;
6+
use Illuminate\Auth\Notifications\VerifyEmail;
67
use Illuminate\Notifications\Events\NotificationSending;
78

89
class SuppressMailNotificationListener
@@ -17,6 +18,11 @@ public function handle(NotificationSending $event): bool
1718
return true;
1819
}
1920

20-
return $event->notifiable->receives_notification_emails;
21+
// System notifications like email verification should always be sent
22+
if ($event->notification instanceof VerifyEmail) {
23+
return true;
24+
}
25+
26+
return (bool) $event->notifiable->receives_notification_emails;
2127
}
2228
}

app/Models/User.php

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

33
namespace App\Models;
44

5-
// use Illuminate\Contracts\Auth\MustVerifyEmail;
65
use App\Enums\PriceTier;
76
use App\Enums\Subscription;
87
use App\Enums\TeamUserStatus;
98
use Filament\Models\Contracts\FilamentUser;
109
use Filament\Models\Contracts\HasName;
1110
use Filament\Panel;
11+
use Illuminate\Contracts\Auth\MustVerifyEmail;
1212
use Illuminate\Database\Eloquent\Casts\Attribute;
1313
use Illuminate\Database\Eloquent\Factories\HasFactory;
1414
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -19,7 +19,7 @@
1919
use Laravel\Cashier\Billable;
2020
use Laravel\Sanctum\HasApiTokens;
2121

22-
class User extends Authenticatable implements FilamentUser, HasName
22+
class User extends Authenticatable implements FilamentUser, HasName, MustVerifyEmail
2323
{
2424
use Billable, HasApiTokens, HasFactory, Notifiable;
2525

app/Notifications/NewPluginAvailable.php

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

33
namespace App\Notifications;
44

5+
use App\Http\Controllers\NotificationUnsubscribeController;
56
use App\Models\Plugin;
7+
use App\Models\User;
68
use Illuminate\Bus\Queueable;
79
use Illuminate\Contracts\Queue\ShouldQueue;
810
use Illuminate\Notifications\Messages\MailMessage;
@@ -30,12 +32,15 @@ public function via(object $notifiable): array
3032

3133
public function toMail(object $notifiable): MailMessage
3234
{
35+
/** @var User $notifiable */
36+
$unsubscribeUrl = NotificationUnsubscribeController::signedUnsubscribeUrl($notifiable);
37+
3338
return (new MailMessage)
3439
->subject("New Plugin: {$this->plugin->name}")
3540
->greeting('A new plugin is available!')
3641
->line("**{$this->plugin->name}** has just been added to the NativePHP Plugin Marketplace.")
3742
->action('View Plugin', route('plugins.show', $this->plugin->routeParams()))
38-
->line('[Manage your notification preferences]('.route('customer.settings', ['tab' => 'notifications']).').');
43+
->line('[Unsubscribe from new plugin notifications]('.$unsubscribeUrl.').');
3944
}
4045

4146
/**

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/views/livewire/customer/dashboard.blade.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@
44
<flux:text>Welcome back, {{ auth()->user()->first_name ?? auth()->user()->name }}</flux:text>
55
</div>
66

7+
{{-- Email Verification Banner --}}
8+
@if (!auth()->user()->hasVerifiedEmail())
9+
<flux:callout variant="warning" icon="envelope" class="mb-6">
10+
<flux:callout.heading>Please verify your email address.</flux:callout.heading>
11+
<flux:callout.text>
12+
We sent a verification email when you registered. Click the link in that email to verify your account.
13+
14+
@if (session('status'))
15+
<span class="font-medium">{{ session('status') }}</span>
16+
@endif
17+
</flux:callout.text>
18+
<x-slot:actions>
19+
<form method="POST" action="{{ route('verification.send') }}">
20+
@csrf
21+
<flux:button type="submit" variant="filled" size="sm">Resend verification email</flux:button>
22+
</form>
23+
</x-slot:actions>
24+
</flux:callout>
25+
@endif
26+
727
{{-- Session Messages --}}
828
@if (session('success'))
929
<flux:callout variant="success" icon="check-circle" class="mb-6">

resources/views/livewire/customer/settings.blade.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,18 @@ class="{{ $tab === 'notifications' ? 'border-b-2 border-zinc-800 dark:border-whi
127127
@endif
128128

129129
@if ($tab === 'notifications')
130+
@if (session('new-plugin-notifications-disabled'))
131+
<flux:callout variant="success" icon="check-circle" class="mb-6">
132+
<flux:callout.text>New plugin notifications have been disabled.</flux:callout.text>
133+
</flux:callout>
134+
@endif
135+
136+
@if (session('new-plugin-notifications-enabled'))
137+
<flux:callout variant="success" icon="check-circle" class="mb-6">
138+
<flux:callout.text>New plugin notifications have been re-enabled.</flux:callout.text>
139+
</flux:callout>
140+
@endif
141+
130142
<flux:card class="space-y-6">
131143
<flux:switch
132144
wire:model.live="receivesNotificationEmails"

0 commit comments

Comments
 (0)