@@ -21,25 +38,43 @@ export function AppShell() {
🌱 pansy
+
- {navLinks.map((l) => (
-
- {l.label}
-
- ))}
+ {user &&
+ navLinks.map((l) => (
+
+ {l.label}
+
+ ))}
-
- Sign in
-
+
+ {user ? (
+
+ {user.displayName}
+
+ {logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
+
+
+ ) : (
+
+ 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
+}
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
new file mode 100644
index 0000000..d277c12
--- /dev/null
+++ b/web/src/components/ui/TextField.tsx
@@ -0,0 +1,45 @@
+import { forwardRef, useId, type InputHTMLAttributes } from 'react'
+import { cn } from '@/lib/cn'
+
+interface TextFieldProps extends InputHTMLAttributes {
+ label: string
+ hint?: string
+}
+
+// A labelled text input. text-base (16px) is deliberate: smaller fonts make iOS
+// 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 generatedId = useId()
+ const inputId = id ?? name ?? generatedId
+ const hintId = hint ? `${inputId}-hint` : undefined
+ return (
+
+
+ {label}
+
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ )
+})
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
new file mode 100644
index 0000000..75ea94e
--- /dev/null
+++ b/web/src/lib/auth.ts
@@ -0,0 +1,97 @@
+// 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
+
+export const providersSchema = z.object({
+ local: z.boolean(),
+ oidc: z.boolean(),
+ oidcLabel: z.string(),
+})
+export type Providers = z.infer
+
+// 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 => {
+ 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 => 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 =>
+ 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 =>
+ 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' })
+ },
+ })
+}
diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts
new file mode 100644
index 0000000..f264441
--- /dev/null
+++ b/web/src/lib/queryClient.ts
@@ -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 },
+ },
+})
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/main.tsx b/web/src/main.tsx
index 4da6841..d0396af 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,16 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
+import { queryClient } from './lib/queryClient'
import './styles/index.css'
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
- },
-})
-
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Missing #root element')
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
index 9f43996..884918a 100644
--- a/web/src/pages/LoginPage.tsx
+++ b/web/src/pages/LoginPage.tsx
@@ -1,5 +1,110 @@
-import { PageStub } from '@/components/PageStub'
+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 { 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_BASE}/auth/oidc/login`
+
+// Messages for the ?error= codes the OIDC callback redirects back with.
+const callbackErrors: Record = {
+ oidc: 'Single sign-on did not complete. Please try again.',
+ oidc_unavailable: 'The single sign-on provider is currently unavailable.',
+ state: 'Your sign-in attempt expired. Please start again.',
+ no_email: 'Your identity provider did not share an email address, which pansy needs.',
+ email_unverified: 'Your email address is not verified with the identity provider.',
+ oidc_conflict: 'That single sign-on identity conflicts with an existing account.',
+}
export function LoginPage() {
- return Login form and SSO button arrive in issue #6.
+ const search = useSearch({ from: '/login' })
+ const navigate = useNavigate()
+ const providers = useProviders()
+ const login = useLogin()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+
+ const dest = safeRedirectPath(search.redirect)
+ const callbackError = search.error ? (callbackErrors[search.error] ?? callbackErrors.oidc) : null
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault()
+ try {
+ await login.mutateAsync({ email, password })
+ await navigate({ to: dest })
+ } catch {
+ // Shown below via login.error.
+ }
+ }
+
+ const local = providers.data?.local ?? false
+ const oidc = providers.data?.oidc ?? false
+
+ return (
+
+
+ {callbackError &&
{callbackError} }
+
+ {providers.isPending &&
Loading…
}
+ {providers.isError &&
Could not load sign-in options. Please refresh. }
+
+ {oidc && (
+
+ {providers.data?.oidcLabel || 'Sign in with SSO'}
+
+ )}
+
+ {oidc && local &&
or }
+
+ {local && (
+
+ )}
+
+ {providers.isSuccess && !local && !oidc && (
+
No sign-in methods are configured on this server.
+ )}
+
+ {local && (
+
+ No account?{' '}
+
+ Create one
+
+
+ )}
+
+
+ )
}
diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx
index c38adfb..fd3d51e 100644
--- a/web/src/pages/RegisterPage.tsx
+++ b/web/src/pages/RegisterPage.tsx
@@ -1,5 +1,118 @@
-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 } from '@/lib/api'
+import { 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.
+ }
+ }
+
+ // 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.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
+
+
+ ) : (
+
+ )}
+
+ )
}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index f0c478c..0a4a71f 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -1,20 +1,49 @@
import {
- createRootRoute,
+ createRootRouteWithContext,
createRoute,
createRouter,
redirect,
} from '@tanstack/react-router'
+import type { QueryClient } from '@tanstack/react-query'
import { AppShell } from '@/components/layout/AppShell'
+import { RouteError } from '@/components/RouteError'
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'
+import { safeRedirectPath } from '@/lib/redirect'
-const rootRoute = createRootRoute({ component: AppShell })
+interface RouterContext {
+ queryClient: QueryClient
+}
+
+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 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: path } })
+ }
+}
+
+// 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 })
+ }
+}
-// 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 +52,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, safeRedirectPath(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 +112,7 @@ const routeTree = rootRoute.addChildren([
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
+ context: { queryClient },
})
declare module '@tanstack/react-router' {