From c5dc3bd256430f7611471480a944ae680d585bea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 17:59:43 -0400 Subject: [PATCH] fix(web): exempt /register from auth guard so bootstrap admin can self-register The +layout.svelte auth guard only treated /login as public, so any unauthenticated visit to /register bounced to /login?returnTo=%2Fregister. The "Register" link on the login page therefore looped back to itself, making the first-user-becomes-admin bootstrap path (handleRegister + CreateUserFirstAdminRace) unreachable in production. Extracted isPublicRoute() into web/src/lib/auth/publicRoutes.ts so the public-route set has one source of truth and is unit-testable. Test file explicitly comment-tags /register as a #376 regression guard. Closes the last gap in user-mgmt umbrella #376. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/auth/publicRoutes.test.ts | 33 +++++++++++++++++++++++++++ web/src/lib/auth/publicRoutes.ts | 9 ++++++++ web/src/routes/+layout.svelte | 7 +++--- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/auth/publicRoutes.test.ts create mode 100644 web/src/lib/auth/publicRoutes.ts diff --git a/web/src/lib/auth/publicRoutes.test.ts b/web/src/lib/auth/publicRoutes.test.ts new file mode 100644 index 00000000..1210eca8 --- /dev/null +++ b/web/src/lib/auth/publicRoutes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; +import { isPublicRoute } from './publicRoutes'; + +describe('isPublicRoute', () => { + test('/login is public — login form must render to unauthenticated visitors', () => { + expect(isPublicRoute('/login')).toBe(true); + }); + + // Regression guard for #376: /register was previously bounced to /login + // by the layout guard, breaking bootstrap-admin self-registration on + // fresh deployments. Do NOT remove /register from the public set unless + // you have a deliberate replacement (e.g. invite-only landing page). + test('/register is public — bootstrap admin must reach the form', () => { + expect(isPublicRoute('/register')).toBe(true); + }); + + test('/ is gated', () => { + expect(isPublicRoute('/')).toBe(false); + }); + + test('/admin/users is gated', () => { + expect(isPublicRoute('/admin/users')).toBe(false); + }); + + test('/settings is gated', () => { + expect(isPublicRoute('/settings')).toBe(false); + }); + + test('paths that merely start with /login are not public', () => { + expect(isPublicRoute('/login/extra')).toBe(false); + expect(isPublicRoute('/loginx')).toBe(false); + }); +}); diff --git a/web/src/lib/auth/publicRoutes.ts b/web/src/lib/auth/publicRoutes.ts new file mode 100644 index 00000000..e79a9c31 --- /dev/null +++ b/web/src/lib/auth/publicRoutes.ts @@ -0,0 +1,9 @@ +// Routes that the +layout.svelte auth guard must NOT redirect away from +// when the visitor is unauthenticated. Bootstrap-admin self-registration +// (#376) requires /register to be reachable without a session — without +// /register here, the login page's "Register" link bounces back to /login. +const PUBLIC_ROUTES = new Set(['/login', '/register']); + +export function isPublicRoute(pathname: string): boolean { + return PUBLIC_ROUTES.has(pathname); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 7d397225..d8d0f19f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import { QueryClientProvider } from '@tanstack/svelte-query'; import { queryClient } from '$lib/query/client'; import { user } from '$lib/auth/store.svelte'; + import { isPublicRoute } from '$lib/auth/publicRoutes'; import Shell from '$lib/components/Shell.svelte'; import QueueDrawer from '$lib/components/QueueDrawer.svelte'; import ToastHost from '$lib/components/ToastHost.svelte'; @@ -27,11 +28,11 @@ // Reactive guard: runs every time page.url or user.value changes. $effect(() => { const path = page.url.pathname; - const isLogin = path === '/login'; - if (user.value === null && !isLogin) { + const isPublic = isPublicRoute(path); + if (user.value === null && !isPublic) { const returnTo = path + page.url.search; goto('/login?returnTo=' + encodeURIComponent(returnTo), { replaceState: true }); - } else if (user.value !== null && isLogin) { + } else if (user.value !== null && isPublic) { goto('/', { replaceState: true }); } });