Skip to content

Commit cb70161

Browse files
committed
feat(users): add email change verification flow
Introduces a verified email change workflow for users. New email is stored as pending until confirmed via signed link, with cancel support and panel-aware notifications for both old and new addresses. - Domain actions: InitiateEmailChange, ConfirmEmailChange, CancelEmailChange - Events + notifications for initiated/confirmed/cancelled states - Filament pages for confirm/cancel and EditProfile integration - Migrations adding pending_email columns - UpgradeCommand for both packages
1 parent d091ce4 commit cb70161

16 files changed

Lines changed: 650 additions & 1 deletion

README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,96 @@ Contributions are welcome! Please follow the PSR-12 coding standard and submit p
201201

202202
---
203203

204+
## ✉️ Email change with confirmation
205+
206+
This package ships the building blocks for a confirmed email-change flow:
207+
actions, events and notifications. **Routing, URL generation and the
208+
listener that sends the mail are intentionally left to your application**
209+
this keeps the package framework-agnostic and lets you decide where the
210+
confirmation link points.
211+
212+
### What's included
213+
214+
- Migration: `pending_email`, `pending_email_token`, `pending_email_token_expires_at`, `pending_email_requested_at`.
215+
- Actions:
216+
- `Backstage\Laravel\Users\Domain\Email\Actions\InitiateEmailChange`
217+
- `Backstage\Laravel\Users\Domain\Email\Actions\ConfirmEmailChange`
218+
- `Backstage\Laravel\Users\Domain\Email\Actions\CancelEmailChange`
219+
- Events: `EmailChangeInitiated`, `EmailChangeConfirmed`, `EmailChangeCancelled` (under `Backstage\Laravel\Users\Events\Email\`).
220+
- Notifications (URL is supplied via constructor): `Backstage\Laravel\Users\Notifications\Email\ConfirmEmailChange`, `Backstage\Laravel\Users\Notifications\Email\EmailChangeRequested`.
221+
- Config block `users.email_change.*` for token lifetime, cooldown and old-address notification.
222+
223+
### Minimal setup in your application
224+
225+
```php
226+
// app/Providers/AppServiceProvider.php
227+
use Backstage\Laravel\Users\Events\Email\EmailChangeInitiated;
228+
use Backstage\Laravel\Users\Notifications\Email\ConfirmEmailChange;
229+
use Backstage\Laravel\Users\Notifications\Email\EmailChangeRequested;
230+
use Illuminate\Support\Facades\Event;
231+
use Illuminate\Support\Facades\Notification;
232+
use Illuminate\Support\Facades\URL;
233+
234+
public function boot(): void
235+
{
236+
Event::listen(function (EmailChangeInitiated $event) {
237+
$confirmUrl = URL::temporarySignedRoute(
238+
'email.confirm',
239+
now()->addMinutes($event->expiresInMinutes),
240+
['user' => $event->user->id, 'token' => $event->rawToken],
241+
);
242+
243+
Notification::route('mail', $event->user->pending_email)
244+
->notify(new ConfirmEmailChange($event->newEmail, $confirmUrl));
245+
246+
if (config('users.email_change.notify_old_address')) {
247+
$cancelUrl = URL::temporarySignedRoute(
248+
'email.cancel',
249+
now()->addMinutes($event->expiresInMinutes),
250+
['user' => $event->user->id, 'token' => $event->rawToken],
251+
);
252+
253+
$event->user->notify(new EmailChangeRequested($event->newEmail, $cancelUrl));
254+
}
255+
});
256+
}
257+
```
258+
259+
```php
260+
// routes/web.php
261+
use Backstage\Laravel\Users\Domain\Email\Actions\CancelEmailChange;
262+
use Backstage\Laravel\Users\Domain\Email\Actions\ConfirmEmailChange;
263+
use Backstage\Laravel\Users\Eloquent\Models\User;
264+
265+
Route::middleware('signed')->group(function () {
266+
Route::get('/email/confirm/{user}/{token}', function (User $user, string $token) {
267+
ConfirmEmailChange::run($user, $token);
268+
269+
return redirect('/');
270+
})->name('email.confirm');
271+
272+
Route::get('/email/cancel/{user}/{token}', function (User $user, string $token) {
273+
CancelEmailChange::run($user, $token);
274+
275+
return redirect('/');
276+
})->name('email.cancel');
277+
});
278+
```
279+
280+
```php
281+
// trigger from your controller / Filament page / Livewire component
282+
use Backstage\Laravel\Users\Domain\Email\Actions\InitiateEmailChange;
283+
284+
InitiateEmailChange::run($user, $request->input('email'));
285+
```
286+
287+
The `?string $source` parameter on `InitiateEmailChange` is opaque to this
288+
package; pass any identifier you need on the receiving end (panel ID, tenant
289+
slug, channel, …). Listeners can branch on `$event->source` to decide which
290+
URL to build.
291+
292+
---
293+
204294
## 📄 License
205295

206296
This package is open-sourced software licensed under the [MIT license](LICENSE.md).

config/users.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,22 @@
5050
'special_chars' => '!@#$%^&*()_+-=[]{}|;:,.<>?',
5151
],
5252
],
53+
54+
/*
55+
|--------------------------------------------------------------------------
56+
| Email change
57+
|--------------------------------------------------------------------------
58+
|
59+
| This package only ships the building blocks (actions, events,
60+
| notifications). Consumers must wire their own routes, controllers and
61+
| a listener for `EmailChangeInitiated` that builds the confirmation URL
62+
| and dispatches the notification. See the README for a minimal example.
63+
|
64+
*/
65+
'email_change' => [
66+
'enabled' => true,
67+
'token_lifetime_minutes' => 60 * 24,
68+
'notify_old_address' => true,
69+
'cooldown_minutes' => 5,
70+
],
5371
];
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::table(config('users.eloquent.user.table', 'users'), function (Blueprint $table) {
12+
$table->string('pending_email')->nullable()->after('email');
13+
$table->string('pending_email_token')->nullable()->after('pending_email');
14+
$table->timestamp('pending_email_token_expires_at')->nullable()->after('pending_email_token');
15+
$table->timestamp('pending_email_requested_at')->nullable()->after('pending_email_token_expires_at');
16+
17+
$table->index('pending_email_token');
18+
});
19+
}
20+
21+
public function down(): void
22+
{
23+
Schema::table(config('users.eloquent.user.table', 'users'), function (Blueprint $table) {
24+
$table->dropIndex([config('users.eloquent.user.table', 'users').'_pending_email_token_index']);
25+
26+
$table->dropColumn([
27+
'pending_email',
28+
'pending_email_token',
29+
'pending_email_token_expires_at',
30+
'pending_email_requested_at',
31+
]);
32+
});
33+
}
34+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
namespace Backstage\Laravel\Users\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Support\Facades\Schema;
7+
8+
use function Laravel\Prompts\confirm;
9+
use function Laravel\Prompts\info;
10+
use function Laravel\Prompts\note;
11+
use function Laravel\Prompts\warning;
12+
13+
class UpgradeCommand extends Command
14+
{
15+
protected $signature = 'users:upgrade
16+
{--force : Skip confirmation prompts}
17+
{--no-migrate : Skip running migrations}';
18+
19+
protected $description = 'Upgrade backstage/laravel-users: publish config and run pending migrations.';
20+
21+
public function handle(): int
22+
{
23+
info('Upgrading backstage/laravel-users…');
24+
25+
if (! $this->option('force') && ! confirm(
26+
label: 'This will publish the package config (if missing) and run pending migrations. Continue?',
27+
default: true,
28+
)) {
29+
warning('Upgrade cancelled.');
30+
31+
return self::SUCCESS;
32+
}
33+
34+
$this->publishConfig();
35+
36+
if (! $this->option('no-migrate')) {
37+
$this->runMigrations();
38+
}
39+
40+
$this->reportEmailChangeFeature();
41+
42+
info('backstage/laravel-users is up to date.');
43+
44+
return self::SUCCESS;
45+
}
46+
47+
protected function publishConfig(): void
48+
{
49+
if (file_exists(config_path('users.php'))) {
50+
note('Config already published at config/users.php — skipping.');
51+
52+
return;
53+
}
54+
55+
$this->call('vendor:publish', [
56+
'--provider' => 'Backstage\Laravel\Users\LaravelUsersServiceProvider',
57+
'--tag' => 'config',
58+
'--force' => false,
59+
]);
60+
}
61+
62+
protected function runMigrations(): void
63+
{
64+
$this->call('migrate', [
65+
'--force' => true,
66+
]);
67+
}
68+
69+
protected function reportEmailChangeFeature(): void
70+
{
71+
$usersTable = config('users.eloquent.user.table', 'users');
72+
73+
$hasPendingEmail = Schema::hasColumn($usersTable, 'pending_email');
74+
75+
if (! $hasPendingEmail) {
76+
warning(__('Email-change columns are missing on the :table table. Run migrations to enable the feature.', ['table' => $usersTable]));
77+
78+
return;
79+
}
80+
81+
info(__('Email-change toolkit is ready.'));
82+
83+
if (config('users.email_change.enabled', true)) {
84+
note(__('Reminder: backstage/laravel-users does not register a listener for EmailChangeInitiated by default. If you are not using backstage/users, wire your own listener that builds the confirmation URL and dispatches the notification (see the package README).'));
85+
}
86+
}
87+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Backstage\Laravel\Users\Domain\Email\Actions;
4+
5+
use Backstage\Laravel\Users\Domain\Email\Exceptions\EmailChangeException;
6+
use Backstage\Laravel\Users\Eloquent\Models\User;
7+
use Backstage\Laravel\Users\Events\Email\EmailChangeCancelled;
8+
use Lorisleiva\Actions\Concerns\AsAction;
9+
10+
class CancelEmailChange
11+
{
12+
use AsAction;
13+
14+
public function handle(User $user, string $rawToken): void
15+
{
16+
if ($user->pending_email === null || $user->pending_email_token === null) {
17+
throw EmailChangeException::noPendingChange();
18+
}
19+
20+
if (! hash_equals((string) $user->pending_email_token, hash('sha256', $rawToken))) {
21+
throw EmailChangeException::tokenInvalid();
22+
}
23+
24+
$abandonedEmail = (string) $user->pending_email;
25+
26+
$user->forceFill([
27+
'pending_email' => null,
28+
'pending_email_token' => null,
29+
'pending_email_token_expires_at' => null,
30+
'pending_email_requested_at' => null,
31+
])->save();
32+
33+
EmailChangeCancelled::dispatch($user->fresh(), $abandonedEmail);
34+
}
35+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace Backstage\Laravel\Users\Domain\Email\Actions;
4+
5+
use Backstage\Laravel\Users\Domain\Email\Exceptions\EmailChangeException;
6+
use Backstage\Laravel\Users\Eloquent\Models\User;
7+
use Backstage\Laravel\Users\Events\Email\EmailChangeConfirmed;
8+
use Illuminate\Support\Facades\DB;
9+
use Lorisleiva\Actions\Concerns\AsAction;
10+
11+
class ConfirmEmailChange
12+
{
13+
use AsAction;
14+
15+
public function handle(User $user, string $rawToken): User
16+
{
17+
if ($user->pending_email === null || $user->pending_email_token === null) {
18+
throw EmailChangeException::noPendingChange();
19+
}
20+
21+
if (! hash_equals((string) $user->pending_email_token, hash('sha256', $rawToken))) {
22+
throw EmailChangeException::tokenInvalid();
23+
}
24+
25+
if ($user->pending_email_token_expires_at !== null && $user->pending_email_token_expires_at->isPast()) {
26+
throw EmailChangeException::tokenExpired();
27+
}
28+
29+
$oldEmail = (string) $user->email;
30+
$newEmail = (string) $user->pending_email;
31+
32+
DB::transaction(function () use ($user, $newEmail) {
33+
$userClass = config('auth.providers.users.model', User::class);
34+
35+
$taken = $userClass::query()
36+
->where('id', '!=', $user->getKey())
37+
->whereRaw('LOWER(email) = ?', [mb_strtolower($newEmail)])
38+
->exists();
39+
40+
if ($taken) {
41+
throw EmailChangeException::alreadyTaken();
42+
}
43+
44+
$user->forceFill([
45+
'email' => $newEmail,
46+
'email_verified_at' => now(),
47+
'pending_email' => null,
48+
'pending_email_token' => null,
49+
'pending_email_token_expires_at' => null,
50+
'pending_email_requested_at' => null,
51+
])->save();
52+
});
53+
54+
EmailChangeConfirmed::dispatch($user->fresh(), $oldEmail);
55+
56+
return $user;
57+
}
58+
}

0 commit comments

Comments
 (0)