import { createRootRouteWithContext, createRoute, createRouter, redirect, } from '@tanstack/react-router' import type { QueryClient } from '@tanstack/react-query' import { AppShell } from '@/components/layout/AppShell' import { NotFound } from '@/components/NotFound' import { RouteError } from '@/components/RouteError' import { LoginPage } from '@/pages/LoginPage' import { RegisterPage } from '@/pages/RegisterPage' import { GardensPage } from '@/pages/GardensPage' import { GardenEditorPage } from '@/pages/GardenEditorPage' import { PublicGardenPage } from '@/pages/PublicGardenPage' import { PlantsPage } from '@/pages/PlantsPage' import { SettingsPage } from '@/pages/SettingsPage' import { meQueryOptions } from '@/lib/auth' import { queryClient } from '@/lib/queryClient' import { safeRedirectPath } from '@/lib/redirect' import { getLastGardenId } from '@/lib/lastGarden' interface RouterContext { queryClient: QueryClient } const rootRoute = createRootRouteWithContext()({ component: AppShell, // A beforeLoad/loader failure that isn't a redirect (e.g. /auth/me errors with // a 500 or the network drops) lands here instead of a blank screen. errorComponent: RouteError, // Unknown paths render inside the app shell rather than a blank screen. notFoundComponent: NotFound, }) // requireAuth: resolve the current user (shared cache with useMe); send anyone // unauthenticated to /login, remembering the path they were headed to. async function requireAuth(context: RouterContext, path: string) { const me = await context.queryClient.ensureQueryData(meQueryOptions) if (!me) { throw redirect({ to: '/login', search: { redirect: path } }) } } // requireGuest: keep already-authenticated users off /login and /register. async function requireGuest(context: RouterContext, redirectTo: string) { const me = await context.queryClient.ensureQueryData(meQueryOptions) if (me) { throw redirect({ to: redirectTo }) } } // requireAdmin: authenticated AND admin. A non-admin who navigates to /settings // is sent to /gardens rather than shown a page whose API calls would 403. This // is convenience routing, not the security boundary — the server's requireAdmin // is authoritative. async function requireAdmin(context: RouterContext, path: string) { const me = await context.queryClient.ensureQueryData(meQueryOptions) if (!me) { throw redirect({ to: '/login', search: { redirect: path } }) } if (!me.isAdmin) { throw redirect({ to: '/gardens' }) } } const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', // Resume the garden this device was last in, so a returning user lands back in // their plot rather than on the list every time. A stored id that no longer // loads is cleared by the editor and bounces here to /gardens on the next // visit, so this can't trap anyone on a dead garden. beforeLoad: () => { const last = getLastGardenId() if (last != null) { throw redirect({ to: '/gardens/$gardenId', params: { gardenId: String(last) } }) } throw redirect({ to: '/gardens' }) }, }) interface LoginSearch { redirect?: string error?: string } const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', // Optional properties (not `key: undefined`) so links to /login don't have to // pass a search object. validateSearch: (search: Record): LoginSearch => { const out: LoginSearch = {} if (typeof search.redirect === 'string') out.redirect = search.redirect if (typeof search.error === 'string') out.error = search.error return out }, beforeLoad: ({ context, search }) => requireGuest(context, safeRedirectPath(search.redirect)), component: LoginPage, }) const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', beforeLoad: ({ context }) => requireGuest(context, '/gardens'), component: RegisterPage, }) const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', beforeLoad: ({ context, location }) => requireAuth(context, location.href), component: GardensPage, }) const gardenEditorRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens/$gardenId', // ?focus= frames that object on load. validateSearch: (search: Record): { focus?: number } => { const f = Number(search.focus) return Number.isInteger(f) && f > 0 ? { focus: f } : {} }, beforeLoad: ({ context, location }) => requireAuth(context, location.href), component: GardenEditorPage, }) const plantsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'plants', beforeLoad: ({ context, location }) => requireAuth(context, location.href), component: PlantsPage, }) const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'settings', beforeLoad: ({ context, location }) => requireAdmin(context, location.href), component: SettingsPage, }) // Public read-only garden by share token. Deliberately has NO beforeLoad auth // guard, so a logged-out visitor viewing a shared link is never redirected to // /login or OIDC. const publicGardenRoute = createRoute({ getParentRoute: () => rootRoute, path: 'g/$token', component: PublicGardenPage, }) const routeTree = rootRoute.addChildren([ indexRoute, loginRoute, registerRoute, gardensRoute, gardenEditorRoute, plantsRoute, settingsRoute, publicGardenRoute, ]) export const router = createRouter({ routeTree, defaultPreload: 'intent', context: { queryClient }, }) declare module '@tanstack/react-router' { interface Register { router: typeof router } }