diff --git a/.env.example b/.env.example index a6ae433..e0b54a9 100644 --- a/.env.example +++ b/.env.example @@ -51,10 +51,10 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_SCHEME=null MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" diff --git a/app/Http/Controllers/DashboardRedirectController.php b/app/Http/Controllers/DashboardRedirectController.php new file mode 100644 index 0000000..fcfefd4 --- /dev/null +++ b/app/Http/Controllers/DashboardRedirectController.php @@ -0,0 +1,26 @@ +redirectPathForCurrentOrganization($request, '/dashboard')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2e11bb6..5c6ec4a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,9 +2,9 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Concerns\HasOrganizations; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Collection; @@ -36,7 +36,7 @@ */ #[Fillable(['name', 'email', 'password', 'current_organization_id'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] -class User extends Authenticatable implements PasskeyUser +class User extends Authenticatable implements MustVerifyEmail, PasskeyUser { /** @use HasFactory */ use HasFactory, HasOrganizations, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; diff --git a/routes/settings.php b/routes/settings.php index f252aac..afb2c32 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -16,7 +16,7 @@ Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); }); -Route::middleware(['auth'])->group(function () { +Route::middleware(['auth', 'verified'])->group(function () { Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::get('settings/security', [SecurityController::class, 'edit']) diff --git a/routes/web.php b/routes/web.php index e793ad1..4368a06 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('home'); Route::get('onboarding', OnboardingController::class) - ->middleware(['auth']) + ->middleware(['auth', 'verified']) ->name('onboarding'); +// Every real dashboard is under an organization. This is the bare path +// Fortify redirects to from config('fortify.home'). +Route::get('dashboard', DashboardRedirectController::class) + ->middleware(['auth', 'verified']) + ->name('dashboard.redirect'); + Route::prefix('{current_organization}') - ->middleware(['auth', EnsureOrganizationMembership::class]) + ->middleware(['auth', 'verified', EnsureOrganizationMembership::class]) ->scopeBindings() ->group(function () { Route::get('dashboard', DashboardController::class)->name('dashboard'); @@ -45,10 +52,12 @@ }); Route::get('invitations', [OrganizationInvitationController::class, 'index']) - ->middleware(['auth']) + ->middleware(['auth', 'verified']) ->name('invitations.index'); -Route::middleware(['auth'])->group(function () { +// Accepting an invitation joins an account to an organization that was +// invited by email, so the address has to be proven before it is used. +Route::middleware(['auth', 'verified'])->group(function () { Route::post('invitations/{invitation}/accept', [OrganizationInvitationController::class, 'accept'])->name('invitations.accept'); Route::delete('invitations/{invitation}', [OrganizationInvitationController::class, 'decline'])->name('invitations.decline'); }); diff --git a/tests/Browser/Auth/AuthenticationTest.php b/tests/Browser/Auth/AuthenticationTest.php index acdd60c..71d3cae 100644 --- a/tests/Browser/Auth/AuthenticationTest.php +++ b/tests/Browser/Auth/AuthenticationTest.php @@ -2,6 +2,7 @@ use App\Models\Organization; use App\Models\User; +use Illuminate\Support\Facades\URL; test('a user signs in through the login form and lands on the dashboard of their organization', function () { $organization = Organization::factory()->create(['name' => 'Toys Online Group']); @@ -34,3 +35,24 @@ $this->assertGuest(); }); + +test('an unverified user is held at the verification prompt until they verify', function () { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user); + + visit(route('dashboard', $user->currentOrganization)) + ->assertPathIs('/email/verify') + ->assertSee('Resend verification email') + ->assertNoJavaScriptErrors(); + + // The link the verification mail carries. + visit(URL::temporarySignedRoute('verification.verify', now()->addHour(), [ + 'id' => $user->id, + 'hash' => sha1($user->email), + ])) + ->assertPathIs("/{$user->currentOrganization->slug}/dashboard") + ->assertNoJavaScriptErrors(); + + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); diff --git a/tests/Feature/Auth/VerifiedAccessTest.php b/tests/Feature/Auth/VerifiedAccessTest.php new file mode 100644 index 0000000..fe0833a --- /dev/null +++ b/tests/Feature/Auth/VerifiedAccessTest.php @@ -0,0 +1,80 @@ +unverified()->create(); + + $this + ->actingAs($user) + ->get($route($user)) + ->assertRedirect(route('verification.notice')); +})->with([ + // The user's own organization, so this is the verification check + // answering and not the membership check behind it. + 'dashboard' => [fn (User $user) => route('dashboard', $user->currentOrganization)], + 'reports' => [fn (User $user) => route('reports.index', $user->currentOrganization)], + 'onboarding' => [fn () => route('onboarding')], + 'invitations' => [fn () => route('invitations.index')], + 'appearance settings' => [fn () => route('appearance.edit')], + 'organization list' => [fn () => route('organizations.index')], + 'bare dashboard path' => [fn () => '/dashboard'], +]); + +test('an unverified user can still reach the profile page to correct their address', function () { + $user = User::factory()->unverified()->create(); + + $this + ->actingAs($user) + ->get(route('profile.edit')) + ->assertOk(); +}); + +test('a verified user reaches their own dashboard', function () { + $user = User::factory()->create(); + + $this + ->actingAs($user) + ->get(route('dashboard', $user->currentOrganization)) + ->assertOk(); +}); + +test('registering sends the verification mail and leaves the account unverified', function () { + Notification::fake(); + + $this->post(route('register.store'), [ + 'name' => 'Rita Hasler', + 'email' => 'rita@example.com', + 'password' => 'password-that-is-long-enough', + 'password_confirmation' => 'password-that-is-long-enough', + ]); + + $user = User::whereEmail('rita@example.com')->sole(); + + expect($user->hasVerifiedEmail())->toBeFalse(); + + Notification::assertSentTo( + $user, + VerifyEmail::class, + ); +}); + +test('the bare dashboard path lands on the current organization', function () { + $user = User::factory()->create(); + + $this + ->actingAs($user) + ->get('/dashboard') + ->assertRedirect("/{$user->currentOrganization->slug}/dashboard"); +}); + +test('the bare dashboard path sends a user with no organization to onboarding', function () { + $user = User::factory()->withoutOrganization()->create(); + + $this + ->actingAs($user) + ->get('/dashboard') + ->assertRedirect(route('onboarding', absolute: false)); +});