Build image / build-and-push (push) Successful in 7s
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) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
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() {
|
|
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 (
|
|
<AuthCard title="Create account">
|
|
<p className="text-sm text-muted">Loading…</p>
|
|
</AuthCard>
|
|
)
|
|
}
|
|
if (providers.isError) {
|
|
return (
|
|
<AuthCard title="Create account">
|
|
<Alert>Could not load sign-in options. Please refresh.</Alert>
|
|
</AuthCard>
|
|
)
|
|
}
|
|
// Local registration is only meaningful when local auth is enabled.
|
|
if (!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">
|
|
<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>
|
|
)
|
|
}
|