Address Gadfly review on #6: logout errors, register gating, guard errors
Build image / build-and-push (push) Successful in 7s
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
This commit is contained in:
@@ -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 (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
||||
<h1 className="text-lg font-semibold text-fg">Something went wrong</h1>
|
||||
<p className="text-sm text-muted">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||
<Button onClick={() => router.invalidate()}>Try again</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,8 +21,14 @@ export function AppShell() {
|
||||
const user = me.data
|
||||
|
||||
async function onLogout() {
|
||||
await logout.mutateAsync()
|
||||
await navigate({ to: '/login' })
|
||||
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 (
|
||||
@@ -55,9 +61,10 @@ export function AppShell() {
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={logout.isPending}
|
||||
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
||||
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'}
|
||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** A horizontal rule with centered label text (e.g. "or" between auth options). */
|
||||
export 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,4 +1,4 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
import { forwardRef, useId, type InputHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
@@ -7,12 +7,14 @@ interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Safari zoom on focus. The field id falls back to the name, then to a generated
|
||||
// id, so the label/hint associations always hold.
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
||||
{ label, hint, id, name, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? name
|
||||
const generatedId = useId()
|
||||
const inputId = id ?? name ?? generatedId
|
||||
const hintId = hint ? `${inputId}-hint` : undefined
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
||||
Reference in New Issue
Block a user