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
+112
View File
@@ -0,0 +1,112 @@
// Auth data layer: zod-validated shapes for the /api/v1/auth/* endpoints plus
// the react-query hooks and shared query options the router guard reuses.
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const userSchema = z.object({
id: z.number(),
email: z.string(),
displayName: z.string(),
isAdmin: z.boolean(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type User = z.infer<typeof userSchema>
export const providersSchema = z.object({
local: z.boolean(),
oidc: z.boolean(),
oidcLabel: z.string(),
})
export type Providers = z.infer<typeof providersSchema>
// meQueryOptions is shared by useMe and the router's beforeLoad guard so both
// hit one cache entry. A 401 is the unauthenticated state, not an error, so it
// resolves to null rather than throwing (which would surface as a query error).
export const meQueryOptions = queryOptions({
queryKey: ['auth', 'me'] as const,
queryFn: async (): Promise<User | null> => {
try {
return userSchema.parse(await api.get('/auth/me'))
} catch (err) {
if (err instanceof ApiError && err.isUnauthorized) return null
throw err
}
},
staleTime: 60_000,
})
export function useMe() {
return useQuery(meQueryOptions)
}
export const providersQueryOptions = queryOptions({
queryKey: ['auth', 'providers'] as const,
queryFn: async (): Promise<Providers> => providersSchema.parse(await api.get('/auth/providers')),
staleTime: Infinity, // the enabled methods don't change within a session
})
export function useProviders() {
return useQuery(providersQueryOptions)
}
export interface RegisterInput {
email: string
displayName: string
password: string
}
export interface LoginInput {
email: string
password: string
}
export function useRegister() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: RegisterInput): Promise<User> =>
userSchema.parse(await api.post('/auth/register', input)),
// Registration auto-logs-in (the server sets the cookie), so prime the me
// cache with the returned user instead of a refetch.
onSuccess: (user) => qc.setQueryData(meQueryOptions.queryKey, user),
})
}
export function useLogin() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: LoginInput): Promise<User> =>
userSchema.parse(await api.post('/auth/login', input)),
onSuccess: (user) => qc.setQueryData(meQueryOptions.queryKey, user),
})
}
export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/auth/logout'),
onSuccess: () => {
qc.setQueryData(meQueryOptions.queryKey, null)
// Drop any other cached data so the next user never sees the previous
// user's gardens/plants. The me/providers entries are re-fetched as needed.
qc.removeQueries({ predicate: (q) => q.queryKey[0] !== 'auth' })
},
})
}
/** The server error code from an ApiError body ({error:{code,message}}), if any. */
export function apiErrorCode(err: unknown): string | null {
if (err instanceof ApiError && err.body && typeof err.body === 'object') {
const e = (err.body as { error?: { code?: unknown } }).error
if (e && typeof e.code === 'string') return e.code
}
return null
}
/** A user-facing message for a caught request error. */
export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string {
if (err instanceof ApiError) return err.message
return fallback
}
+10
View File
@@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query'
// A single QueryClient shared by the React tree (QueryClientProvider) and the
// router (as beforeLoad context), so route guards and components read the same
// cache — the auth guard's ensureQueryData populates exactly what useMe reads.
export const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
},
})