Address Gadfly review on #6: logout errors, register gating, guard errors
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:
2026-07-18 18:10:00 -04:00
co-authored by Claude Opus 4.8
parent 622010cd71
commit ae1906e169
10 changed files with 120 additions and 55 deletions
+12 -9
View File
@@ -6,6 +6,7 @@ import {
} 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'
@@ -13,19 +14,25 @@ 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'
interface RouterContext {
queryClient: QueryClient
}
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: AppShell })
const rootRoute = createRootRouteWithContext<RouterContext>()({
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 where they were headed.
async function requireAuth(context: RouterContext, href: string) {
// 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: href } })
throw redirect({ to: '/login', search: { redirect: path } })
}
}
@@ -37,10 +44,6 @@ async function requireGuest(context: RouterContext, redirectTo: string) {
}
}
function safeInternalPath(dest: string | undefined): string {
return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : '/gardens'
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
@@ -65,7 +68,7 @@ const loginRoute = createRoute({
if (typeof search.error === 'string') out.error = search.error
return out
},
beforeLoad: ({ context, search }) => requireGuest(context, safeInternalPath(search.redirect)),
beforeLoad: ({ context, search }) => requireGuest(context, safeRedirectPath(search.redirect)),
component: LoginPage,
})