diff --git a/app/Http/Responses/LoginResponse.php b/app/Http/Responses/LoginResponse.php index 87982eca..ded8f861 100644 --- a/app/Http/Responses/LoginResponse.php +++ b/app/Http/Responses/LoginResponse.php @@ -2,6 +2,7 @@ namespace App\Http\Responses; +use App\Services\AuthEntryPointService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; @@ -9,13 +10,15 @@ use Laravel\Fortify\Fortify; class LoginResponse implements LoginResponseContract { + public function __construct(private readonly AuthEntryPointService $authEntryPointService) {} + /** * Create an HTTP response that represents the object. */ public function toResponse($request): JsonResponse|RedirectResponse { - // Flash a session variable to indicate the user just logged in session()->flash('show_encryption_prompt', true); + $this->authEntryPointService->queueReturningUserCookie(); return $request->wantsJson() ? response()->json(['two_factor' => false]) diff --git a/app/Http/Responses/RegisterResponse.php b/app/Http/Responses/RegisterResponse.php index 7314fb55..f0533182 100644 --- a/app/Http/Responses/RegisterResponse.php +++ b/app/Http/Responses/RegisterResponse.php @@ -2,14 +2,19 @@ namespace App\Http\Responses; +use App\Services\AuthEntryPointService; use Illuminate\Http\JsonResponse; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; use Symfony\Component\HttpFoundation\Response; class RegisterResponse implements RegisterResponseContract { + public function __construct(private readonly AuthEntryPointService $authEntryPointService) {} + public function toResponse($request): Response { + $this->authEntryPointService->queueReturningUserCookie(); + if ($request->wantsJson()) { return new JsonResponse('', 201); } diff --git a/app/Http/Responses/TwoFactorLoginResponse.php b/app/Http/Responses/TwoFactorLoginResponse.php index 1d62c4c4..6cf31aee 100644 --- a/app/Http/Responses/TwoFactorLoginResponse.php +++ b/app/Http/Responses/TwoFactorLoginResponse.php @@ -2,6 +2,7 @@ namespace App\Http\Responses; +use App\Services\AuthEntryPointService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract; @@ -9,13 +10,15 @@ use Laravel\Fortify\Fortify; class TwoFactorLoginResponse implements TwoFactorLoginResponseContract { + public function __construct(private readonly AuthEntryPointService $authEntryPointService) {} + /** * Create an HTTP response that represents the object. */ public function toResponse($request): JsonResponse|RedirectResponse { - // Flash a session variable to indicate the user just logged in session()->flash('show_encryption_prompt', true); + $this->authEntryPointService->queueReturningUserCookie(); return $request->wantsJson() ? new JsonResponse('', 204) diff --git a/app/Services/AuthEntryPointService.php b/app/Services/AuthEntryPointService.php new file mode 100644 index 00000000..4265058f --- /dev/null +++ b/app/Services/AuthEntryPointService.php @@ -0,0 +1,58 @@ +hasAuthenticatedBefore($request) + || ! Features::enabled(Features::registration()) + || $this->landingAuthOverrideService->authButtonsHidden($request) + ) { + return route('login'); + } + + return route('register'); + } + + public function queueReturningUserCookie(): void + { + Cookie::queue($this->makeReturningUserCookie()); + } + + private function hasAuthenticatedBefore(Request $request): bool + { + return filter_var($request->cookie(self::COOKIE_NAME), FILTER_VALIDATE_BOOL); + } + + private function makeReturningUserCookie(): HttpFoundationCookie + { + return Cookie::make( + self::COOKIE_NAME, + '1', + self::COOKIE_MINUTES, + '/', + config('session.domain'), + config('session.secure'), + true, + false, + config('session.same_site', 'lax'), + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 41a83c47..58d435d1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,7 @@ use App\Http\Middleware\EnsureUserIsSubscribed; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\SetLocale; +use App\Services\AuthEntryPointService; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -21,6 +22,8 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->redirectGuestsTo(fn (Request $request) => app(AuthEntryPointService::class)->guestRedirectRoute($request)); + $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'chart-color-scheme']); $middleware->trustProxies( diff --git a/resources/js/components/partials/header.tsx b/resources/js/components/partials/header.tsx index cd8e013a..3bd7f3bf 100644 --- a/resources/js/components/partials/header.tsx +++ b/resources/js/components/partials/header.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils'; -import { dashboard } from '@/routes'; +import { dashboard, login } from '@/routes'; import { type SharedData } from '@/types'; import { __ } from '@/utils/i18n'; import { Link, usePage } from '@inertiajs/react'; @@ -119,7 +119,7 @@ export default function Header({ ) : ( <> - + ) : ( <> - + { if (hideAuthButtons) { - router.visit(login()); + router.visit(login({ query: { force: 1 } })); } }, [hideAuthButtons]); @@ -153,7 +153,10 @@ export default function Register({ {__('Already have an account?')}{' '} - + {__('Log in')} diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php index cb80f179..dd64bb37 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -11,22 +11,24 @@ use App\Models\RealEstateDetail; use App\Models\User; beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); + $this->user = User::factory()->onboarded()->create(); $this->actingAs($this->user); }); -test('guests are redirected to the login page for accounts index', function () { +test('guests are redirected to the registration page for accounts index', function () { auth()->logout(); - $this->get(route('accounts.list'))->assertRedirect(route('login')); + $this->get(route('accounts.list'))->assertRedirect(route('register')); }); -test('guests are redirected to the login page for account show', function () { +test('guests are redirected to the registration page for account show', function () { auth()->logout(); $account = Account::factory()->create(['user_id' => $this->user->id]); - $this->get(route('accounts.show', $account))->assertRedirect(route('login')); + $this->get(route('accounts.show', $account))->assertRedirect(route('register')); }); test('authenticated users can visit the accounts index', function () { diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index dbb42976..a75928c7 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -10,6 +10,23 @@ test('login screen can be rendered', function () { $response->assertStatus(200); }); +test('login screen can be rendered with an intended destination in session', function () { + $response = $this + ->withSession(['url.intended' => route('dashboard')]) + ->get(route('login')); + + $response->assertSuccessful(); +}); + +test('login screen stays available for returning users with an intended destination', function () { + $response = $this + ->withCookie('whisper_money_returning_user', '1') + ->withSession(['url.intended' => route('dashboard')]) + ->get(route('login')); + + $response->assertSuccessful(); +}); + test('users can authenticate using the login screen', function () { $user = User::factory()->withoutTwoFactor()->create(); @@ -19,7 +36,9 @@ test('users can authenticate using the login screen', function () { ]); $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); + $response + ->assertRedirect(route('dashboard', absolute: false)) + ->assertCookie('whisper_money_returning_user'); }); test('users with two factor enabled are redirected to two factor challenge', function () { diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index c950e3eb..cf8dad6f 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -3,6 +3,10 @@ use App\Models\User; use Inertia\Testing\AssertableInertia as Assert; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('confirm password screen can be rendered', function () { $user = User::factory()->create(); @@ -18,5 +22,5 @@ test('confirm password screen can be rendered', function () { test('password confirmation requires authentication', function () { $response = $this->get(route('password.confirm')); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index e8ef4e48..ab1411d9 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -56,7 +56,9 @@ test('new users can register with force query when auth buttons are hidden', fun ]); $this->assertAuthenticated(); - $response->assertRedirect(route('onboarding', absolute: false)); + $response + ->assertRedirect(route('onboarding', absolute: false)) + ->assertCookie('whisper_money_returning_user'); }); test('new users can register', function () { @@ -70,7 +72,9 @@ test('new users can register', function () { ]); $this->assertAuthenticated(); - $response->assertRedirect(route('onboarding', absolute: false)); + $response + ->assertRedirect(route('onboarding', absolute: false)) + ->assertCookie('whisper_money_returning_user'); }); test('new users store their detected timezone on registration', function () { diff --git a/tests/Feature/CashflowPageTest.php b/tests/Feature/CashflowPageTest.php index 1ad36781..8255d598 100644 --- a/tests/Feature/CashflowPageTest.php +++ b/tests/Feature/CashflowPageTest.php @@ -3,8 +3,12 @@ use App\Models\User; use Inertia\Testing\AssertableInertia; -test('guests are redirected to login', function () { - $this->get(route('cashflow'))->assertRedirect(route('login')); +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + +test('guests are redirected to registration', function () { + $this->get(route('cashflow'))->assertRedirect(route('register')); }); test('period prop is null when no query param given', function () { diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 22d5d728..6473161e 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,11 +1,49 @@ false]); +}); + +test('new guests are redirected to the registration page', function () { + $this->get(route('dashboard'))->assertRedirect(route('register')); +}); + +test('returning guests are redirected to the login page', function () { + $this + ->withCookie('whisper_money_returning_user', '1') + ->get(route('dashboard')) + ->assertRedirect(route('login')); +}); + +test('new guests are redirected to the login page when registration is disabled', function () { + config([ + 'fortify.features' => array_values(array_filter( + config('fortify.features'), + fn (string $feature): bool => $feature !== Features::registration(), + )), + ]); -test('guests are redirected to the login page', function () { $this->get(route('dashboard'))->assertRedirect(route('login')); }); +test('new guests are redirected to the login page when auth buttons are hidden', function () { + config(['landing.hide_auth_buttons' => true]); + + $this->get(route('dashboard'))->assertRedirect(route('login')); +}); + +test('new guests with landing auth override are redirected to the registration page', function () { + config(['landing.hide_auth_buttons' => true]); + + $this + ->withCookie(config('landing.auth_override.cookie_name'), '1') + ->get(route('dashboard')) + ->assertRedirect(route('register')); +}); + test('authenticated users can visit the dashboard', function () { $this->actingAs(User::factory()->onboarded()->create()); diff --git a/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php b/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php index 091165b4..8fec4865 100644 --- a/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php +++ b/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php @@ -1,5 +1,9 @@ false]); +}); + test('guests cannot access institutions route', function () { $this->getJson('/open-banking/institutions?country=ES') ->assertUnauthorized(); @@ -14,10 +18,10 @@ test('guests cannot access authorize route', function () { test('guests are redirected away from callback route', function () { $this->get('/open-banking/callback?code=test') - ->assertRedirect(route('login')); + ->assertRedirect(route('register')); }); test('guests are redirected away from connections index', function () { $this->get('/settings/connections') - ->assertRedirect(route('login')); + ->assertRedirect(route('register')); }); diff --git a/tests/Feature/Settings/CategoryTest.php b/tests/Feature/Settings/CategoryTest.php index 95f16641..35a7d6b0 100644 --- a/tests/Feature/Settings/CategoryTest.php +++ b/tests/Feature/Settings/CategoryTest.php @@ -7,6 +7,10 @@ use App\Models\Category; use App\Models\User; use Illuminate\Database\UniqueConstraintViolationException; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('authenticated users can view their categories', function () { $user = User::factory()->create(); $categories = Category::factory()->count(3)->create(['user_id' => $user->id]); @@ -256,10 +260,10 @@ test('users cannot delete categories they do not own', function () { test('guests cannot access category management', function () { $response = $this->get(route('categories.index')); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); $response = $this->post(route('categories.store'), []); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); test('default categories are created when user registers', function () { diff --git a/tests/Feature/Settings/ChartColorSchemeTest.php b/tests/Feature/Settings/ChartColorSchemeTest.php index 2a6396a8..45955bda 100644 --- a/tests/Feature/Settings/ChartColorSchemeTest.php +++ b/tests/Feature/Settings/ChartColorSchemeTest.php @@ -4,6 +4,10 @@ use App\Enums\ChartColorScheme; use App\Models\User; use App\Models\UserSetting; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('chart color scheme can be updated', function () { $user = User::factory()->create(); @@ -35,7 +39,7 @@ test('chart color scheme requires authentication', function () { 'chart_color_scheme' => 'blue', ]); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); test('chart color scheme creates setting when none exists', function () { diff --git a/tests/Feature/Settings/NetWorthChartLoanPreferenceTest.php b/tests/Feature/Settings/NetWorthChartLoanPreferenceTest.php index 1fc6ec3a..8ecb9020 100644 --- a/tests/Feature/Settings/NetWorthChartLoanPreferenceTest.php +++ b/tests/Feature/Settings/NetWorthChartLoanPreferenceTest.php @@ -3,6 +3,10 @@ use App\Models\User; use App\Models\UserSetting; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('net worth chart loan preference can be updated', function () { $user = User::factory()->create(); @@ -34,7 +38,7 @@ test('net worth chart loan preference requires authentication', function () { 'include_loans_in_net_worth_chart' => false, ]); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); test('net worth chart loan preference creates setting when none exists', function () { diff --git a/tests/Feature/Settings/NetWorthChartRealEstatePreferenceTest.php b/tests/Feature/Settings/NetWorthChartRealEstatePreferenceTest.php index 5fb0de3d..dd9c59f8 100644 --- a/tests/Feature/Settings/NetWorthChartRealEstatePreferenceTest.php +++ b/tests/Feature/Settings/NetWorthChartRealEstatePreferenceTest.php @@ -3,6 +3,10 @@ use App\Models\User; use App\Models\UserSetting; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('net worth chart real estate preference can be updated', function () { $user = User::factory()->create(); @@ -34,7 +38,7 @@ test('net worth chart real estate preference requires authentication', function 'include_real_estate_in_net_worth_chart' => false, ]); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); test('net worth chart real estate preference creates setting when none exists', function () { diff --git a/tests/Feature/Settings/TimezoneTest.php b/tests/Feature/Settings/TimezoneTest.php index 7301ebe9..b281b682 100644 --- a/tests/Feature/Settings/TimezoneTest.php +++ b/tests/Feature/Settings/TimezoneTest.php @@ -2,6 +2,10 @@ use App\Models\User; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('timezone can be backfilled for authenticated users without one', function () { $user = User::factory()->create(['timezone' => null]); @@ -42,5 +46,5 @@ test('timezone backfill requires authentication', function () { 'timezone' => 'Europe/Madrid', ]); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index 2db44b78..16f71af2 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -10,13 +10,16 @@ use App\Models\User; use Illuminate\Http\RedirectResponse; beforeEach(function () { - config(['subscriptions.enabled' => true]); + config([ + 'landing.hide_auth_buttons' => false, + 'subscriptions.enabled' => true, + ]); }); test('guests cannot access subscription pages', function () { - $this->get(route('subscribe'))->assertRedirect(route('login')); - $this->get(route('subscribe.checkout'))->assertRedirect(route('login')); - $this->get(route('subscribe.success'))->assertRedirect(route('login')); + $this->get(route('subscribe'))->assertRedirect(route('register')); + $this->get(route('subscribe.checkout'))->assertRedirect(route('register')); + $this->get(route('subscribe.success'))->assertRedirect(route('register')); }); test('users without subscription are redirected to paywall when accessing protected routes', function () { diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index 6bb15d59..7015ccc4 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -12,10 +12,14 @@ use App\Services\BudgetTransactionService; use function Pest\Laravel\actingAs; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('guests cannot access transactions page', function () { $response = $this->get(route('transactions.index')); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); test('authenticated users can access transactions page', function () { @@ -76,7 +80,7 @@ test('authenticated users can access categorize transactions page', function () test('guests cannot access categorize transactions page', function () { $response = $this->get(route('transactions.categorize')); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); }); test('users can update their own transaction category', function () { @@ -765,5 +769,5 @@ test('categorize page does not return transactions from other users', function ( test('guests are redirected from categorize page', function () { $response = $this->get(route('transactions.categorize')); - $response->assertRedirect(route('login')); + $response->assertRedirect(route('register')); });