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
+43
View File
@@ -0,0 +1,43 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string
hint?: string
}
// A labelled text input. text-base (16px) is deliberate: smaller fonts make iOS
// Safari zoom on focus. The label's htmlFor falls back to the field name.
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
{ label, hint, id, name, className, ...props },
ref,
) {
const inputId = id ?? name
const hintId = hint ? `${inputId}-hint` : undefined
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={inputId} className="text-sm font-medium text-fg">
{label}
</label>
<input
ref={ref}
id={inputId}
name={name}
aria-describedby={hintId}
className={cn(
'w-full rounded-md border border-border bg-surface px-3 py-2 text-base text-fg',
'placeholder:text-muted/70 outline-none transition-colors',
'focus:border-accent focus:ring-2 focus:ring-accent/30',
'disabled:opacity-60',
className,
)}
{...props}
/>
{hint && (
<p id={hintId} className="text-xs text-muted">
{hint}
</p>
)}
</div>
)
})