From c7b1e3af33dae6a20930d12ddd8f45904474ebb1 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Sat, 22 Aug 2026 20:16:40 -0700 Subject: [PATCH] feat(web): protect application routes --- .../src/features/auth/guest-session.test.ts | 12 +- apps/web/src/features/auth/guest-session.ts | 4 + .../dashboard/application-shell.test.tsx | 15 +- .../dashboard-access-boundary.test.tsx | 121 +++++++++++++++++ .../dashboard/dashboard-access-boundary.tsx | 58 ++++++++ .../features/dashboard/section-landing.tsx | 11 +- apps/web/src/routeTree.gen.ts | 128 +++++++++++------- .../{dashboard.tsx => _app.dashboard.tsx} | 4 +- .../routes/{history.tsx => _app.history.tsx} | 4 +- .../{routines.tsx => _app.routines.tsx} | 4 +- .../{settings.tsx => _app.settings.tsx} | 4 +- apps/web/src/routes/_app.tsx | 19 +++ 12 files changed, 315 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx create mode 100644 apps/web/src/features/dashboard/dashboard-access-boundary.tsx rename apps/web/src/routes/{dashboard.tsx => _app.dashboard.tsx} (58%) rename apps/web/src/routes/{history.tsx => _app.history.tsx} (59%) rename apps/web/src/routes/{routines.tsx => _app.routines.tsx} (59%) rename apps/web/src/routes/{settings.tsx => _app.settings.tsx} (59%) create mode 100644 apps/web/src/routes/_app.tsx diff --git a/apps/web/src/features/auth/guest-session.test.ts b/apps/web/src/features/auth/guest-session.test.ts index b739cd2..c096e8e 100644 --- a/apps/web/src/features/auth/guest-session.test.ts +++ b/apps/web/src/features/auth/guest-session.test.ts @@ -1,7 +1,11 @@ // Verifies the temporary guest marker can be started and cleared by the welcome flow. import { afterEach, describe, expect, it } from 'vitest'; -import { clearGuestSession, startGuestSession } from './guest-session'; +import { + clearGuestSession, + isGuestSession, + startGuestSession, +} from './guest-session'; afterEach(() => { sessionStorage.clear(); @@ -9,11 +13,13 @@ afterEach(() => { describe('guest session', () => { it('can clear guest mode when the welcome page loads', () => { + expect(isGuestSession()).toBe(false); + startGuestSession(); - expect(sessionStorage.getItem('goforlift.guest')).toBe('true'); + expect(isGuestSession()).toBe(true); clearGuestSession(); - expect(sessionStorage.getItem('goforlift.guest')).toBeNull(); + expect(isGuestSession()).toBe(false); }); }); diff --git a/apps/web/src/features/auth/guest-session.ts b/apps/web/src/features/auth/guest-session.ts index 107a8b0..25b7390 100644 --- a/apps/web/src/features/auth/guest-session.ts +++ b/apps/web/src/features/auth/guest-session.ts @@ -5,6 +5,10 @@ export function startGuestSession() { sessionStorage.setItem(GUEST_SESSION_KEY, 'true'); } +export function isGuestSession() { + return sessionStorage.getItem(GUEST_SESSION_KEY) === 'true'; +} + export function clearGuestSession() { sessionStorage.removeItem(GUEST_SESSION_KEY); } diff --git a/apps/web/src/features/dashboard/application-shell.test.tsx b/apps/web/src/features/dashboard/application-shell.test.tsx index 38acf46..3395336 100644 --- a/apps/web/src/features/dashboard/application-shell.test.tsx +++ b/apps/web/src/features/dashboard/application-shell.test.tsx @@ -1,16 +1,27 @@ // Verifies the application shell exposes accessible responsive navigation and active state. +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import { cleanup, render, screen, within } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import { router } from '../../router'; +import { startGuestSession } from '../auth/guest-session'; -afterEach(cleanup); +afterEach(() => { + cleanup(); + sessionStorage.clear(); +}); describe('application shell', () => { it('renders desktop and mobile navigation with the current page selected', async () => { await router.navigate({ to: '/dashboard' }); - render(); + startGuestSession(); + const queryClient = new QueryClient(); + render( + + + , + ); expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); diff --git a/apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx b/apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx new file mode 100644 index 0000000..67e3227 --- /dev/null +++ b/apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx @@ -0,0 +1,121 @@ +// Verifies dashboard access states with an isolated router and query cache for each case. +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { startGuestSession } from '../auth/guest-session'; +import { DashboardAccessBoundary } from './dashboard-access-boundary'; + +const publicUser = { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + email: 'lifter@example.com', + displayName: 'Go For Lifter', + avatarUrl: null, +}; + +afterEach(() => { + cleanup(); + sessionStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe('dashboard access boundary', () => { + it('allows an authenticated visitor into the dashboard', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ user: publicUser })), + ); + renderDashboard(); + + expect( + await screen.findByRole('heading', { name: 'Dashboard' }), + ).toBeVisible(); + }); + + it('allows an active guest without waiting for the API', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + startGuestSession(); + renderDashboard(); + + expect( + await screen.findByRole('heading', { name: 'Dashboard' }), + ).toBeVisible(); + }); + + it('redirects an anonymous visitor to the welcome page', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ user: null })), + ); + renderDashboard(); + + expect( + await screen.findByRole('heading', { name: 'Welcome' }), + ).toBeVisible(); + }); + + it('shows a loading state while authentication is unresolved', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + renderDashboard(); + + expect(await screen.findByText('Checking your session…')).toBeVisible(); + }); + + it('shows a recoverable error when authentication cannot be verified', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(null, { status: 500 })), + ); + renderDashboard(); + + expect( + await screen.findByText("We couldn't verify your session."), + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeVisible(); + }); +}); + +function renderDashboard() { + const rootRoute = createRootRoute({ component: Outlet }); + const welcomeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Welcome

, + }); + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () => ( + +

Dashboard

+
+ ), + }); + const testRouter = createRouter({ + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + routeTree: rootRoute.addChildren([welcomeRoute, dashboardRoute]), + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + , + ); +} diff --git a/apps/web/src/features/dashboard/dashboard-access-boundary.tsx b/apps/web/src/features/dashboard/dashboard-access-boundary.tsx new file mode 100644 index 0000000..68e25c2 --- /dev/null +++ b/apps/web/src/features/dashboard/dashboard-access-boundary.tsx @@ -0,0 +1,58 @@ +// Allows authenticated and guest visitors into application routes while handling unresolved access. +import { Navigate } from '@tanstack/react-router'; +import type { ReactNode } from 'react'; + +import { Button } from '../../components/ui/button'; +import { useCurrentUser } from '../auth/current-user.query'; +import { isGuestSession } from '../auth/guest-session'; + +type DashboardAccessBoundaryProps = { + children: ReactNode; +}; + +export function DashboardAccessBoundary({ + children, +}: DashboardAccessBoundaryProps) { + const currentUser = useCurrentUser(); + + if (isGuestSession()) { + return children; + } + + if (currentUser.authenticationStatus === 'loading') { + return ; + } + + if (currentUser.authenticationStatus === 'error') { + return ( + + + + ); + } + + if (currentUser.authenticationStatus === 'anonymous') { + return ; + } + + return children; +} + +function AccessStatus({ + children, + message, +}: { + children?: ReactNode; + message: string; +}) { + return ( +
+

+ {message} +

+ {children} +
+ ); +} diff --git a/apps/web/src/features/dashboard/section-landing.tsx b/apps/web/src/features/dashboard/section-landing.tsx index 981c41a..3b3f94b 100644 --- a/apps/web/src/features/dashboard/section-landing.tsx +++ b/apps/web/src/features/dashboard/section-landing.tsx @@ -1,5 +1,4 @@ -// Presents a minimal content heading inside the shared application shell. -import { ApplicationShell } from './application-shell'; +// Presents a minimal heading within the parent application route layout. type SectionLandingProps = { title: string; @@ -7,10 +6,8 @@ type SectionLandingProps = { export function SectionLanding({ title }: SectionLandingProps) { return ( - -

- {title} -

-
+

+ {title} +

); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7831fcd..7f25a46 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,73 +10,83 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' -import { Route as DashboardRouteImport } from './routes/dashboard' -import { Route as HistoryRouteImport } from './routes/history' -import { Route as RoutinesRouteImport } from './routes/routines' -import { Route as SettingsRouteImport } from './routes/settings' +import { Route as AppRouteImport } from './routes/_app' +import { Route as AppDashboardRouteImport } from './routes/_app.dashboard' +import { Route as AppHistoryRouteImport } from './routes/_app.history' +import { Route as AppRoutinesRouteImport } from './routes/_app.routines' +import { Route as AppSettingsRouteImport } from './routes/_app.settings' const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const DashboardRoute = DashboardRouteImport.update({ +const AppRoute = AppRouteImport.update({ + id: '/_app', + getParentRoute: () => rootRouteImport, +} as any) +const AppDashboardRoute = AppDashboardRouteImport.update({ id: '/dashboard', path: '/dashboard', - getParentRoute: () => rootRouteImport, + getParentRoute: () => AppRoute, } as any) -const HistoryRoute = HistoryRouteImport.update({ +const AppHistoryRoute = AppHistoryRouteImport.update({ id: '/history', path: '/history', - getParentRoute: () => rootRouteImport, + getParentRoute: () => AppRoute, } as any) -const RoutinesRoute = RoutinesRouteImport.update({ +const AppRoutinesRoute = AppRoutinesRouteImport.update({ id: '/routines', path: '/routines', - getParentRoute: () => rootRouteImport, + getParentRoute: () => AppRoute, } as any) -const SettingsRoute = SettingsRouteImport.update({ +const AppSettingsRoute = AppSettingsRouteImport.update({ id: '/settings', path: '/settings', - getParentRoute: () => rootRouteImport, + getParentRoute: () => AppRoute, } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/dashboard': typeof DashboardRoute - '/history': typeof HistoryRoute - '/routines': typeof RoutinesRoute - '/settings': typeof SettingsRoute + '/dashboard': typeof AppDashboardRoute + '/history': typeof AppHistoryRoute + '/routines': typeof AppRoutinesRoute + '/settings': typeof AppSettingsRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/dashboard': typeof DashboardRoute - '/history': typeof HistoryRoute - '/routines': typeof RoutinesRoute - '/settings': typeof SettingsRoute + '/dashboard': typeof AppDashboardRoute + '/history': typeof AppHistoryRoute + '/routines': typeof AppRoutinesRoute + '/settings': typeof AppSettingsRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/dashboard': typeof DashboardRoute - '/history': typeof HistoryRoute - '/routines': typeof RoutinesRoute - '/settings': typeof SettingsRoute + '/_app': typeof AppRouteWithChildren + '/_app/dashboard': typeof AppDashboardRoute + '/_app/history': typeof AppHistoryRoute + '/_app/routines': typeof AppRoutinesRoute + '/_app/settings': typeof AppSettingsRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: '/' | '/dashboard' | '/history' | '/routines' | '/settings' fileRoutesByTo: FileRoutesByTo to: '/' | '/dashboard' | '/history' | '/routines' | '/settings' - id: '__root__' | '/' | '/dashboard' | '/history' | '/routines' | '/settings' + id: + | '__root__' + | '/' + | '/_app' + | '/_app/dashboard' + | '/_app/history' + | '/_app/routines' + | '/_app/settings' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - DashboardRoute: typeof DashboardRoute - HistoryRoute: typeof HistoryRoute - RoutinesRoute: typeof RoutinesRoute - SettingsRoute: typeof SettingsRoute + AppRoute: typeof AppRouteWithChildren } declare module '@tanstack/react-router' { @@ -88,43 +98,63 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/dashboard': { - id: '/dashboard' + '/_app': { + id: '/_app' + path: '' + fullPath: '/' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/_app/dashboard': { + id: '/_app/dashboard' path: '/dashboard' fullPath: '/dashboard' - preLoaderRoute: typeof DashboardRouteImport - parentRoute: typeof rootRouteImport + preLoaderRoute: typeof AppDashboardRouteImport + parentRoute: typeof AppRoute } - '/history': { - id: '/history' + '/_app/history': { + id: '/_app/history' path: '/history' fullPath: '/history' - preLoaderRoute: typeof HistoryRouteImport - parentRoute: typeof rootRouteImport + preLoaderRoute: typeof AppHistoryRouteImport + parentRoute: typeof AppRoute } - '/routines': { - id: '/routines' + '/_app/routines': { + id: '/_app/routines' path: '/routines' fullPath: '/routines' - preLoaderRoute: typeof RoutinesRouteImport - parentRoute: typeof rootRouteImport + preLoaderRoute: typeof AppRoutinesRouteImport + parentRoute: typeof AppRoute } - '/settings': { - id: '/settings' + '/_app/settings': { + id: '/_app/settings' path: '/settings' fullPath: '/settings' - preLoaderRoute: typeof SettingsRouteImport - parentRoute: typeof rootRouteImport + preLoaderRoute: typeof AppSettingsRouteImport + parentRoute: typeof AppRoute } } } +interface AppRouteChildren { + AppDashboardRoute: typeof AppDashboardRoute + AppHistoryRoute: typeof AppHistoryRoute + AppRoutinesRoute: typeof AppRoutinesRoute + AppSettingsRoute: typeof AppSettingsRoute +} + +const AppRouteChildren: AppRouteChildren = { + AppDashboardRoute: AppDashboardRoute, + AppHistoryRoute: AppHistoryRoute, + AppRoutinesRoute: AppRoutinesRoute, + AppSettingsRoute: AppSettingsRoute, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - DashboardRoute: DashboardRoute, - HistoryRoute: HistoryRoute, - RoutinesRoute: RoutinesRoute, - SettingsRoute: SettingsRoute, + AppRoute: AppRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/_app.dashboard.tsx similarity index 58% rename from apps/web/src/routes/dashboard.tsx rename to apps/web/src/routes/_app.dashboard.tsx index 728103c..b7c8aee 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/_app.dashboard.tsx @@ -1,8 +1,8 @@ -// Maps the dashboard route to the shared post-entry landing state. +// Maps the dashboard URL into the protected application layout. import { createFileRoute } from '@tanstack/react-router'; import { DashboardLanding } from '../features/dashboard/dashboard-landing'; -export const Route = createFileRoute('/dashboard')({ +export const Route = createFileRoute('/_app/dashboard')({ component: DashboardLanding, }); diff --git a/apps/web/src/routes/history.tsx b/apps/web/src/routes/_app.history.tsx similarity index 59% rename from apps/web/src/routes/history.tsx rename to apps/web/src/routes/_app.history.tsx index 8ad2e9a..3fb92a7 100644 --- a/apps/web/src/routes/history.tsx +++ b/apps/web/src/routes/_app.history.tsx @@ -1,8 +1,8 @@ -// Maps the history destination into the shared responsive application shell. +// Maps the history URL into the protected application layout. import { createFileRoute } from '@tanstack/react-router'; import { SectionLanding } from '../features/dashboard/section-landing'; -export const Route = createFileRoute('/history')({ +export const Route = createFileRoute('/_app/history')({ component: () => , }); diff --git a/apps/web/src/routes/routines.tsx b/apps/web/src/routes/_app.routines.tsx similarity index 59% rename from apps/web/src/routes/routines.tsx rename to apps/web/src/routes/_app.routines.tsx index 748764b..2a61935 100644 --- a/apps/web/src/routes/routines.tsx +++ b/apps/web/src/routes/_app.routines.tsx @@ -1,8 +1,8 @@ -// Maps the routines destination into the shared responsive application shell. +// Maps the routines URL into the protected application layout. import { createFileRoute } from '@tanstack/react-router'; import { SectionLanding } from '../features/dashboard/section-landing'; -export const Route = createFileRoute('/routines')({ +export const Route = createFileRoute('/_app/routines')({ component: () => , }); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/_app.settings.tsx similarity index 59% rename from apps/web/src/routes/settings.tsx rename to apps/web/src/routes/_app.settings.tsx index d63288d..d9ed0b2 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/_app.settings.tsx @@ -1,8 +1,8 @@ -// Maps the settings destination into the shared responsive application shell. +// Maps the settings URL into the protected application layout. import { createFileRoute } from '@tanstack/react-router'; import { SectionLanding } from '../features/dashboard/section-landing'; -export const Route = createFileRoute('/settings')({ +export const Route = createFileRoute('/_app/settings')({ component: () => , }); diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx new file mode 100644 index 0000000..2e173b6 --- /dev/null +++ b/apps/web/src/routes/_app.tsx @@ -0,0 +1,19 @@ +// Protects and frames every route inside the signed-in or guest application area. +import { createFileRoute, Outlet } from '@tanstack/react-router'; + +import { ApplicationShell } from '../features/dashboard/application-shell'; +import { DashboardAccessBoundary } from '../features/dashboard/dashboard-access-boundary'; + +export const Route = createFileRoute('/_app')({ + component: ProtectedApplicationLayout, +}); + +function ProtectedApplicationLayout() { + return ( + + + + + + ); +}