fix(web): auth token gate — synthetic 401 on missing token, unauthenticated boot state, and recovery screen (#2740)
When `gsd --web` is opened without the #token= hash fragment (manual URL entry, bookmark, new tab), `authenticatedFetch` previously fell through to a naked `fetch()` that always returned 401, flooding the console with cascading errors and leaving the UI in a broken state with no recovery path. Three changes: 1. `web/lib/auth.ts` — `authFetch()` now returns a synthetic 401 Response when `getAuthToken()` returns null instead of delegating to bare fetch. This makes missing-token failures consistent and immediately catchable by all callers without a network round-trip. 2. `web/lib/gsd-workspace-store.tsx` — Added `"unauthenticated"` to `WorkspaceStatus`. `refreshBoot()` now detects a 401 response from /api/boot and patches `bootStatus` to `"unauthenticated"` instead of throwing a generic error. This is a distinct state — not an error worth retrying, but a configuration problem the user must resolve. 3. `web/components/gsd/app-shell.tsx` — Added an early-return guard that renders a minimal "Authentication Required" screen when `bootStatus === "unauthenticated"`. The screen explains the problem and tells users to copy the full terminal URL (including `#token=…`) or restart with `gsd --web`. Fixes #2731
This commit is contained in:
parent
07d804588e
commit
6cc6c36a69
3 changed files with 54 additions and 2 deletions
|
|
@ -235,6 +235,41 @@ function WorkspaceChrome() {
|
|||
detection.kind !== "active-gsd" &&
|
||||
detection.kind !== "empty-gsd"
|
||||
|
||||
// --- Unauthenticated gate ---
|
||||
// Render a clear recovery screen before any workspace chrome is mounted so
|
||||
// users who open a manually-typed URL (no #token= fragment) get actionable
|
||||
// guidance instead of a cascade of 401 errors.
|
||||
if (workspace.bootStatus === "unauthenticated") {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col items-center justify-center gap-6 bg-background p-8 text-center">
|
||||
<Image
|
||||
src="/logo-black.svg"
|
||||
alt="GSD"
|
||||
width={57}
|
||||
height={16}
|
||||
className="shrink-0 h-4 w-auto dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
src="/logo-white.svg"
|
||||
alt="GSD"
|
||||
width={57}
|
||||
height={16}
|
||||
className="shrink-0 h-4 w-auto hidden dark:block"
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-foreground">Authentication Required</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
This workspace requires an auth token. Copy the full URL from your terminal
|
||||
(including the{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">#token=…</code>{" "}
|
||||
part) or restart with{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">gsd --web</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border bg-card px-2 md:px-4">
|
||||
|
|
|
|||
|
|
@ -81,10 +81,20 @@ export function authHeaders(extra?: Record<string, string>): Record<string, stri
|
|||
|
||||
/**
|
||||
* Wrapper around `fetch()` that automatically injects the auth token.
|
||||
*
|
||||
* When no token is available (missing `#token=` fragment and no sessionStorage
|
||||
* entry), returns a synthetic 401 Response instead of making an unauthenticated
|
||||
* request that will fail server-side anyway. This lets callers handle the
|
||||
* missing-token case uniformly rather than silently cascading 401s.
|
||||
*/
|
||||
export async function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const token = getAuthToken()
|
||||
if (!token) return fetch(input, init)
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: "No auth token available" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!headers.has("Authorization")) {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ import type {
|
|||
} from "./session-browser-contract"
|
||||
import { authFetch, appendAuthParam } from "./auth"
|
||||
|
||||
export type WorkspaceStatus = "idle" | "loading" | "ready" | "error"
|
||||
export type WorkspaceStatus = "idle" | "loading" | "ready" | "error" | "unauthenticated"
|
||||
export type WorkspaceConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
|
|
@ -4135,6 +4135,13 @@ export class GSDWorkspaceStore {
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.patchState({
|
||||
bootStatus: "unauthenticated",
|
||||
connectionState: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
throw new Error(`Boot request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue