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