diff --git a/app/Models/User.php b/app/Models/User.php index 6106e585..3e4586a1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); + } } diff --git a/app/Notifications/VerifyEmailNotification.php b/app/Notifications/VerifyEmailNotification.php new file mode 100644 index 00000000..87aa0d50 --- /dev/null +++ b/app/Notifications/VerifyEmailNotification.php @@ -0,0 +1,22 @@ +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, + ]); + } +} diff --git a/resources/views/mail/verify-email.blade.php b/resources/views/mail/verify-email.blade.php new file mode 100644 index 00000000..96282349 --- /dev/null +++ b/resources/views/mail/verify-email.blade.php @@ -0,0 +1,21 @@ + +# 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. + + +Verify Email Address + + +If you didn't create a Whisper Money account, you can safely ignore this email. + +Best,
+Víctor F,
+Founder of Whisper Money + + +If you're having trouble clicking the "Verify Email Address" button, copy and paste the URL below into your web browser: [{{ $verificationUrl }}]({{ $verificationUrl }}) + +
diff --git a/storage/videos/ac0945c527f014b9cd657f18c911f496.webm b/storage/videos/ac0945c527f014b9cd657f18c911f496.webm new file mode 100644 index 00000000..9601175d Binary files /dev/null and b/storage/videos/ac0945c527f014b9cd657f18c911f496.webm differ diff --git a/storage/videos/email-verification-page.png b/storage/videos/email-verification-page.png new file mode 100644 index 00000000..dad4b718 Binary files /dev/null and b/storage/videos/email-verification-page.png differ diff --git a/tests/Browser/AuthenticationTest.php b/tests/Browser/AuthenticationTest.php index a3515334..464db8a8 100644 --- a/tests/Browser/AuthenticationTest.php +++ b/tests/Browser/AuthenticationTest.php @@ -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', [ diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 5d579cc5..bf380192 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -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 diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 67e8ef08..81139ef6 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -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')); +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 66293ad6..7b76ba19 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -1,11 +1,14 @@ 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(); +}); diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php index 90f75af9..b664e372 100644 --- a/tests/Feature/Auth/VerificationNotificationTest.php +++ b/tests/Feature/Auth/VerificationNotificationTest.php @@ -1,7 +1,7 @@ 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 () {