Files
pansy/web/src/pages/LoginPage.tsx
T
steveandClaude Opus 4.8 ae1906e169
Build image / build-and-push (push) Successful in 7s
Address Gadfly review on #6: logout errors, register gating, guard errors
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
2026-07-18 18:10:00 -04:00

111 lines
4.0 KiB
TypeScript

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<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.',
}
export function LoginPage() {
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 (
<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">
<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>
)
}