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