From 622010cd71b38d38f9fdda72cb16fc032204cc1c Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 18 Jul 2026 17:49:57 -0400 Subject: [PATCH 1/2] Add auth UI: login/register pages, OIDC button, route guard (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=) 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) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- web/src/components/auth/AuthCard.tsx | 22 +++++ web/src/components/layout/AppShell.tsx | 64 +++++++++---- web/src/components/ui/Alert.tsx | 20 ++++ web/src/components/ui/Button.tsx | 24 +++++ web/src/components/ui/TextField.tsx | 43 +++++++++ web/src/lib/auth.ts | 112 +++++++++++++++++++++++ web/src/lib/queryClient.ts | 10 ++ web/src/main.tsx | 9 +- web/src/pages/LoginPage.tsx | 121 ++++++++++++++++++++++++- web/src/pages/RegisterPage.tsx | 100 +++++++++++++++++++- web/src/router.tsx | 81 +++++++++++++++-- 11 files changed, 569 insertions(+), 37 deletions(-) create mode 100644 web/src/components/auth/AuthCard.tsx create mode 100644 web/src/components/ui/Alert.tsx create mode 100644 web/src/components/ui/Button.tsx create mode 100644 web/src/components/ui/TextField.tsx create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/queryClient.ts diff --git a/web/src/components/auth/AuthCard.tsx b/web/src/components/auth/AuthCard.tsx new file mode 100644 index 0000000..efe04a1 --- /dev/null +++ b/web/src/components/auth/AuthCard.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +/** Centered card layout shared by the login and register pages. */ +export function AuthCard({ + title, + subtitle, + children, +}: { + title: string + subtitle?: string + children: ReactNode +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
{children}
+
+
+ ) +} diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index be99caf..9054e90 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -1,4 +1,5 @@ -import { Link, Outlet } from '@tanstack/react-router' +import { Link, Outlet, useNavigate } from '@tanstack/react-router' +import { useLogout, useMe } from '@/lib/auth' const navLinks = [ { to: '/gardens', label: 'Gardens' }, @@ -14,6 +15,16 @@ const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg' /** Top-level chrome: a sticky nav bar plus the routed page in an . */ export function AppShell() { + const me = useMe() + const logout = useLogout() + const navigate = useNavigate() + const user = me.data + + async function onLogout() { + await logout.mutateAsync() + await navigate({ to: '/login' }) + } + return (
@@ -21,25 +32,42 @@ export function AppShell() { 🌱 pansy +
- {navLinks.map((l) => ( - - {l.label} - - ))} + {user && + navLinks.map((l) => ( + + {l.label} + + ))}
- - Sign in - + + {user ? ( +
+ {user.displayName} + +
+ ) : ( + + Sign in + + )}
diff --git a/web/src/components/ui/Alert.tsx b/web/src/components/ui/Alert.tsx new file mode 100644 index 0000000..46caf51 --- /dev/null +++ b/web/src/components/ui/Alert.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/cn' + +type AlertTone = 'error' | 'info' + +/** A small inline notice for form errors and status messages. */ +export function Alert({ tone = 'error', children }: { tone?: AlertTone; children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..331078f --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,24 @@ +import type { ButtonHTMLAttributes } from 'react' +import { cn } from '@/lib/cn' + +export type ButtonVariant = 'primary' | 'ghost' + +/** Shared button styling, exported so anchor "buttons" (e.g. the OIDC link) match. */ +export function buttonClasses(variant: ButtonVariant = 'primary', className?: string) { + return cn( + 'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors', + 'outline-none focus-visible:ring-2 focus-visible:ring-accent/40', + 'disabled:cursor-not-allowed disabled:opacity-60', + variant === 'primary' && 'bg-accent text-accent-contrast hover:bg-accent-strong', + variant === 'ghost' && 'border border-border text-fg hover:bg-border/50', + className, + ) +} + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant +} + +export function Button({ variant = 'primary', className, type = 'button', ...props }: ButtonProps) { + return + + )} + + {providers.isSuccess && !local && !oidc && ( + No sign-in methods are configured on this server. + )} + + {local && ( +

+ No account?{' '} + + Create one + +

+ )} +
+ + ) +} + +function Divider({ children }: { children: ReactNode }) { + return ( +
+ + {children} + +
+ ) } diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index c38adfb..9f8cb26 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -1,5 +1,101 @@ -import { PageStub } from '@/components/PageStub' +import { useState, type FormEvent } from 'react' +import { Link, useNavigate } from '@tanstack/react-router' +import { AuthCard } from '@/components/auth/AuthCard' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { TextField } from '@/components/ui/TextField' +import { apiErrorCode, errorMessage, useProviders, useRegister } from '@/lib/auth' + +const MIN_PASSWORD = 8 export function RegisterPage() { - return Registration form arrives in issue #6. + const navigate = useNavigate() + const providers = useProviders() + const register = useRegister() + const [email, setEmail] = useState('') + const [displayName, setDisplayName] = useState('') + const [password, setPassword] = useState('') + + async function onSubmit(e: FormEvent) { + e.preventDefault() + try { + await register.mutateAsync({ email, displayName, password }) + await navigate({ to: '/gardens' }) // registration auto-logs-in + } catch { + // Shown below via register.error. + } + } + + // Local registration is only meaningful when local auth is enabled. + if (providers.isSuccess && !providers.data.local) { + return ( + +
+ This server uses single sign-on. Local registration is disabled. + + Back to sign in + +
+
+ ) + } + + const registrationClosed = apiErrorCode(register.error) === 'REGISTRATION_CLOSED' + + return ( + + {registrationClosed ? ( +
+ Registration is closed on this server. Ask an administrator for an account. + + Back to sign in + +
+ ) : ( +
+ setEmail(e.target.value)} + /> + setDisplayName(e.target.value)} + /> + setPassword(e.target.value)} + /> + {register.isError && {errorMessage(register.error, 'Could not create your account.')}} + +

+ Already have an account?{' '} + + Sign in + +

+ + )} +
+ ) } diff --git a/web/src/router.tsx b/web/src/router.tsx index f0c478c..5de62be 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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()({ 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): 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' { From ae1906e169acc47f4ac25022e24aa5ff95c2938e Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 18 Jul 2026 18:10:00 -0400 Subject: [PATCH 2/2] Address Gadfly review on #6: logout errors, register gating, guard errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the PR #25 adversarial review (graded 23 real / 5 false positive): Error handling - onLogout wraps mutateAsync in try/catch: a failed logout no longer becomes an unhandled rejection; the user stays put (session still valid) and the button offers "Retry sign out" (5 models flagged this). - Root errorComponent (RouteError): a non-401 /auth/me failure in a beforeLoad guard now shows a recoverable "Try again" screen instead of blanking. - Forms drop noValidate, restoring native required/type=email/minLength checks before hitting the server. Correctness - RegisterPage gates on providers.isPending/isError before rendering, so the form no longer briefly appears (submittable) on an SSO-only server. - TextField id falls back to name then a useId() value, so the label/hint associations hold even if a caller omits both. Maintainability - Single safeRedirectPath (lib/redirect.ts) replaces the duplicated safeRedirect/safeInternalPath (4 models). - Generic ApiError helpers (apiErrorCode, errorMessage) moved to lib/api.ts; LoginPage builds the OIDC URL from the exported API_BASE. - Divider moved to components/ui/. errorMessage doc clarified. Verified again in a real browser: register -> /gardens; Sign out -> /login. tsc --noEmit and vite build clean. Not changed (graded, with rationale): OIDC deep-link redirect isn't preserved (needs backend state threading — follow-up); 60s me staleTime in guards (server is authoritative); the login/register onSubmit catches are the intended react-query pattern (errors render via mutation state). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- web/src/components/RouteError.tsx | 17 ++++++++++++++++ web/src/components/layout/AppShell.tsx | 13 +++++++++--- web/src/components/ui/Divider.tsx | 12 +++++++++++ web/src/components/ui/TextField.tsx | 8 +++++--- web/src/lib/api.ts | 24 ++++++++++++++++++++-- web/src/lib/auth.ts | 15 -------------- web/src/lib/redirect.ts | 14 +++++++++++++ web/src/pages/LoginPage.tsx | 28 ++++++++------------------ web/src/pages/RegisterPage.tsx | 23 ++++++++++++++++++--- web/src/router.tsx | 21 ++++++++++--------- 10 files changed, 120 insertions(+), 55 deletions(-) create mode 100644 web/src/components/RouteError.tsx create mode 100644 web/src/components/ui/Divider.tsx create mode 100644 web/src/lib/redirect.ts diff --git a/web/src/components/RouteError.tsx b/web/src/components/RouteError.tsx new file mode 100644 index 0000000..095900e --- /dev/null +++ b/web/src/components/RouteError.tsx @@ -0,0 +1,17 @@ +import { useRouter } from '@tanstack/react-router' +import { Button } from '@/components/ui/Button' +import { errorMessage } from '@/lib/api' + +// Root error boundary: if a route's beforeLoad/loader throws something other +// than a redirect (e.g. /auth/me fails with a network error or a 500), show a +// recoverable message instead of a blank screen. Retrying re-runs the guards. +export function RouteError({ error }: { error: Error }) { + const router = useRouter() + return ( +
+

Something went wrong

+

{errorMessage(error, 'An unexpected error occurred.')}

+ +
+ ) +} diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 9054e90..5cf7770 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -21,8 +21,14 @@ export function AppShell() { const user = me.data async function onLogout() { - await logout.mutateAsync() - await navigate({ to: '/login' }) + try { + await logout.mutateAsync() + await navigate({ to: '/login' }) + } catch { + // The logout request failed, so the session is still valid server-side: + // leave the user where they are (the button re-enables for a retry) rather + // than pretending they're signed out. logout.isError drives the title below. + } } return ( @@ -55,9 +61,10 @@ export function AppShell() { type="button" onClick={onLogout} disabled={logout.isPending} + title={logout.isError ? 'Sign out failed — try again' : undefined} className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg disabled:opacity-60" > - {logout.isPending ? 'Signing out…' : 'Sign out'} + {logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'} ) : ( diff --git a/web/src/components/ui/Divider.tsx b/web/src/components/ui/Divider.tsx new file mode 100644 index 0000000..5c638f7 --- /dev/null +++ b/web/src/components/ui/Divider.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' + +/** A horizontal rule with centered label text (e.g. "or" between auth options). */ +export function Divider({ children }: { children: ReactNode }) { + return ( +
+ + {children} + +
+ ) +} diff --git a/web/src/components/ui/TextField.tsx b/web/src/components/ui/TextField.tsx index 7015c3a..d277c12 100644 --- a/web/src/components/ui/TextField.tsx +++ b/web/src/components/ui/TextField.tsx @@ -1,4 +1,4 @@ -import { forwardRef, type InputHTMLAttributes } from 'react' +import { forwardRef, useId, type InputHTMLAttributes } from 'react' import { cn } from '@/lib/cn' interface TextFieldProps extends InputHTMLAttributes { @@ -7,12 +7,14 @@ interface TextFieldProps extends InputHTMLAttributes { } // 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. +// Safari zoom on focus. The field id falls back to the name, then to a generated +// id, so the label/hint associations always hold. export const TextField = forwardRef(function TextField( { label, hint, id, name, className, ...props }, ref, ) { - const inputId = id ?? name + const generatedId = useId() + const inputId = id ?? name ?? generatedId const hintId = hint ? `${inputId}-hint` : undefined return (
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9cefee5..7515f20 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,7 +5,8 @@ // editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and, // on a 409, reads the *current* row back out of `ApiError.body` to reconcile. -const BASE = '/api/v1' +/** Base path for the versioned JSON API; also used to build server-nav links. */ +export const API_BASE = '/api/v1' /** Error thrown for any non-2xx response (or a network failure, status 0). */ export class ApiError extends Error { @@ -44,7 +45,7 @@ export interface RequestOptions { } function buildUrl(path: string, params?: Params): string { - const url = BASE + path + const url = API_BASE + path if (!params) return url const qs = new URLSearchParams() for (const [k, v] of Object.entries(params)) { @@ -112,6 +113,25 @@ export async function apiFetch(path: string, opts: RequestOptions = {}): Prom return payload as T } +/** 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. An ApiError carries the + * server's own message (already user-friendly), so that wins; `fallback` covers + * everything else (unexpected throws). + */ +export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string { + if (err instanceof ApiError) return err.message + return fallback +} + /** Convenience verbs over apiFetch. */ export const api = { get: (path: string, opts?: Omit) => diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 85d4996..75ea94e 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -95,18 +95,3 @@ export function useLogout() { }, }) } - -/** 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 -} diff --git a/web/src/lib/redirect.ts b/web/src/lib/redirect.ts new file mode 100644 index 0000000..bf28804 --- /dev/null +++ b/web/src/lib/redirect.ts @@ -0,0 +1,14 @@ +// Where to land after auth. The router guard stashes the intended destination +// in ?redirect=, and the login page reads it back; both go through this so a +// caller-controlled value can never send the user to an external origin. + +const DEFAULT_DESTINATION = '/gardens' + +/** + * Return dest only if it is a same-origin absolute path (starts with a single + * '/'); otherwise fall back to the gardens list. Rejects protocol-relative + * ('//evil.com') and absolute ('https://…') URLs. + */ +export function safeRedirectPath(dest: string | undefined, fallback = DEFAULT_DESTINATION): string { + return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : fallback +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 550f656..884918a 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,14 +1,17 @@ -import { useState, type FormEvent, type ReactNode } from 'react' +import { useState, type FormEvent } from 'react' import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { AuthCard } from '@/components/auth/AuthCard' import { Alert } from '@/components/ui/Alert' import { Button, buttonClasses } from '@/components/ui/Button' +import { Divider } from '@/components/ui/Divider' import { TextField } from '@/components/ui/TextField' -import { errorMessage, useLogin, useProviders } from '@/lib/auth' +import { API_BASE, errorMessage } from '@/lib/api' +import { useLogin, useProviders } from '@/lib/auth' +import { safeRedirectPath } from '@/lib/redirect' // Server-initiated redirect (not a fetch): the browser navigates to the Go // handler, which 302s to the IdP. -const OIDC_LOGIN_URL = '/api/v1/auth/oidc/login' +const OIDC_LOGIN_URL = `${API_BASE}/auth/oidc/login` // Messages for the ?error= codes the OIDC callback redirects back with. const callbackErrors: Record = { @@ -20,11 +23,6 @@ const callbackErrors: Record = { oidc_conflict: 'That single sign-on identity conflicts with an existing account.', } -// Only follow same-origin absolute paths, never a caller-controlled external URL. -function safeRedirect(dest: string | undefined): string { - return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : '/gardens' -} - export function LoginPage() { const search = useSearch({ from: '/login' }) const navigate = useNavigate() @@ -33,7 +31,7 @@ export function LoginPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') - const dest = safeRedirect(search.redirect) + const dest = safeRedirectPath(search.redirect) const callbackError = search.error ? (callbackErrors[search.error] ?? callbackErrors.oidc) : null async function onSubmit(e: FormEvent) { @@ -66,7 +64,7 @@ export function LoginPage() { {oidc && local && or} {local && ( -
+ ) } - -function Divider({ children }: { children: ReactNode }) { - return ( -
- - {children} - -
- ) -} diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index 9f8cb26..fd3d51e 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -4,7 +4,8 @@ import { AuthCard } from '@/components/auth/AuthCard' import { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' import { TextField } from '@/components/ui/TextField' -import { apiErrorCode, errorMessage, useProviders, useRegister } from '@/lib/auth' +import { apiErrorCode, errorMessage } from '@/lib/api' +import { useProviders, useRegister } from '@/lib/auth' const MIN_PASSWORD = 8 @@ -26,8 +27,24 @@ export function RegisterPage() { } } + // Don't render the form until we know local registration is offered — otherwise + // it briefly appears (and is submittable) on an SSO-only server. + if (providers.isPending) { + return ( + +

Loading…

+
+ ) + } + if (providers.isError) { + return ( + + Could not load sign-in options. Please refresh. + + ) + } // Local registration is only meaningful when local auth is enabled. - if (providers.isSuccess && !providers.data.local) { + if (!providers.data.local) { return (
@@ -52,7 +69,7 @@ export function RegisterPage() {
) : ( - + ()({ component: AppShell }) +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, +}) // 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) { +// 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: href } }) + throw redirect({ to: '/login', search: { redirect: path } }) } } @@ -37,10 +44,6 @@ async function requireGuest(context: RouterContext, redirectTo: string) { } } -function safeInternalPath(dest: string | undefined): string { - return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : '/gardens' -} - const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', @@ -65,7 +68,7 @@ const loginRoute = createRoute({ if (typeof search.error === 'string') out.error = search.error return out }, - beforeLoad: ({ context, search }) => requireGuest(context, safeInternalPath(search.redirect)), + beforeLoad: ({ context, search }) => requireGuest(context, safeRedirectPath(search.redirect)), component: LoginPage, })