Add auth UI: login/register pages, OIDC button, route guard (#6)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 9m4s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m4s

Frontend for pansy auth, rendering whatever /auth/providers reports.

- lib/auth.ts: zod-validated User/Providers, a shared meQueryOptions
  (a 401 resolves to null, not an error) reused by useMe() and the router
  guard, useProviders(), and useLogin/useRegister/useLogout mutations that
  prime/clear the me cache (logout also drops non-auth cached data).
- lib/queryClient.ts: one QueryClient shared by the React tree and the
  router context so guard and components hit the same cache.
- router.tsx: createRootRouteWithContext with the queryClient; requireAuth
  (redirects to /login?redirect=<dest>) on gardens/plants/editor, and
  requireGuest on login/register (bounces authed users away). Login search
  params (redirect, error) validated as optional; redirect targets are
  restricted to same-origin paths.
- pages/LoginPage: OIDC button (label from providers) linking to
  /auth/oidc/login, "or" divider, local email/password form, and friendly
  messages for the OIDC callback ?error= codes. RegisterPage: email +
  display name + password (min 8), registration-closed and SSO-only
  handling; auto-login lands on /gardens.
- AppShell: auth-aware nav — shows the user's display name + Sign out when
  logged in, Sign in otherwise; nav links only when authed.
- ui/: TextField (16px text to avoid iOS zoom, autocomplete + a11y),
  Button (+ shared buttonClasses for the OIDC anchor), Alert; AuthCard.

Verified in a real browser against the embedded binary: register →
auto-login → /gardens; refresh stays in; Sign out → /login; a logged-out
/gardens/1 redirects to /login and returns after login; OIDC button renders
with the Authentik label when configured. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 17:49:57 -04:00
co-authored by Claude Opus 4.8
parent 92eb64a790
commit 622010cd71
11 changed files with 569 additions and 37 deletions
+73 -8
View File
@@ -1,20 +1,46 @@
import {
createRootRoute,
createRootRouteWithContext,
createRoute,
createRouter,
redirect,
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { AppShell } from '@/components/layout/AppShell'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { GardensPage } from '@/pages/GardensPage'
import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PlantsPage } from '@/pages/PlantsPage'
import { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient'
const rootRoute = createRootRoute({ component: AppShell })
interface RouterContext {
queryClient: QueryClient
}
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: AppShell })
// requireAuth: resolve the current user (shared cache with useMe); send anyone
// unauthenticated to /login, remembering where they were headed.
async function requireAuth(context: RouterContext, href: string) {
const me = await context.queryClient.ensureQueryData(meQueryOptions)
if (!me) {
throw redirect({ to: '/login', search: { redirect: href } })
}
}
// 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 })
}
}
function safeInternalPath(dest: string | undefined): string {
return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : '/gardens'
}
// The root path has no page of its own yet; send it to the gardens list. A real
// auth-aware landing/redirect lands with the route guard in issue #6.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
@@ -23,15 +49,53 @@ const indexRoute = createRoute({
},
})
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', component: LoginPage })
const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', component: RegisterPage })
const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', component: GardensPage })
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<string, unknown>): 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, safeInternalPath(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',
beforeLoad: ({ context, location }) => requireAuth(context, location.href),
component: GardenEditorPage,
})
const plantsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'plants', component: PlantsPage })
const plantsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'plants',
beforeLoad: ({ context, location }) => requireAuth(context, location.href),
component: PlantsPage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
@@ -45,6 +109,7 @@ const routeTree = rootRoute.addChildren([
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
context: { queryClient },
})
declare module '@tanstack/react-router' {