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/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..5cf7770 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,22 @@ 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() { + 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 (
@@ -21,25 +38,43 @@ 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 + +

+ )} +
+ + ) } 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 + +
+ ) : ( +
+ 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..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' {