fix: route new PWA guests to signup (#313)

## Summary
- redirect guest access to protected routes based on returning-user
cookie
- send new PWA guests to registration and returning guests to login
- persist returning-user cookie after register, login, and 2FA login
- keep explicit login links working via `force=1`

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/Auth/AuthenticationTest.php
tests/Feature/DashboardTest.php tests/Feature/Auth/RegistrationTest.php
This commit is contained in:
Víctor Falcón 2026-04-21 10:53:05 +01:00 committed by GitHub
parent 240fcf1703
commit 905edeb4a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 212 additions and 33 deletions

View File

@ -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])

View File

@ -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);
}

View File

@ -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)

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Laravel\Fortify\Features;
use Symfony\Component\HttpFoundation\Cookie as HttpFoundationCookie;
class AuthEntryPointService
{
private const COOKIE_NAME = 'whisper_money_returning_user';
private const COOKIE_MINUTES = 60 * 24 * 365 * 5;
public function __construct(private readonly LandingAuthOverrideService $landingAuthOverrideService) {}
/**
* @api
*/
public function guestRedirectRoute(Request $request): string
{
if (
$this->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'),
);
}
}

View File

@ -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(

View File

@ -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({
</Link>
) : (
<>
<Link href="/login">
<Link href={login({ query: { force: 1 } })}>
<Button
variant={'ghost'}
size="sm"
@ -227,7 +227,9 @@ export default function Header({
</Link>
) : (
<>
<Link href="/login">
<Link
href={login({ query: { force: 1 } })}
>
<Button
variant={'ghost'}
className="cursor-pointer"

View File

@ -33,7 +33,7 @@ export default function Register({
useEffect(() => {
if (hideAuthButtons) {
router.visit(login());
router.visit(login({ query: { force: 1 } }));
}
}, [hideAuthButtons]);
@ -153,7 +153,10 @@ export default function Register({
<div className="text-center text-sm text-muted-foreground">
{__('Already have an account?')}{' '}
<TextLink href={login()} tabIndex={6}>
<TextLink
href={login({ query: { force: 1 } })}
tabIndex={6}
>
{__('Log in')}
</TextLink>
</div>

View File

@ -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 () {

View File

@ -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 () {

View File

@ -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'));
});

View File

@ -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 () {

View File

@ -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 () {

View File

@ -1,11 +1,49 @@
<?php
use App\Models\User;
use Laravel\Fortify\Features;
beforeEach(function () {
config(['landing.hide_auth_buttons' => 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());

View File

@ -1,5 +1,9 @@
<?php
beforeEach(function () {
config(['landing.hide_auth_buttons' => 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'));
});

View File

@ -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 () {

View File

@ -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 () {

View File

@ -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 () {

View File

@ -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 () {

View File

@ -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'));
});

View File

@ -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 () {

View File

@ -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'));
});