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  ## 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:
parent
1500e5cd91
commit
370d388d99
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use App\Enums\DripEmailType;
|
use App\Enums\DripEmailType;
|
||||||
|
use App\Notifications\VerifyEmailNotification;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -15,7 +16,7 @@ use Laravel\Cashier\Billable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Pennant\Concerns\HasFeatures;
|
use Laravel\Pennant\Concerns\HasFeatures;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
@ -133,4 +134,9 @@ class User extends Authenticatable
|
||||||
{
|
{
|
||||||
return $this->email === config('app.demo.email');
|
return $this->email === config('app.demo.email');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sendEmailVerificationNotification(): void
|
||||||
|
{
|
||||||
|
$this->notify(new VerifyEmailNotification);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -12,9 +12,7 @@ it('can register a new user', function () {
|
||||||
->fill('password_confirmation', 'password123')
|
->fill('password_confirmation', 'password123')
|
||||||
->click('@register-user-button')
|
->click('@register-user-button')
|
||||||
->wait(2)
|
->wait(2)
|
||||||
->assertSee('Welcome to')
|
->assertPathIs('/email/verify')
|
||||||
->assertSee('Whisper Money')
|
|
||||||
->assertPathIs('/onboarding')
|
|
||||||
->assertNoJavascriptErrors();
|
->assertNoJavascriptErrors();
|
||||||
|
|
||||||
$this->assertDatabaseHas('users', [
|
$this->assertDatabaseHas('users', [
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use App\Models\User;
|
||||||
// Basic Redirect Tests
|
// Basic Redirect Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
it('redirects new registration to onboarding page', function () {
|
it('redirects new registration to email verification', function () {
|
||||||
$page = visit('/register');
|
$page = visit('/register');
|
||||||
|
|
||||||
$page->assertSee('Create an account')
|
$page->assertSee('Create an account')
|
||||||
|
|
@ -18,7 +18,7 @@ it('redirects new registration to onboarding page', function () {
|
||||||
->fill('password_confirmation', 'password123456')
|
->fill('password_confirmation', 'password123456')
|
||||||
->click('@register-user-button')
|
->click('@register-user-button')
|
||||||
->wait(3)
|
->wait(3)
|
||||||
->assertPathIs('/onboarding')
|
->assertPathIs('/email/verify')
|
||||||
->assertNoJavascriptErrors();
|
->assertNoJavascriptErrors();
|
||||||
|
|
||||||
$this->assertDatabaseHas('users', [
|
$this->assertDatabaseHas('users', [
|
||||||
|
|
@ -365,17 +365,15 @@ it('completes onboarding flow through account creation', function () {
|
||||||
// Create a bank for the account creation step
|
// Create a bank for the account creation step
|
||||||
Bank::factory()->create(['name' => 'Chase Bank']);
|
Bank::factory()->create(['name' => 'Chase Bank']);
|
||||||
|
|
||||||
$page = visit('/register');
|
$user = User::factory()->create([
|
||||||
|
'onboarded_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
// Step 1: Register
|
$this->actingAs($user);
|
||||||
$page->assertSee('Create an account')
|
|
||||||
->fill('name', 'E2E Test User')
|
$page = visit('/onboarding');
|
||||||
->fill('email', 'e2e-onboarding@example.com')
|
|
||||||
->fill('password', 'SecurePassword123!')
|
$page->assertPathIs('/onboarding')
|
||||||
->fill('password_confirmation', 'SecurePassword123!')
|
|
||||||
->click('@register-user-button')
|
|
||||||
->wait(3)
|
|
||||||
->assertPathIs('/onboarding')
|
|
||||||
->assertNoJavascriptErrors();
|
->assertNoJavascriptErrors();
|
||||||
|
|
||||||
// Step 2: Welcome
|
// Step 2: Welcome
|
||||||
|
|
@ -428,7 +426,7 @@ it('completes onboarding flow through account creation', function () {
|
||||||
->assertNoJavascriptErrors();
|
->assertNoJavascriptErrors();
|
||||||
|
|
||||||
// Verify user's encryption was set up
|
// Verify user's encryption was set up
|
||||||
$user = User::where('email', 'e2e-onboarding@example.com')->first();
|
$user->refresh();
|
||||||
expect($user->encryption_salt)->not->toBeNull();
|
expect($user->encryption_salt)->not->toBeNull();
|
||||||
|
|
||||||
// Verify account was created
|
// Verify account was created
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ test('email verification screen can be rendered', function () {
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get(route('verification.notice'));
|
$response = $this->actingAs($user)->get(route('verification.notice'));
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('email can be verified', function () {
|
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();
|
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||||
Event::assertNotDispatched(Verified::class);
|
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'));
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\VerifyEmailNotification;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
test('registration screen can be rendered', function () {
|
test('registration screen can be rendered', function () {
|
||||||
$response = $this->get(route('register'));
|
$response = $this->get(route('register'));
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('new users can register', function () {
|
test('new users can register', function () {
|
||||||
|
|
@ -21,3 +24,33 @@ test('new users can register', function () {
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
$response->assertRedirect(route('onboarding', absolute: false));
|
$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();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
use App\Notifications\VerifyEmailNotification;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
test('sends verification notification', function () {
|
test('sends verification notification', function () {
|
||||||
|
|
@ -15,7 +15,7 @@ test('sends verification notification', function () {
|
||||||
->post(route('verification.send'))
|
->post(route('verification.send'))
|
||||||
->assertRedirect(route('home'));
|
->assertRedirect(route('home'));
|
||||||
|
|
||||||
Notification::assertSentTo($user, VerifyEmail::class);
|
Notification::assertSentTo($user, VerifyEmailNotification::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not send verification notification if email is verified', function () {
|
test('does not send verification notification if email is verified', function () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue