diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index 5e792268..b65e3816 100644
--- a/app/Actions/Fortify/CreateNewUser.php
+++ b/app/Actions/Fortify/CreateNewUser.php
@@ -3,6 +3,7 @@
namespace App\Actions\Fortify;
use App\Models\User;
+use App\Services\LandingAuthOverrideService;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
@@ -11,6 +12,8 @@ class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
+ public function __construct(private LandingAuthOverrideService $landingAuthOverrideService) {}
+
/**
* Validate and create a newly registered user.
*
@@ -18,7 +21,7 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
- if (config('landing.hide_auth_buttons', false) && ! request()->boolean('force')) {
+ if ($this->landingAuthOverrideService->authButtonsHidden(request())) {
abort(404);
}
diff --git a/app/Console/Commands/GenerateLandingAuthLinkCommand.php b/app/Console/Commands/GenerateLandingAuthLinkCommand.php
new file mode 100644
index 00000000..0bee32ae
--- /dev/null
+++ b/app/Console/Commands/GenerateLandingAuthLinkCommand.php
@@ -0,0 +1,36 @@
+option('days'), FILTER_VALIDATE_INT);
+
+ if ($days === false || $days < 1) {
+ $this->error('Days must be a positive integer.');
+
+ return self::FAILURE;
+ }
+
+ $url = $this->landingAuthOverrideService->generateSignedUrl($days);
+
+ $this->line($url);
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
index c9dff507..8ba7d090 100644
--- a/app/Providers/FortifyServiceProvider.php
+++ b/app/Providers/FortifyServiceProvider.php
@@ -8,6 +8,7 @@ use App\Actions\Fortify\ResetUserPassword;
use App\Http\Responses\LoginResponse;
use App\Http\Responses\TwoFactorLoginResponse;
use App\Models\User;
+use App\Services\LandingAuthOverrideService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@@ -57,9 +58,12 @@ class FortifyServiceProvider extends ServiceProvider
*/
private function configureViews(): void
{
+ $landingAuthOverrideService = app(LandingAuthOverrideService::class);
+
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
- 'canRegister' => Features::enabled(Features::registration()) && ! config('landing.hide_auth_buttons', false),
+ 'canRegister' => Features::enabled(Features::registration())
+ && ! $landingAuthOverrideService->authButtonsHidden($request),
'status' => $request->session()->get('status'),
]));
@@ -77,7 +81,7 @@ class FortifyServiceProvider extends ServiceProvider
]));
Fortify::registerView(fn (Request $request) => Inertia::render('auth/register', [
- 'hideAuthButtons' => config('landing.hide_auth_buttons', false) && ! $request->boolean('force'),
+ 'hideAuthButtons' => $landingAuthOverrideService->authButtonsHidden($request),
'forcedRegistration' => $request->boolean('force'),
]));
diff --git a/app/Services/LandingAuthOverrideService.php b/app/Services/LandingAuthOverrideService.php
new file mode 100644
index 00000000..a35b10a0
--- /dev/null
+++ b/app/Services/LandingAuthOverrideService.php
@@ -0,0 +1,175 @@
+allowsAuthentication($request);
+ }
+
+ public function allowsAuthentication(Request $request): bool
+ {
+ if (! config('landing.hide_auth_buttons', false)) {
+ return true;
+ }
+
+ if ($request->boolean('force')) {
+ return true;
+ }
+
+ if ($this->hasOverrideCookie($request)) {
+ return true;
+ }
+
+ if (! $this->hasValidSignedOverride($request)) {
+ return false;
+ }
+
+ $this->queueOverrideCookie();
+
+ return true;
+ }
+
+ public function generateSignedUrl(int $days): string
+ {
+ $path = $this->signedPath(now()->addDays($days));
+
+ return rtrim(config('app.url'), '/').$path;
+ }
+
+ public function signedPath(\DateTimeInterface|\DateInterval|int $expiration): string
+ {
+ $parameters = [
+ $this->queryParameter() => 1,
+ 'expires' => $this->availableAt($expiration),
+ ];
+
+ ksort($parameters);
+
+ $signature = hash_hmac('sha256', $this->originalString('/', $parameters), $this->signingKey());
+
+ return '/?'.Arr::query($parameters + ['signature' => $signature]);
+ }
+
+ private function queueOverrideCookie(): void
+ {
+ Cookie::queue($this->makeOverrideCookie());
+ }
+
+ private function makeOverrideCookie(): HttpFoundationCookie
+ {
+ return Cookie::make(
+ $this->cookieName(),
+ '1',
+ (int) config('landing.auth_override.cookie_minutes', 60 * 24 * 7),
+ '/',
+ config('session.domain'),
+ config('session.secure'),
+ true,
+ false,
+ config('session.same_site', 'lax'),
+ );
+ }
+
+ private function hasValidSignedOverride(Request $request): bool
+ {
+ if (! $request->boolean($this->queryParameter())) {
+ return false;
+ }
+
+ if ($this->signatureHasExpired($request)) {
+ return false;
+ }
+
+ $parameters = $request->query();
+ unset($parameters['signature']);
+
+ foreach (config('landing.auth_override.ignore_signature_query_parameters', []) as $ignoredParameter) {
+ unset($parameters[$ignoredParameter]);
+ }
+
+ ksort($parameters);
+
+ $signature = (string) $request->query('signature', '');
+
+ return hash_equals(
+ hash_hmac('sha256', $this->originalString($request->getPathInfo(), $parameters), $this->signingKey()),
+ $signature,
+ );
+ }
+
+ private function signatureHasExpired(Request $request): bool
+ {
+ $expires = $request->query('expires');
+
+ if (! is_numeric($expires)) {
+ return true;
+ }
+
+ return (int) $expires < now()->getTimestamp();
+ }
+
+ /**
+ * @param array $parameters
+ */
+ private function originalString(string $path, array $parameters): string
+ {
+ $normalizedPath = $path === '' ? '/' : $path;
+ $query = Arr::query($parameters);
+
+ return rtrim($normalizedPath.'?'.$query, '?');
+ }
+
+ private function signingKey(): string
+ {
+ $key = app('config')->get('app.key');
+
+ if (! is_string($key) || $key === '') {
+ $url = URL::to('/');
+
+ throw new \RuntimeException("Unable to sign landing auth URL for {$url} without app.key.");
+ }
+
+ return $key;
+ }
+
+ private function availableAt(\DateTimeInterface|\DateInterval|int $delay): int
+ {
+ if ($delay instanceof \DateTimeInterface) {
+ return $delay->getTimestamp();
+ }
+
+ if ($delay instanceof \DateInterval) {
+ return now()->add($delay)->getTimestamp();
+ }
+
+ return now()->addSeconds($delay)->getTimestamp();
+ }
+
+ private function hasOverrideCookie(Request $request): bool
+ {
+ return filter_var($request->cookie($this->cookieName()), FILTER_VALIDATE_BOOL);
+ }
+
+ private function queryParameter(): string
+ {
+ return (string) config('landing.auth_override.query_parameter', 'signup');
+ }
+
+ private function cookieName(): string
+ {
+ return (string) config('landing.auth_override.cookie_name', 'landing_auth_override');
+ }
+}
diff --git a/config/landing.php b/config/landing.php
index 0875b458..ce0212c4 100644
--- a/config/landing.php
+++ b/config/landing.php
@@ -8,10 +8,38 @@ return [
|--------------------------------------------------------------------------
|
| When set to true, this will hide authentication buttons (login/register)
- | from the landing page and return 404 for auth routes.
+ | from the landing page and block registration unless a valid override is
+ | present.
|
*/
'hide_auth_buttons' => env('HIDE_AUTH_BUTTONS', false),
+ /*
+ |--------------------------------------------------------------------------
+ | Authentication Override
+ |--------------------------------------------------------------------------
+ |
+ | Temporary signed landing links can unlock authentication buttons for a
+ | limited time. Once a user opens a valid signed link, a secure cookie is
+ | stored so the same browser session can continue into the installed PWA.
+ |
+ */
+
+ 'auth_override' => [
+ 'query_parameter' => 'signup',
+ 'cookie_name' => 'landing_auth_override',
+ 'cookie_minutes' => 60 * 24 * 7,
+ 'ignore_signature_query_parameters' => [
+ 'lang',
+ 'utm_source',
+ 'utm_medium',
+ 'utm_campaign',
+ 'utm_term',
+ 'utm_content',
+ 'gclid',
+ 'fbclid',
+ ],
+ ],
+
];
diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx
index 71a241ca..46e66340 100644
--- a/resources/js/pages/welcome.tsx
+++ b/resources/js/pages/welcome.tsx
@@ -2713,11 +2713,17 @@ export default function Welcome({
)}
-
-
-
+ {hideAuthButtons ? (
+
+ ) : isMobile ? (
+
+ ) : (
+
+
+
+ )}
diff --git a/routes/web.php b/routes/web.php
index e2734730..c7da1533 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -20,13 +20,15 @@ use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\TransactionController;
use App\Http\Controllers\UserLeadController;
use App\Models\Bank;
+use App\Services\LandingAuthOverrideService;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Fortify\Features;
-Route::get('/', function () {
- $user = request()->user();
+Route::get('/', function (Request $request, LandingAuthOverrideService $landingAuthOverrideService) {
+ $user = $request->user();
$popularBanks = Cache::remember('popular-banks', now()->addDay(), function () {
return Bank::query()
@@ -53,9 +55,11 @@ Route::get('/', function () {
->toArray();
});
+ $hideAuthButtons = $landingAuthOverrideService->authButtonsHidden($request);
+
return Inertia::render('welcome', [
- 'canRegister' => Features::enabled(Features::registration()),
- 'hideAuthButtons' => config('landing.hide_auth_buttons', false),
+ 'canRegister' => Features::enabled(Features::registration()) && ! $hideAuthButtons,
+ 'hideAuthButtons' => $hideAuthButtons,
'popularBanks' => $popularBanks,
]);
})->name('home');
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php
index a4b7c89b..e8ef4e48 100644
--- a/tests/Feature/Auth/RegistrationTest.php
+++ b/tests/Feature/Auth/RegistrationTest.php
@@ -6,6 +6,10 @@ use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia as Assert;
+beforeEach(function () {
+ config(['landing.hide_auth_buttons' => false]);
+});
+
test('registration screen can be rendered', function () {
$response = $this->withoutVite()->get(route('register'));
diff --git a/tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php b/tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php
new file mode 100644
index 00000000..0a83ea2d
--- /dev/null
+++ b/tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php
@@ -0,0 +1,34 @@
+ true]);
+
+ Artisan::call('landing:auth-link', ['--days' => 3]);
+
+ $output = trim(Artisan::output());
+ $query = [];
+ parse_str(parse_url($output, PHP_URL_QUERY) ?: '', $query);
+ $devUrl = 'https://dev.whisper.money.localhost:1355/?'.parse_url($output, PHP_URL_QUERY);
+
+ expect($query['signup'] ?? null)->toBe('1');
+ expect($query)->toHaveKeys(['expires', 'signature']);
+
+ $this->withoutVite()->get($devUrl)
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('welcome')
+ ->where('hideAuthButtons', false)
+ ->where('canRegister', true)
+ );
+});
+
+test('command fails for invalid expiration days', function () {
+ artisan('landing:auth-link', ['--days' => 0])
+ ->expectsOutputToContain('Days must be a positive integer.')
+ ->assertFailed();
+});
diff --git a/tests/Feature/LandingAuthOverrideTest.php b/tests/Feature/LandingAuthOverrideTest.php
new file mode 100644
index 00000000..0c4749af
--- /dev/null
+++ b/tests/Feature/LandingAuthOverrideTest.php
@@ -0,0 +1,63 @@
+ true]);
+});
+
+test('signed landing link unlocks auth buttons and queues override cookie', function () {
+ $signedUrl = 'https://dev.whisper.money.localhost:1355'
+ .app(LandingAuthOverrideService::class)->signedPath(now()->addHour())
+ .'&lang=es';
+
+ $this->withoutVite()->get($signedUrl)
+ ->assertOk()
+ ->assertCookie(config('landing.auth_override.cookie_name'))
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('welcome')
+ ->where('hideAuthButtons', false)
+ ->where('canRegister', true)
+ );
+});
+
+test('unsigned landing link keeps auth buttons hidden', function () {
+ $this->withoutVite()->get(route('home', ['signup' => 1]))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('welcome')
+ ->where('hideAuthButtons', true)
+ ->where('canRegister', false)
+ );
+});
+
+test('login page allows registration with the override cookie', function () {
+ $cookieName = config('landing.auth_override.cookie_name');
+
+ $this->withCookie($cookieName, '1')
+ ->withoutVite()
+ ->get(route('login'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('auth/login')
+ ->where('canRegister', true)
+ );
+});
+
+test('new users can register with the override cookie', function () {
+ Queue::fake();
+
+ $cookieName = config('landing.auth_override.cookie_name');
+
+ $response = $this->withCookie($cookieName, '1')->post(route('register.store'), [
+ 'name' => 'Signed Link User',
+ 'email' => 'signed@example.com',
+ 'password' => 'password',
+ 'password_confirmation' => 'password',
+ ]);
+
+ $this->assertAuthenticated();
+ $response->assertRedirect(route('onboarding', absolute: false));
+});