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:
+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() {
|
||||
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() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user