Auth UI: login/register pages, OIDC button, route guard (#6) #25
@@ -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
|
const user = me.data
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
|
|
|||||||
|
try {
|
||||||
await logout.mutateAsync()
|
await logout.mutateAsync()
|
||||||
await navigate({ to: '/login' })
|
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 (
|
return (
|
||||||
@@ -55,9 +61,10 @@ export function AppShell() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
disabled={logout.isPending}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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'
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
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
|
// 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(
|
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
||||||
{ label, hint, id, name, className, ...props },
|
{ label, hint, id, name, className, ...props },
|
||||||
ref,
|
ref,
|
||||||
|
gitea-actions
commented
🟠 hintId becomes literal 'undefined-hint' when id and name are both omitted correctness, error-handling · flagged by 1 model
🪰 Gadfly · advisory 🟠 **hintId becomes literal 'undefined-hint' when id and name are both omitted**
_correctness, error-handling · flagged by 1 model_
- **`web/src/components/ui/TextField.tsx:15`** — `inputId` is computed as `id ?? name`. Both are optional on `InputHTMLAttributes`, so a caller that omits both (easy to do in a shared UI primitive) yields `inputId === undefined`. The `<label htmlFor>` and `<input id>` are then omitted, breaking the programmatic label–input association, and if `hint` is present `aria-describedby` becomes `"undefined-hint"`. **Fix:** fall back to `React.useId()` when neither `id` nor `name` is supplied.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
) {
|
) {
|
||||||
const inputId = id ?? name
|
const generatedId = useId()
|
||||||
|
const inputId = id ?? name ?? generatedId
|
||||||
const hintId = hint ? `${inputId}-hint` : undefined
|
const hintId = hint ? `${inputId}-hint` : undefined
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
|
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
|
||||||
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
|
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
|
||||||
|
|
||||||
const BASE = '/api/v1'
|
/** Base path for the versioned JSON API; also used to build server-nav links. */
|
||||||
|
export const API_BASE = '/api/v1'
|
||||||
|
|
||||||
/** Error thrown for any non-2xx response (or a network failure, status 0). */
|
/** Error thrown for any non-2xx response (or a network failure, status 0). */
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -44,7 +45,7 @@ export interface RequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(path: string, params?: Params): string {
|
function buildUrl(path: string, params?: Params): string {
|
||||||
const url = BASE + path
|
const url = API_BASE + path
|
||||||
if (!params) return url
|
if (!params) return url
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
@@ -112,6 +113,25 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
|
|||||||
return payload as T
|
return payload as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The server error code from an ApiError body ({error:{code,message}}), if any. */
|
||||||
|
export function apiErrorCode(err: unknown): string | null {
|
||||||
|
if (err instanceof ApiError && err.body && typeof err.body === 'object') {
|
||||||
|
const e = (err.body as { error?: { code?: unknown } }).error
|
||||||
|
if (e && typeof e.code === 'string') return e.code
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user-facing message for a caught request error. An ApiError carries the
|
||||||
|
* server's own message (already user-friendly), so that wins; `fallback` covers
|
||||||
|
* everything else (unexpected throws).
|
||||||
|
*/
|
||||||
|
export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string {
|
||||||
|
if (err instanceof ApiError) return err.message
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
/** Convenience verbs over apiFetch. */
|
/** Convenience verbs over apiFetch. */
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
|
|||||||
@@ -95,18 +95,3 @@ export function useLogout() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The server error code from an ApiError body ({error:{code,message}}), if any. */
|
|
||||||
export function apiErrorCode(err: unknown): string | null {
|
|
||||||
if (err instanceof ApiError && err.body && typeof err.body === 'object') {
|
|
||||||
const e = (err.body as { error?: { code?: unknown } }).error
|
|
||||||
if (e && typeof e.code === 'string') return e.code
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A user-facing message for a caught request error. */
|
|
||||||
export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string {
|
|
||||||
if (err instanceof ApiError) return err.message
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Where to land after auth. The router guard stashes the intended destination
|
||||||
|
// in ?redirect=, and the login page reads it back; both go through this so a
|
||||||
|
// caller-controlled value can never send the user to an external origin.
|
||||||
|
|
||||||
|
const DEFAULT_DESTINATION = '/gardens'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return dest only if it is a same-origin absolute path (starts with a single
|
||||||
|
* '/'); otherwise fall back to the gardens list. Rejects protocol-relative
|
||||||
|
* ('//evil.com') and absolute ('https://…') URLs.
|
||||||
|
*/
|
||||||
|
export function safeRedirectPath(dest: string | undefined, fallback = DEFAULT_DESTINATION): string {
|
||||||
|
return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : fallback
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||||
import { AuthCard } from '@/components/auth/AuthCard'
|
import { AuthCard } from '@/components/auth/AuthCard'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
import { Button, buttonClasses } from '@/components/ui/Button'
|
import { Button, buttonClasses } from '@/components/ui/Button'
|
||||||
|
import { Divider } from '@/components/ui/Divider'
|
||||||
import { TextField } from '@/components/ui/TextField'
|
import { TextField } from '@/components/ui/TextField'
|
||||||
import { errorMessage, useLogin, useProviders } from '@/lib/auth'
|
import { API_BASE, errorMessage } from '@/lib/api'
|
||||||
|
import { useLogin, useProviders } from '@/lib/auth'
|
||||||
|
import { safeRedirectPath } from '@/lib/redirect'
|
||||||
|
|
||||||
|
gitea-actions
commented
🟡 Hardcoded API base path duplicates lib/api.ts constant maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **Hardcoded API base path duplicates lib/api.ts constant**
_maintainability · flagged by 1 model_
- **`web/src/pages/LoginPage.tsx:11`** — `OIDC_LOGIN_URL` hardcodes `/api/v1`, duplicating the base path already defined in `lib/api.ts`. If the API base ever changes, this link will break silently. Export `BASE` from `lib/api.ts` (or a derived helper) and construct the OIDC URL from it so the prefix lives in one place.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
// Server-initiated redirect (not a fetch): the browser navigates to the Go
|
// Server-initiated redirect (not a fetch): the browser navigates to the Go
|
||||||
// handler, which 302s to the IdP.
|
// handler, which 302s to the IdP.
|
||||||
const OIDC_LOGIN_URL = '/api/v1/auth/oidc/login'
|
const OIDC_LOGIN_URL = `${API_BASE}/auth/oidc/login`
|
||||||
|
|
||||||
// Messages for the ?error= codes the OIDC callback redirects back with.
|
// Messages for the ?error= codes the OIDC callback redirects back with.
|
||||||
const callbackErrors: Record<string, string> = {
|
const callbackErrors: Record<string, string> = {
|
||||||
@@ -20,11 +23,6 @@ const callbackErrors: Record<string, string> = {
|
|||||||
oidc_conflict: 'That single sign-on identity conflicts with an existing account.',
|
oidc_conflict: 'That single sign-on identity conflicts with an existing account.',
|
||||||
}
|
}
|
||||||
|
gitea-actions
commented
🟠 Duplicate redirect sanitization logic with router.tsx maintainability · flagged by 4 models
🪰 Gadfly · advisory 🟠 **Duplicate redirect sanitization logic with router.tsx**
_maintainability · flagged by 4 models_
- **`web/src/pages/LoginPage.tsx:24`** and **`web/src/router.tsx:40`** — `safeRedirect` and `safeInternalPath` are identical redirect-sanitization functions. Keeping two copies means any future change (e.g., adding an allow-list of paths, or changing the default fallback) has to be made in both places. Extract one shared utility (e.g. `lib/url.ts` or `lib/auth.ts`) and import it in both files.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
|
||||||
// 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() {
|
export function LoginPage() {
|
||||||
const search = useSearch({ from: '/login' })
|
const search = useSearch({ from: '/login' })
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -33,7 +31,7 @@ export function LoginPage() {
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
const dest = safeRedirect(search.redirect)
|
const dest = safeRedirectPath(search.redirect)
|
||||||
const callbackError = search.error ? (callbackErrors[search.error] ?? callbackErrors.oidc) : null
|
const callbackError = search.error ? (callbackErrors[search.error] ?? callbackErrors.oidc) : null
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
|
gitea-actions
commented
🟡 error-handling · flagged by 1 model
🪰 Gadfly · advisory 🟡 **`web/src/pages/LoginPage.tsx:37`**
_error-handling · flagged by 1 model_
- **`web/src/pages/LoginPage.tsx:37`** — `callbackErrors[search.error] ?? callbackErrors.oidc` falls back to the generic `oidc` ("Single sign-on did not complete") message for any unknown `?error=` code. `validateSearch` (`router.tsx:62-67`) accepts any string for `error`, so an attacker-supplied/typo'd code yields a misleading SSO message even on a non-OIDC flow. Minor UX/edge: better to show a generic fallback or restrict allowed codes. **Verified** by reading both files.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
@@ -66,7 +64,7 @@ export function LoginPage() {
|
|||||||
{oidc && local && <Divider>or</Divider>}
|
{oidc && local && <Divider>or</Divider>}
|
||||||
|
|
||||||
{local && (
|
{local && (
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate>
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||||
<TextField
|
<TextField
|
||||||
label="Email"
|
label="Email"
|
||||||
|
gitea-actions
commented
🟡 noValidate disables required/minLength guards; empty/invalid input is sent to the server with no client-side check error-handling · flagged by 2 models
🪰 Gadfly · advisory 🟡 **noValidate disables required/minLength guards; empty/invalid input is sent to the server with no client-side check**
_error-handling · flagged by 2 models_
- **`web/src/pages/LoginPage.tsx:69` and `web/src/pages/RegisterPage.tsx:55` (register form) — `noValidate` silently disables the declared `required`/`minLength` guards.** Both forms set `noValidate` but perform no client-side validation before `mutateAsync(...)`, so empty email/displayName and sub-8-char passwords are submitted straight to the network. The server does reject them (shown via `errorMessage`), so it isn't a crash, but the `required`/`minLength={8}` attributes are decorative. Eithe…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
name="email"
|
name="email"
|
||||||
@@ -110,13 +108,3 @@ export function LoginPage() {
|
|||||||
</AuthCard>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { AuthCard } from '@/components/auth/AuthCard'
|
|||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { TextField } from '@/components/ui/TextField'
|
import { TextField } from '@/components/ui/TextField'
|
||||||
import { apiErrorCode, errorMessage, useProviders, useRegister } from '@/lib/auth'
|
import { apiErrorCode, errorMessage } from '@/lib/api'
|
||||||
|
import { useProviders, useRegister } from '@/lib/auth'
|
||||||
|
|
||||||
const MIN_PASSWORD = 8
|
const MIN_PASSWORD = 8
|
||||||
|
|
||||||
@@ -26,8 +27,24 @@ export function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't render the form until we know local registration is offered — otherwise
|
||||||
|
gitea-actions
commented
🔴 RegisterPage renders form while providers query is pending or errored correctness, error-handling, maintainability · flagged by 4 models
🪰 Gadfly · advisory 🔴 **RegisterPage renders form while providers query is pending or errored**
_correctness, error-handling, maintainability · flagged by 4 models_
- **`web/src/pages/RegisterPage.tsx:30`** — The page immediately falls through to render the full registration form when `providers` is still loading (`isPending`) or has errored (`isError`). Unlike `LoginPage`, there is no loading spinner or error alert for the providers query, so the form is interactive before the app even knows whether local registration is enabled. **Fix:** Add early returns for `providers.isPending` and `providers.isError`, mirroring the guard pattern in `LoginPage.tsx:57-5…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// 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.
|
// Local registration is only meaningful when local auth is enabled.
|
||||||
if (providers.isSuccess && !providers.data.local) {
|
if (!providers.data.local) {
|
||||||
return (
|
return (
|
||||||
<AuthCard title="Create account">
|
<AuthCard title="Create account">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -52,7 +69,7 @@ export function RegisterPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate>
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||||
<TextField
|
<TextField
|
||||||
label="Email"
|
label="Email"
|
||||||
name="email"
|
name="email"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { AppShell } from '@/components/layout/AppShell'
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { RouteError } from '@/components/RouteError'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { RegisterPage } from '@/pages/RegisterPage'
|
import { RegisterPage } from '@/pages/RegisterPage'
|
||||||
import { GardensPage } from '@/pages/GardensPage'
|
import { GardensPage } from '@/pages/GardensPage'
|
||||||
@@ -13,19 +14,25 @@ import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
|||||||
import { PlantsPage } from '@/pages/PlantsPage'
|
import { PlantsPage } from '@/pages/PlantsPage'
|
||||||
import { meQueryOptions } from '@/lib/auth'
|
import { meQueryOptions } from '@/lib/auth'
|
||||||
import { queryClient } from '@/lib/queryClient'
|
import { queryClient } from '@/lib/queryClient'
|
||||||
|
import { safeRedirectPath } from '@/lib/redirect'
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
queryClient: QueryClient
|
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
|
||||||
|
gitea-actions
commented
🔴 Redirect-after-login broken because location.href (full URL) is passed but safeRedirect expects a path error-handling · flagged by 2 models
🪰 Gadfly · advisory 🔴 **Redirect-after-login broken because location.href (full URL) is passed but safeRedirect expects a path**
_error-handling · flagged by 2 models_
- **`web/src/router.tsx:25-30` / `:33-38` — a non-401 error in `ensureQueryData` throws inside `beforeLoad` with no `errorComponent` configured.** `meQueryOptions.queryFn` rethrows anything that isn't a 401 (e.g. a network failure, `status: 0`). The query default `retry` (3) softens transient blips, but a persistent outage on `/gardens` makes `requireAuth` throw a real (non-redirect) error in `beforeLoad`. `rootRoute` is created with only `{ component: AppShell }` — no `errorComponent`/`notFound…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// 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
|
// requireAuth: resolve the current user (shared cache with useMe); send anyone
|
||||||
// unauthenticated to /login, remembering where they were headed.
|
// unauthenticated to /login, remembering the path they were headed to.
|
||||||
async function requireAuth(context: RouterContext, href: string) {
|
async function requireAuth(context: RouterContext, path: string) {
|
||||||
const me = await context.queryClient.ensureQueryData(meQueryOptions)
|
const me = await context.queryClient.ensureQueryData(meQueryOptions)
|
||||||
if (!me) {
|
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({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -65,7 +68,7 @@ const loginRoute = createRoute({
|
|||||||
if (typeof search.error === 'string') out.error = search.error
|
if (typeof search.error === 'string') out.error = search.error
|
||||||
return out
|
return out
|
||||||
},
|
},
|
||||||
beforeLoad: ({ context, search }) => requireGuest(context, safeInternalPath(search.redirect)),
|
beforeLoad: ({ context, search }) => requireGuest(context, safeRedirectPath(search.redirect)),
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
🔴 Logout failure is unhandled, leaving stale auth state and no user feedback
correctness, error-handling · flagged by 5 models
web/src/components/layout/AppShell.tsx:23-26—onLogoutawaitslogout.mutateAsync()without atry/catch. If the logout request fails (network error, server 5xx), the error becomes an unhandled promise rejection, themecache is never cleared (theuseLogoutonSuccessonly runs on success), and the UI continues to show the user as signed in with zero feedback. Fix: Wraplogout.mutateAsync()intry/catch; on error, surface it in the UI (e.g., an inline alert) or at l…🪰 Gadfly · advisory