feat: Enable email verification on sign up (#97)

## Summary
- Enables `MustVerifyEmail` on the `User` model so new users must verify
their email before accessing protected routes
- Unverified users are redirected to `/email/verify` when attempting to
access routes behind the `verified` middleware (subscribe, onboarding,
dashboard)
- A verification email is automatically sent on registration via Fortify

## Screenshot

![Email verification
page](https://raw.githubusercontent.com/whisper-money/whisper-money/mail-verification/storage/videos/email-verification-page.png)

## Video


https://github.com/whisper-money/whisper-money/raw/mail-verification/storage/videos/ac0945c527f014b9cd657f18c911f496.webm

## Test plan
- [x] New test: registration sends a `VerifyEmail` notification
- [x] New test: newly registered users have `email_verified_at` as null
- [x] New test: unverified users are redirected to `verification.notice`
from protected routes (subscribe, onboarding, dashboard)
- [x] New test: verified users are not redirected to verification notice
- [x] Existing email verification and notification tests still pass (31
auth tests, 52 settings tests)
- [x] Browser walkthrough: registration → redirected to `/email/verify`
page with "Resend verification email" button
This commit is contained in:
Víctor Falcón 2026-02-03 10:15:07 +01:00 committed by GitHub
parent 1500e5cd91
commit 370d388d99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 120 additions and 22 deletions

View File

@ -2,8 +2,9 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\DripEmailType;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -15,7 +16,7 @@ use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Pennant\Concerns\HasFeatures;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
@ -133,4 +134,9 @@ class User extends Authenticatable
{
return $this->email === config('app.demo.email');
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmailNotification);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
class VerifyEmailNotification extends VerifyEmail
{
public function toMail($notifiable): MailMessage
{
$verificationUrl = $this->verificationUrl($notifiable);
return (new MailMessage)
->from(config('mail.from.address', 'hello@example.com'), 'Victor')
->subject('Verify Your Email - Whisper Money')
->markdown('mail.verify-email', [
'userName' => $notifiable->name,
'verificationUrl' => $verificationUrl,
]);
}
}

View File

@ -0,0 +1,21 @@
<x-mail::message>
# Verify your email, {{ $userName }}!
Hi! I'm Victor, the founder of Whisper Money. Thanks for signing up I just need you to verify your email address to get started.
Once verified, you'll be able to set up your encryption key and start tracking your finances with full privacy.
<x-mail::button :url="$verificationUrl">
Verify Email Address
</x-mail::button>
If you didn't create a Whisper Money account, you can safely ignore this email.
Best,<br>
Víctor F,<br>
Founder of Whisper Money
<x-mail::subcopy>
If you're having trouble clicking the "Verify Email Address" button, copy and paste the URL below into your web browser: <span class="break-all">[{{ $verificationUrl }}]({{ $verificationUrl }})</span>
</x-mail::subcopy>
</x-mail::message>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -12,9 +12,7 @@ it('can register a new user', function () {
->fill('password_confirmation', 'password123')
->click('@register-user-button')
->wait(2)
->assertSee('Welcome to')
->assertSee('Whisper Money')
->assertPathIs('/onboarding')
->assertPathIs('/email/verify')
->assertNoJavascriptErrors();
$this->assertDatabaseHas('users', [

View File

@ -8,7 +8,7 @@ use App\Models\User;
// Basic Redirect Tests
// =============================================================================
it('redirects new registration to onboarding page', function () {
it('redirects new registration to email verification', function () {
$page = visit('/register');
$page->assertSee('Create an account')
@ -18,7 +18,7 @@ it('redirects new registration to onboarding page', function () {
->fill('password_confirmation', 'password123456')
->click('@register-user-button')
->wait(3)
->assertPathIs('/onboarding')
->assertPathIs('/email/verify')
->assertNoJavascriptErrors();
$this->assertDatabaseHas('users', [
@ -365,17 +365,15 @@ it('completes onboarding flow through account creation', function () {
// Create a bank for the account creation step
Bank::factory()->create(['name' => 'Chase Bank']);
$page = visit('/register');
$user = User::factory()->create([
'onboarded_at' => null,
]);
// Step 1: Register
$page->assertSee('Create an account')
->fill('name', 'E2E Test User')
->fill('email', 'e2e-onboarding@example.com')
->fill('password', 'SecurePassword123!')
->fill('password_confirmation', 'SecurePassword123!')
->click('@register-user-button')
->wait(3)
->assertPathIs('/onboarding')
$this->actingAs($user);
$page = visit('/onboarding');
$page->assertPathIs('/onboarding')
->assertNoJavascriptErrors();
// Step 2: Welcome
@ -428,7 +426,7 @@ it('completes onboarding flow through account creation', function () {
->assertNoJavascriptErrors();
// Verify user's encryption was set up
$user = User::where('email', 'e2e-onboarding@example.com')->first();
$user->refresh();
expect($user->encryption_salt)->not->toBeNull();
// Verify account was created

View File

@ -10,7 +10,7 @@ test('email verification screen can be rendered', function () {
$response = $this->actingAs($user)->get(route('verification.notice'));
$response->assertStatus(200);
$response->assertSuccessful();
});
test('email can be verified', function () {
@ -90,3 +90,23 @@ test('already verified user visiting verification link is redirected without fir
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
Event::assertNotDispatched(Verified::class);
});
test('unverified user is redirected to verification notice from protected routes', function (string $route) {
$user = User::factory()->unverified()->onboarded()->create();
$this->actingAs($user)->get(route($route))
->assertRedirect(route('verification.notice'));
})->with([
'subscribe',
'onboarding',
'dashboard',
]);
test('verified user is not redirected to verification notice', function () {
$user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->get(route('subscribe'));
expect($response->headers->get('Location'))
->not->toBe(route('verification.notice'));
});

View File

@ -1,11 +1,14 @@
<?php
use App\Models\User;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
$response->assertStatus(200);
$response->assertSuccessful();
});
test('new users can register', function () {
@ -21,3 +24,33 @@ test('new users can register', function () {
$this->assertAuthenticated();
$response->assertRedirect(route('onboarding', absolute: false));
});
test('new users receive a verification email on registration', function () {
Notification::fake();
$this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$user = User::where('email', 'test@example.com')->first();
Notification::assertSentTo($user, VerifyEmailNotification::class);
});
test('new users are not verified after registration', function () {
Queue::fake();
$this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$user = User::where('email', 'test@example.com')->first();
expect($user->hasVerifiedEmail())->toBeFalse();
});

View File

@ -1,7 +1,7 @@
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Support\Facades\Notification;
test('sends verification notification', function () {
@ -15,7 +15,7 @@ test('sends verification notification', function () {
->post(route('verification.send'))
->assertRedirect(route('home'));
Notification::assertSentTo($user, VerifyEmail::class);
Notification::assertSentTo($user, VerifyEmailNotification::class);
});
test('does not send verification notification if email is verified', function () {