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:
@@ -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 (
|
||||||
|
<div className="mx-auto flex min-h-[70vh] w-full max-w-sm flex-col justify-center">
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-fg">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
||||||
|
<div className="mt-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = [
|
const navLinks = [
|
||||||
{ to: '/gardens', label: 'Gardens' },
|
{ 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 <Outlet>. */
|
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
|
||||||
export function AppShell() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<div className="flex min-h-full flex-col">
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
|
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
|
||||||
@@ -21,8 +32,10 @@ export function AppShell() {
|
|||||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
||||||
🌱 pansy
|
🌱 pansy
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center gap-1">
|
<div className="flex flex-1 items-center gap-1">
|
||||||
{navLinks.map((l) => (
|
{user &&
|
||||||
|
navLinks.map((l) => (
|
||||||
<Link
|
<Link
|
||||||
key={l.to}
|
key={l.to}
|
||||||
to={l.to}
|
to={l.to}
|
||||||
@@ -34,12 +47,27 @@ export function AppShell() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
disabled={logout.isPending}
|
||||||
|
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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
|
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
role={tone === 'error' ? 'alert' : 'status'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border px-3 py-2 text-sm',
|
||||||
|
tone === 'error' && 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||||
|
tone === 'info' && 'border-border bg-border/30 text-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = 'primary', className, type = 'button', ...props }: ButtonProps) {
|
||||||
|
return <button type={type} className={buttonClasses(variant, className)} {...props} />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
})
|
||||||
+2
-7
@@ -1,16 +1,11 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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 { RouterProvider } from '@tanstack/react-router'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
import { queryClient } from './lib/queryClient'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) throw new Error('Missing #root element')
|
if (!rootEl) throw new Error('Missing #root element')
|
||||||
|
|
||||||
|
|||||||
+119
-2
@@ -1,5 +1,122 @@
|
|||||||
import { PageStub } from '@/components/PageStub'
|
import { useState, type FormEvent, type ReactNode } 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 { TextField } from '@/components/ui/TextField'
|
||||||
|
import { errorMessage, useLogin, useProviders } from '@/lib/auth'
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
|
||||||
|
// Messages for the ?error= codes the OIDC callback redirects back with.
|
||||||
|
const callbackErrors: Record<string, string> = {
|
||||||
|
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.',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export function LoginPage() {
|
||||||
return <PageStub title="Sign in">Login form and SSO button arrive in issue #6.</PageStub>
|
const search = useSearch({ from: '/login' })
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const providers = useProviders()
|
||||||
|
const login = useLogin()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
|
const dest = safeRedirect(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 (
|
||||||
|
<AuthCard title="Sign in to pansy" subtitle="Plan your garden, bed by bed.">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{callbackError && <Alert>{callbackError}</Alert>}
|
||||||
|
|
||||||
|
{providers.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||||
|
{providers.isError && <Alert>Could not load sign-in options. Please refresh.</Alert>}
|
||||||
|
|
||||||
|
{oidc && (
|
||||||
|
<a href={OIDC_LOGIN_URL} className={buttonClasses('primary', 'w-full')}>
|
||||||
|
{providers.data?.oidcLabel || 'Sign in with SSO'}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oidc && local && <Divider>or</Divider>}
|
||||||
|
|
||||||
|
{local && (
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{login.isError && <Alert>{errorMessage(login.error, 'Could not sign in.')}</Alert>}
|
||||||
|
<Button type="submit" disabled={login.isPending} className="mt-1">
|
||||||
|
{login.isPending ? 'Signing in…' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providers.isSuccess && !local && !oidc && (
|
||||||
|
<Alert tone="info">No sign-in methods are configured on this server.</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{local && (
|
||||||
|
<p className="text-center text-sm text-muted">
|
||||||
|
No account?{' '}
|
||||||
|
<Link to="/register" className="font-medium text-accent-strong hover:underline">
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AuthCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
{children}
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
export function RegisterPage() {
|
||||||
return <PageStub title="Create account">Registration form arrives in issue #6.</PageStub>
|
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 (
|
||||||
|
<AuthCard title="Create account">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Alert tone="info">This server uses single sign-on. Local registration is disabled.</Alert>
|
||||||
|
<Link to="/login" className="text-center text-sm font-medium text-accent-strong hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AuthCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationClosed = apiErrorCode(register.error) === 'REGISTRATION_CLOSED'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCard title="Create your pansy account" subtitle="The first account becomes the admin.">
|
||||||
|
{registrationClosed ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Alert>Registration is closed on this server. Ask an administrator for an account.</Alert>
|
||||||
|
<Link to="/login" className="text-center text-sm font-medium text-accent-strong hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Display name"
|
||||||
|
name="displayName"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={MIN_PASSWORD}
|
||||||
|
hint={`At least ${MIN_PASSWORD} characters.`}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{register.isError && <Alert>{errorMessage(register.error, 'Could not create your account.')}</Alert>}
|
||||||
|
<Button type="submit" disabled={register.isPending} className="mt-1">
|
||||||
|
{register.isPending ? 'Creating account…' : 'Create account'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-sm text-muted">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="font-medium text-accent-strong hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</AuthCard>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-8
@@ -1,20 +1,46 @@
|
|||||||
import {
|
import {
|
||||||
createRootRoute,
|
createRootRouteWithContext,
|
||||||
createRoute,
|
createRoute,
|
||||||
createRouter,
|
createRouter,
|
||||||
redirect,
|
redirect,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { AppShell } from '@/components/layout/AppShell'
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { RegisterPage } from '@/pages/RegisterPage'
|
import { RegisterPage } from '@/pages/RegisterPage'
|
||||||
import { GardensPage } from '@/pages/GardensPage'
|
import { GardensPage } from '@/pages/GardensPage'
|
||||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||||
import { PlantsPage } from '@/pages/PlantsPage'
|
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({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -23,15 +49,53 @@ const indexRoute = createRoute({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', component: LoginPage })
|
interface LoginSearch {
|
||||||
const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', component: RegisterPage })
|
redirect?: string
|
||||||
const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', component: GardensPage })
|
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({
|
const gardenEditorRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: 'gardens/$gardenId',
|
path: 'gardens/$gardenId',
|
||||||
|
beforeLoad: ({ context, location }) => requireAuth(context, location.href),
|
||||||
component: GardenEditorPage,
|
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([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
@@ -45,6 +109,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
|
context: { queryClient },
|
||||||
})
|
})
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|||||||
Reference in New Issue
Block a user