Add auth UI: login/register pages, OIDC button, route guard (#6)
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:
+73
-8
@@ -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' {
|
||||
|
||||
Reference in New Issue
Block a user