From ec66f3f36119e88f8c85b5585f32db82c8d1d7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 22 Sep 2026 18:54:07 +0200 Subject: [PATCH 1/2] fix(auth): reconnect expired browser logins without reloading Confirm CodeNomad authentication after API 401s or event-stream disconnects and explain expired browser sessions in an in-page login dialog. A server restart invalidates its memory-only sessions even when credentials remain valid; avoid misdiagnosing upstream OpenCode authorization or network outages. Coalesce bounded status checks and fence late responses across login. Restore the event stream after reauthentication while leaving the composer mounted and never replaying failed application mutations. Reuse native login endpoints, shared square dialog styling and all ten locales. Validate with real authentication routes and replaced session managers in browser fixtures: SSE-triggered recovery, drafts and attachments, invalid credentials, upstream 401s, outages, overlapping alerts, narrow screens and cookies renewed elsewhere. Add unit coverage for probe/login races and restoration; adjacent SDK/event/draft regressions and UI typecheck pass. --- AGENTS.md | 1 + packages/ui/src/App.tsx | 2 + .../src/components/auth-recovery-dialog.tsx | 60 +++++++ packages/ui/src/lib/api-base.ts | 4 + packages/ui/src/lib/api-client.ts | 9 +- packages/ui/src/lib/auth-recovery.test.ts | 86 ++++++++++ packages/ui/src/lib/auth-recovery.ts | 74 +++++++++ .../ui/src/lib/i18n/messages/de/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/en/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/es/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/fr/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/he/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/ja/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/ne/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/ru/dialogs.ts | 10 ++ .../ui/src/lib/i18n/messages/tr/dialogs.ts | 10 ++ .../src/lib/i18n/messages/zh-Hans/dialogs.ts | 10 ++ packages/ui/src/lib/sdk-manager.ts | 3 +- packages/ui/src/lib/server-events.ts | 5 + .../src/styles/components/auth-recovery.css | 23 +++ packages/ui/src/styles/controls.css | 1 + .../ui/tests/browser/auth-recovery.test.ts | 149 ++++++++++++++++++ .../tests/browser/fixtures/auth-recovery.tsx | 32 ++++ 23 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/auth-recovery-dialog.tsx create mode 100644 packages/ui/src/lib/api-base.ts create mode 100644 packages/ui/src/lib/auth-recovery.test.ts create mode 100644 packages/ui/src/lib/auth-recovery.ts create mode 100644 packages/ui/src/styles/components/auth-recovery.css create mode 100644 packages/ui/tests/browser/auth-recovery.test.ts create mode 100644 packages/ui/tests/browser/fixtures/auth-recovery.tsx diff --git a/AGENTS.md b/AGENTS.md index a2a363123..c882a5080 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size. - Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere. - Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions. +- Authentication recovery uses `lib/auth-recovery.ts` and `components/auth-recovery-dialog.tsx`: confirm CodeNomad auth status before showing an expired-login form, reconnect in place to preserve drafts, and never replay failed mutations. Its shared window styles live in `styles/components/auth-recovery.css`. - Persistent command/search utility windows use `components/dismissible-window.tsx`: non-modal, no scrim, outside interactions keep them open, and explicit toggles use `.icon-toggle` with `aria-expanded`/`aria-controls`. Keep search state scoped to its instance/session and close it when that view becomes inactive. - The composer reserves `/btw` for native `session.generate`, outside ordinary prompt/command submission. Its ephemeral question/answer window uses `DismissibleWindow` and `styles/components/session-aside.css`; cancellation and inactive/session transitions fence late results without interrupting the main session. - OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 8b9ce64f6..5b34b142f 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -4,6 +4,7 @@ import { Toaster } from "solid-toast" import useMediaQuery from "@suid/material/useMediaQuery" import { Minimize2 } from "lucide-solid" import AlertDialog from "./components/alert-dialog" +import AuthRecoveryDialog from "./components/auth-recovery-dialog" import FolderSelectionView from "./components/folder-selection-view" import { useDesktopFolderLaunch } from "./lib/hooks/use-electron-folder-launch" import { showConfirmDialog } from "./stores/alerts" @@ -898,6 +899,7 @@ const App: Component = () => { setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> + ("") + createEffect(() => { if (!authRecovery.required()) { setPassword(""); setError("") } }) + onMount(() => { + const check = () => { if (authRecovery.required()) void authRecovery.check() } + window.addEventListener("focus", check) + onCleanup(() => window.removeEventListener("focus", check)) + }) + + async function signIn(event: SubmitEvent) { + event.preventDefault() + if (pending()) return + setPending(true) + setError("") + const result = await authRecovery.signIn(username(), password()) + setPassword("") + if (result !== "ok") setError(result) + setPending(false) + } + + return ( + + + + { event.preventDefault(); event.stopImmediatePropagation() }} + onInteractOutside={event => event.preventDefault()}> +
{t("authRecovery.title")}
+
+
+ {t("authRecovery.description")} +

{t("authRecovery.drafts")}

+ + setUsername(event.currentTarget.value)} disabled={pending()} /> + + setPassword(event.currentTarget.value)} disabled={pending()} /> +

{t(error() === "credentials" ? "authRecovery.credentials" : "authRecovery.unavailable")}

+
+ +
+
+
+
+ ) +} diff --git a/packages/ui/src/lib/api-base.ts b/packages/ui/src/lib/api-base.ts new file mode 100644 index 000000000..db1deee32 --- /dev/null +++ b/packages/ui/src/lib/api-base.ts @@ -0,0 +1,4 @@ +const runtimeBase = typeof window !== "undefined" ? window.location?.origin : undefined +const defaultBase = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? runtimeBase : undefined + +export const CODENOMAD_API_BASE = import.meta.env?.VITE_CODENOMAD_API_BASE ?? defaultBase diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 536cb9cfb..ff6f67312 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -53,11 +53,10 @@ import { getClientIdentity } from "./client-identity" import { getLogger } from "./logger" import { attachEventSourceHandlers } from "./event-source-handlers" import { HttpResponseError, retryFileSearch } from "./retryable-file-search" +import { authenticatedFetch } from "./auth-recovery" +import { CODENOMAD_API_BASE as API_BASE } from "./api-base" -const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined -const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events" -const API_BASE = import.meta.env?.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH) export const CODENOMAD_API_BASE = API_BASE @@ -136,7 +135,7 @@ async function request(path: string, init?: RequestInit): Promise { logHttp(`${method} ${path}`) try { - const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) + const response = await authenticatedFetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) if (!response.ok) { const message = await readErrorMessage(response) logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) @@ -165,7 +164,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise { const startedAt = Date.now() logHttp(`${method} ${path}`) - const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) + const response = await authenticatedFetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) if (!response.ok) { const message = await readErrorMessage(response) logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) diff --git a/packages/ui/src/lib/auth-recovery.test.ts b/packages/ui/src/lib/auth-recovery.test.ts new file mode 100644 index 000000000..f820a250d --- /dev/null +++ b/packages/ui/src/lib/auth-recovery.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { createAuthRecovery } from "./auth-recovery" + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +test("coalesces status checks and only explicit unauthenticated status opens recovery", async () => { + let calls = 0 + const response = deferred() + const recovery = createAuthRecovery("https://fixture.invalid", async (input, init) => { + calls++ + assert.equal(String(input), "https://fixture.invalid/api/auth/status") + assert.equal(init?.credentials, "include") + assert.equal(init?.cache, "no-store") + return response.promise + }) + const first = recovery.check(), second = recovery.check() + assert.equal(calls, 1) + response.resolve(Response.json({ authenticated: false })) + await Promise.all([first, second]) + assert.equal(recovery.required(), true) + for (const body of [{}, { authenticated: "false" }, { authenticated: true }]) { + const other = createAuthRecovery(undefined, async () => Response.json(body)) + await other.check() + assert.equal(other.required(), false) + } + for (const result of [async () => new Response("down", { status: 503 }), async () => { throw Error("offline") }]) { + const other = createAuthRecovery(undefined, result) + await other.check() + assert.equal(other.required(), false) + } +}) + +test("login fences probes begun before and during authentication", async () => { + for (const during of [false, true]) { + const status = deferred(), login = deferred() + let restored = 0, requests = 0 + const recovery = createAuthRecovery(undefined, async (input) => { + if (String(input).endsWith("/login")) return login.promise + requests++ + return requests === 1 ? Response.json({ authenticated: false }) : status.promise + }) + recovery.onRestored(() => { restored++ }) + await recovery.check() + const pendingStatus = during ? undefined : recovery.check() + const pendingLogin = recovery.signIn("person", "secret") + const duringStatus = during ? recovery.check() : undefined + login.resolve(Response.json({ ok: true })) + assert.equal(await pendingLogin, "ok") + status.resolve(Response.json({ authenticated: false })) + await Promise.all([pendingStatus, duringStatus]) + assert.equal(recovery.required(), false) + assert.equal(restored, 1) + } +}) + +test("wrong credentials, offline login and malformed success preserve recovery without replay", async () => { + for (const [status, body, expected] of [[401, {}, "credentials"], [503, {}, "unavailable"], [200, {}, "unavailable"]] as const) { + const requests: string[] = [] + const recovery = createAuthRecovery(undefined, async (input) => { + requests.push(String(input)) + return String(input).endsWith("/status") ? Response.json({ authenticated: false }) : Response.json(body, { status }) + }) + await recovery.check() + assert.equal(await recovery.signIn("person", "secret"), expected) + assert.equal(recovery.required(), true) + assert.deepEqual(requests, ["/api/auth/status", "/api/auth/login"]) + } +}) + +test("a renewed cookie in another tab restores once without a login POST", async () => { + let authenticated = false, notifications = 0 + const recovery = createAuthRecovery(undefined, async () => Response.json({ authenticated })) + const unsubscribe = recovery.onRestored(() => { notifications++ }) + await recovery.check() + authenticated = true + await recovery.check() + await recovery.check() + assert.equal(recovery.required(), false) + assert.equal(notifications, 1) + unsubscribe() +}) diff --git a/packages/ui/src/lib/auth-recovery.ts b/packages/ui/src/lib/auth-recovery.ts new file mode 100644 index 000000000..2eef2bcc4 --- /dev/null +++ b/packages/ui/src/lib/auth-recovery.ts @@ -0,0 +1,74 @@ +import { createSignal } from "solid-js" +import { CODENOMAD_API_BASE } from "./api-base" + +/** Browser login recovery only. Never retries a failed application request or + * interprets an upstream OpenCode 401 as proof of an expired CodeNomad login. */ +export function createAuthRecovery(base: string | undefined, fetcher: typeof fetch = (...args) => fetch(...args)) { + const [required, setRequired] = createSignal(false) + const restored = new Set<() => void>() + let generation = 0 + let checking: Promise | undefined + const url = (path: string) => base ? new URL(path, base).toString() : path + + function authenticated() { + const wasRequired = required() + setRequired(false) + if (wasRequired) for (const handler of restored) handler() + } + + function check(): Promise { + if (checking) return checking + const current = generation + const pending = (async () => { + try { + const response = await fetcher(url("/api/auth/status"), { + credentials: "include", cache: "no-store", signal: AbortSignal.timeout(8000), + }) + if (!response.ok) return + const status = await response.json() + if (current !== generation) return + if (status?.authenticated === false) setRequired(true) + else if (status?.authenticated === true) authenticated() + } catch { /* Offline/unreachable is not evidence of an expired login. */ } + })() + checking = pending + void pending.finally(() => { if (checking === pending) checking = undefined }) + return pending + } + + async function signIn(username: string, password: string): Promise<"ok" | "credentials" | "unavailable"> { + // Fence probes started before this login, including their response bodies. + const current = ++generation + checking = undefined + try { + const response = await fetcher(url("/api/auth/login"), { + method: "POST", credentials: "include", cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), signal: AbortSignal.timeout(15000), + }) + if (current !== generation) return "unavailable" + if (response.status === 401) return "credentials" + if (!response.ok) return "unavailable" + const body = await response.json() + if (current !== generation || body?.ok !== true) return "unavailable" + // A probe issued while login was in flight may still carry the old cookie. + generation++ + checking = undefined + authenticated() + return "ok" + } catch { return "unavailable" } + } + + return { + required, check, signIn, + onRestored(handler: () => void) { restored.add(handler); return () => restored.delete(handler) }, + } +} + +export const authRecovery = createAuthRecovery(CODENOMAD_API_BASE) + +export const authenticatedFetch: typeof fetch = async (input, init) => { + const response = await globalThis.fetch(input, init) + if (response.status === 401) void authRecovery.check() + return response +} diff --git a/packages/ui/src/lib/i18n/messages/de/dialogs.ts b/packages/ui/src/lib/i18n/messages/de/dialogs.ts index 1331bb022..1e72ecf56 100644 --- a/packages/ui/src/lib/i18n/messages/de/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/de/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "Erneut bei CodeNomad anmelden", + "authRecovery.description": "Ihre CodeNomad-Anmeldung ist nicht mehr gültig. Dies kann nach einem Serverneustart passieren. Melden Sie sich erneut an, um die Verbindung wiederherzustellen.", + "authRecovery.drafts": "Diese Seite bleibt geöffnet, damit Ihre Entwürfe erhalten bleiben. Wiederholen Sie nach der Anmeldung die fehlgeschlagene Aktion.", + "authRecovery.username": "Benutzername", + "authRecovery.password": "Passwort", + "authRecovery.signIn": "Anmelden", + "authRecovery.pending": "Anmeldung läuft…", + "authRecovery.check": "Verbindung prüfen", + "authRecovery.credentials": "Benutzername oder Passwort ist falsch.", + "authRecovery.unavailable": "Anmeldung nicht möglich. Prüfen Sie, ob der CodeNomad-Server erreichbar ist, und versuchen Sie es erneut.", "alertDialog.fallbackTitle.info": "Hinweis", "alertDialog.fallbackTitle.warning": "Bitte überprüfen", "alertDialog.fallbackTitle.error": "Etwas ist schiefgelaufen", diff --git a/packages/ui/src/lib/i18n/messages/en/dialogs.ts b/packages/ui/src/lib/i18n/messages/en/dialogs.ts index 5f23b8c83..157aa16b5 100644 --- a/packages/ui/src/lib/i18n/messages/en/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/en/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "Sign in to CodeNomad again", + "authRecovery.description": "Your CodeNomad login is no longer valid. This can happen after the server restarts. Sign in again to reconnect.", + "authRecovery.drafts": "This page stays open to keep your drafts. After reconnecting, retry the action that failed.", + "authRecovery.username": "Username", + "authRecovery.password": "Password", + "authRecovery.signIn": "Sign in", + "authRecovery.pending": "Signing in…", + "authRecovery.check": "Check connection", + "authRecovery.credentials": "The username or password is incorrect.", + "authRecovery.unavailable": "Unable to sign in. Check that the CodeNomad server is available and try again.", "alertDialog.fallbackTitle.info": "Heads up", "alertDialog.fallbackTitle.warning": "Please review", "alertDialog.fallbackTitle.error": "Something went wrong", diff --git a/packages/ui/src/lib/i18n/messages/es/dialogs.ts b/packages/ui/src/lib/i18n/messages/es/dialogs.ts index 07d89db3c..f2a4ff779 100644 --- a/packages/ui/src/lib/i18n/messages/es/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/es/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "Volver a iniciar sesión en CodeNomad", + "authRecovery.description": "Tu sesión de CodeNomad ya no es válida. Esto puede ocurrir después de reiniciar el servidor. Inicia sesión de nuevo para reconectarte.", + "authRecovery.drafts": "Esta página permanece abierta para conservar tus borradores. Tras reconectarte, vuelve a intentar la acción que falló.", + "authRecovery.username": "Nombre de usuario", + "authRecovery.password": "Contraseña", + "authRecovery.signIn": "Iniciar sesión", + "authRecovery.pending": "Iniciando sesión…", + "authRecovery.check": "Comprobar conexión", + "authRecovery.credentials": "El nombre de usuario o la contraseña son incorrectos.", + "authRecovery.unavailable": "No se pudo iniciar sesión. Comprueba que el servidor CodeNomad esté disponible e inténtalo de nuevo.", "alertDialog.fallbackTitle.info": "Aviso", "alertDialog.fallbackTitle.warning": "Por favor revisa", "alertDialog.fallbackTitle.error": "Algo salió mal", diff --git a/packages/ui/src/lib/i18n/messages/fr/dialogs.ts b/packages/ui/src/lib/i18n/messages/fr/dialogs.ts index a9789362c..23c7da94b 100644 --- a/packages/ui/src/lib/i18n/messages/fr/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/fr/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "Se reconnecter à CodeNomad", + "authRecovery.description": "Votre connexion à CodeNomad n’est plus valide. Cela peut arriver après un redémarrage du serveur. Identifiez-vous à nouveau pour vous reconnecter.", + "authRecovery.drafts": "Cette page reste ouverte pour conserver vos brouillons. Après la reconnexion, réessayez l’action qui a échoué.", + "authRecovery.username": "Nom d’utilisateur", + "authRecovery.password": "Mot de passe", + "authRecovery.signIn": "Se connecter", + "authRecovery.pending": "Connexion…", + "authRecovery.check": "Vérifier la connexion", + "authRecovery.credentials": "Le nom d’utilisateur ou le mot de passe est incorrect.", + "authRecovery.unavailable": "Connexion impossible. Vérifiez que le serveur CodeNomad est disponible et réessayez.", "alertDialog.fallbackTitle.info": "Attention", "alertDialog.fallbackTitle.warning": "Veuillez vérifier", "alertDialog.fallbackTitle.error": "Un problème est survenu", diff --git a/packages/ui/src/lib/i18n/messages/he/dialogs.ts b/packages/ui/src/lib/i18n/messages/he/dialogs.ts index d224460f3..7966a11fb 100644 --- a/packages/ui/src/lib/i18n/messages/he/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/he/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "התחברות מחדש ל-CodeNomad", + "authRecovery.description": "ההתחברות שלך ל-CodeNomad אינה תקפה עוד. זה יכול לקרות לאחר הפעלה מחדש של השרת. יש להתחבר שוב כדי לחדש את החיבור.", + "authRecovery.drafts": "הדף נשאר פתוח כדי לשמור על הטיוטות שלך. לאחר החיבור מחדש, יש לנסות שוב את הפעולה שנכשלה.", + "authRecovery.username": "שם משתמש", + "authRecovery.password": "סיסמה", + "authRecovery.signIn": "התחברות", + "authRecovery.pending": "מתחבר…", + "authRecovery.check": "בדיקת חיבור", + "authRecovery.credentials": "שם המשתמש או הסיסמה שגויים.", + "authRecovery.unavailable": "לא ניתן להתחבר. יש לבדוק ששרת CodeNomad זמין ולנסות שוב.", "alertDialog.fallbackTitle.info": "לתשומת לבך", "alertDialog.fallbackTitle.warning": "נא לבדוק", "alertDialog.fallbackTitle.error": "משהו השתבש", diff --git a/packages/ui/src/lib/i18n/messages/ja/dialogs.ts b/packages/ui/src/lib/i18n/messages/ja/dialogs.ts index c060ba896..11f84c6ef 100644 --- a/packages/ui/src/lib/i18n/messages/ja/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/ja/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "CodeNomad に再ログイン", + "authRecovery.description": "CodeNomad のログインが無効になりました。サーバーの再起動後に発生することがあります。再接続するには、もう一度ログインしてください。", + "authRecovery.drafts": "下書きを保持するため、このページは開いたままになります。再接続後、失敗した操作をもう一度実行してください。", + "authRecovery.username": "ユーザー名", + "authRecovery.password": "パスワード", + "authRecovery.signIn": "ログイン", + "authRecovery.pending": "ログイン中…", + "authRecovery.check": "接続を確認", + "authRecovery.credentials": "ユーザー名またはパスワードが正しくありません。", + "authRecovery.unavailable": "ログインできません。CodeNomad サーバーが利用可能か確認して、もう一度お試しください。", "alertDialog.fallbackTitle.info": "お知らせ", "alertDialog.fallbackTitle.warning": "ご確認ください", "alertDialog.fallbackTitle.error": "問題が発生しました", diff --git a/packages/ui/src/lib/i18n/messages/ne/dialogs.ts b/packages/ui/src/lib/i18n/messages/ne/dialogs.ts index 792f0bc92..ddb700482 100644 --- a/packages/ui/src/lib/i18n/messages/ne/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/ne/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "CodeNomad मा फेरि साइन इन गर्नुहोस्", + "authRecovery.description": "तपाईंको CodeNomad लगइन अब मान्य छैन। सर्भर पुनः सुरु भएपछि यस्तो हुन सक्छ। पुनः जडान गर्न फेरि साइन इन गर्नुहोस्।", + "authRecovery.drafts": "तपाईंका मस्यौदाहरू सुरक्षित राख्न यो पृष्ठ खुला रहन्छ। पुनः जडान भएपछि असफल भएको कार्य फेरि प्रयास गर्नुहोस्।", + "authRecovery.username": "प्रयोगकर्ता नाम", + "authRecovery.password": "पासवर्ड", + "authRecovery.signIn": "साइन इन गर्नुहोस्", + "authRecovery.pending": "साइन इन हुँदै…", + "authRecovery.check": "जडान जाँच्नुहोस्", + "authRecovery.credentials": "प्रयोगकर्ता नाम वा पासवर्ड गलत छ।", + "authRecovery.unavailable": "साइन इन गर्न सकिएन। CodeNomad सर्भर उपलब्ध छ कि छैन जाँचेर फेरि प्रयास गर्नुहोस्।", "alertDialog.fallbackTitle.info": "जानकारी", "alertDialog.fallbackTitle.warning": "कृपया समीक्षा गर्नुहोस्", "alertDialog.fallbackTitle.error": "केहि गलत भयो", diff --git a/packages/ui/src/lib/i18n/messages/ru/dialogs.ts b/packages/ui/src/lib/i18n/messages/ru/dialogs.ts index 017d6b59c..d51adbfbb 100644 --- a/packages/ui/src/lib/i18n/messages/ru/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/ru/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "Войдите в CodeNomad снова", + "authRecovery.description": "Ваш вход в CodeNomad больше не действителен. Это может произойти после перезапуска сервера. Войдите снова, чтобы восстановить соединение.", + "authRecovery.drafts": "Страница остаётся открытой, чтобы сохранить черновики. После подключения повторите неудавшееся действие.", + "authRecovery.username": "Имя пользователя", + "authRecovery.password": "Пароль", + "authRecovery.signIn": "Войти", + "authRecovery.pending": "Вход…", + "authRecovery.check": "Проверить соединение", + "authRecovery.credentials": "Неверное имя пользователя или пароль.", + "authRecovery.unavailable": "Не удалось войти. Убедитесь, что сервер CodeNomad доступен, и повторите попытку.", "alertDialog.fallbackTitle.info": "Внимание", "alertDialog.fallbackTitle.warning": "Пожалуйста, проверьте", "alertDialog.fallbackTitle.error": "Что-то пошло не так", diff --git a/packages/ui/src/lib/i18n/messages/tr/dialogs.ts b/packages/ui/src/lib/i18n/messages/tr/dialogs.ts index e9bb54770..b88b90487 100644 --- a/packages/ui/src/lib/i18n/messages/tr/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/tr/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "CodeNomad’a yeniden giriş yapın", + "authRecovery.description": "CodeNomad oturumunuz artık geçerli değil. Bu, sunucu yeniden başlatıldıktan sonra olabilir. Yeniden bağlanmak için tekrar giriş yapın.", + "authRecovery.drafts": "Taslaklarınızı korumak için bu sayfa açık kalır. Yeniden bağlandıktan sonra başarısız olan işlemi tekrar deneyin.", + "authRecovery.username": "Kullanıcı adı", + "authRecovery.password": "Parola", + "authRecovery.signIn": "Giriş yap", + "authRecovery.pending": "Giriş yapılıyor…", + "authRecovery.check": "Bağlantıyı kontrol et", + "authRecovery.credentials": "Kullanıcı adı veya parola yanlış.", + "authRecovery.unavailable": "Giriş yapılamadı. CodeNomad sunucusunun kullanılabilir olduğunu kontrol edip tekrar deneyin.", "alertDialog.fallbackTitle.info": "Dikkat", "alertDialog.fallbackTitle.warning": "Gözden geçirin", "alertDialog.fallbackTitle.error": "Bir şeyler ters gitti", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/dialogs.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/dialogs.ts index ed088b847..306a35eb4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/dialogs.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/dialogs.ts @@ -1,4 +1,14 @@ export const dialogMessages = { + "authRecovery.title": "重新登录 CodeNomad", + "authRecovery.description": "你的 CodeNomad 登录已失效。这可能发生在服务器重启之后。请重新登录以恢复连接。", + "authRecovery.drafts": "此页面将保持打开以保留草稿。重新连接后,请重试失败的操作。", + "authRecovery.username": "用户名", + "authRecovery.password": "密码", + "authRecovery.signIn": "登录", + "authRecovery.pending": "正在登录…", + "authRecovery.check": "检查连接", + "authRecovery.credentials": "用户名或密码不正确。", + "authRecovery.unavailable": "无法登录。请检查 CodeNomad 服务器是否可用,然后重试。", "alertDialog.fallbackTitle.info": "提示", "alertDialog.fallbackTitle.warning": "请确认", "alertDialog.fallbackTitle.error": "出错了", diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index b8de429e4..1fc440d80 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -1,6 +1,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode/client" import { CODENOMAD_API_BASE } from "./api-client" import { backgroundReads } from "./background-read-queue" +import { authenticatedFetch } from "./auth-recovery" import { prioritizedRead } from "./prioritized-read" import { SESSION_ENVIRONMENT_FAILED_ERROR_CODE } from "../../../server/src/api-types" @@ -48,7 +49,7 @@ export function createInstanceFetch(baseUrl: string, isForeground: () => boolean const requestUrl = new URL(input instanceof Request ? input.url : input) const relativeUrl = `${requestUrl.pathname.replace(/^\/+/, "")}${requestUrl.search}` const read = async () => { - const response = await globalThis.fetch(new URL(relativeUrl, baseUrl), { + const response = await authenticatedFetch(new URL(relativeUrl, baseUrl), { ...init, credentials: init?.credentials ?? "include", }) diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 7c3433d29..29d834207 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -9,6 +9,7 @@ import { } from "./event-transport" import { getLogger } from "./logger" import { retryWithBackoff, isRetryableError } from "./retry-utils" +import { authRecovery } from "./auth-recovery" const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 @@ -32,6 +33,7 @@ class ServerEvents { private retryTimer: ReturnType | null = null constructor() { + authRecovery.onRestored(() => this.restart("authentication restored")) void this.connect() } @@ -113,6 +115,9 @@ class ServerEvents { } private scheduleReconnect() { + // EventSource hides HTTP status. Probe our own auth endpoint so a server + // restart is recoverable even when the user has not made another API call. + void authRecovery.check() if (this.retryTimer) { return } diff --git a/packages/ui/src/styles/components/auth-recovery.css b/packages/ui/src/styles/components/auth-recovery.css new file mode 100644 index 000000000..b39f42675 --- /dev/null +++ b/packages/ui/src/styles/components/auth-recovery.css @@ -0,0 +1,23 @@ +.auth-recovery-overlay { + z-index: 1400; +} + +.auth-recovery-window { + position: fixed; + inset-block-start: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + z-index: 1401; + width: min(28rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + overflow: auto; +} + +[dir="rtl"] .auth-recovery-window { + transform: translate(50%, -50%); +} + +.auth-recovery-window label { + display: block; + margin-block: var(--space-sm) var(--space-xs); +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index cd2bc34c6..4fd5cac6c 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -1,6 +1,7 @@ @import "./components/buttons.css"; @import "./components/switches.css"; @import "./components/window.css"; +@import "./components/auth-recovery.css"; @import "./components/session-aside.css"; @import "./components/history-search.css"; @import "./components/native-titlebar.css"; diff --git a/packages/ui/tests/browser/auth-recovery.test.ts b/packages/ui/tests/browser/auth-recovery.test.ts new file mode 100644 index 000000000..b76e3f04c --- /dev/null +++ b/packages/ui/tests/browser/auth-recovery.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict" +import { after, before, test } from "node:test" +import { fileURLToPath } from "node:url" +import { chromium, type Browser } from "playwright" +import { createServer, type ViteDevServer } from "vite" +import solid from "vite-plugin-solid" +import Fastify, { type FastifyInstance } from "fastify" +import type { ServerResponse } from "node:http" +import { AuthManager } from "../../../server/src/auth/manager" +import { sendUnauthorized } from "../../../server/src/auth/http-auth" +import { registerAuthRoutes } from "../../../server/src/server/routes/auth" + +let server: ViteDevServer, backend: FastifyInstance, browser: Browser, url: string +let auth: AuthManager, mutationAttempts = 0, loginCount = 0, offline = false +const streams = new Set() +const logger: any = { debug() {}, warn() {}, child() { return this } } +const newAuth = () => new AuthManager({ configPath: fileURLToPath(new URL("./unused-auth-fixture/config.json", import.meta.url)), + username: "fixture", password: "fixture-only", generateToken: false }, logger) +function restart() { + auth = newAuth() + for (const stream of streams) stream.end() + streams.clear() +} +before(async () => { + auth = newAuth() + backend = Fastify() + // Real auth routes and in-memory session manager; swapping the manager models + // a backend restart without touching a daemon, profile or database. + registerAuthRoutes(backend, { authManager: new Proxy({} as AuthManager, { get: (_target, key) => { + const value = (auth as any)[key] + return typeof value === "function" ? value.bind(auth) : value + } }) }) + backend.addHook("preHandler", async (request, reply) => { + if (request.url === "/api/workspaces" && request.method === "POST") mutationAttempts++ + if (request.url === "/api/auth/login") loginCount++ + if (offline) return reply.code(503).send({ error: "Unavailable" }) + if (request.url.startsWith("/api/auth/")) return + if (!auth.getSessionFromRequest(request)) return sendUnauthorized(request, reply) + }) + backend.get("/api/events", (_request, reply) => { + reply.hijack() + const stream = reply.raw + stream.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }) + stream.write(": connected\n\n") + streams.add(stream) + stream.on("close", () => streams.delete(stream)) + }) + backend.get("/api/workspaces", async () => []) + backend.post("/api/workspaces", async () => ({ id: "fixture" })) + backend.get("/workspaces/fixture/instance/api/session", async (_request, reply) => reply.code(401).send({ error: "Upstream credentials" })) + backend.all("/api/*", async () => ({})) + await backend.listen({ host: "127.0.0.1", port: 0 }) + const target = `http://127.0.0.1:${(backend.server.address() as { port: number }).port}` + server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", + plugins: [solid(), { name: "auth-fixture", configureServer(s) { + s.middlewares.use("/auth-fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await s.transformIndexHtml("/auth-fixture", '
')) + }) + } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: { "/api": target, "/workspaces": target } }, + }) + await server.listen() + url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) +}) +after(async () => { + await browser?.close() + for (const stream of streams) stream.end() + await server?.close() + await backend?.close() +}) +async function setup(width = 1100) { + offline = false + restart() + const page = await browser.newPage({ locale: "en-US", viewport: { width, height: 800 } }) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + await page.request.post(`${url}/api/auth/login`, { data: { username: "fixture", password: "fixture-only" } }) + await page.goto(`${url}/auth-fixture`) + await page.waitForFunction(() => (window as any).fixture?.opens() > 0) + return { page, errors } +} + +test("server restart opens recovery via SSE; real login preserves the composer and resumes events", async () => { + const { page, errors } = await setup() + try { + const composer = page.locator(".prompt-input-container textarea").first() + await composer.fill("UNSENT_DRAFT") + await page.evaluate(() => (window as any).fixture.attach()) + const opens = await page.evaluate(() => (window as any).fixture.opens()) + const previousUrl = page.url() + restart() + const dialog = page.getByRole("dialog", { name: "Sign in to CodeNomad again" }) + await dialog.waitFor() + await dialog.getByLabel("Username", { exact: true }).fill("fixture") + await dialog.getByLabel("Password", { exact: true }).fill("wrong") + await dialog.getByRole("button", { name: "Sign in", exact: true }).click() + await dialog.getByRole("alert").waitFor() + assert.equal(await dialog.getByLabel("Password", { exact: true }).inputValue(), "") + await dialog.getByLabel("Password", { exact: true }).fill("fixture-only") + await dialog.getByRole("button", { name: "Sign in", exact: true }).click() + await dialog.waitFor({ state: "hidden" }) + await page.waitForFunction(n => (window as any).fixture.opens() > n, opens) + assert.equal(page.url(), previousUrl) + assert.equal(await composer.inputValue(), "UNSENT_DRAFT") + assert.equal(await page.evaluate(() => (window as any).fixture.attachments()), 1) + assert.deepEqual(errors, []) + } finally { await page.close() } +}) + +test("an upstream 401 and an offline server do not ask for CodeNomad credentials", async () => { + const { page } = await setup() + try { + const checked = page.waitForResponse(response => response.url().endsWith("/api/auth/status")) + await page.evaluate(() => (window as any).fixture.upstream401()) + await checked + assert.equal(await page.getByRole("dialog").count(), 0) + offline = true + const unavailable = page.waitForResponse(response => response.url().endsWith("/api/auth/status") && response.status() === 503) + for (const stream of streams) stream.end() + await unavailable + assert.equal(await page.getByRole("dialog").count(), 0) + } finally { offline = false; await page.close() } +}) + +test("API expiry recovery works above an error dialog and accepts login renewed in another tab", async () => { + const { page } = await setup(360) + try { + // Leave SSE untouched to exercise API failure as the recovery trigger. + auth = newAuth() + const beforeLogin = loginCount + const beforeMutations = mutationAttempts + await page.evaluate(() => (window as any).fixture.openProject()) + const dialog = page.getByRole("dialog", { name: "Sign in to CodeNomad again" }) + await dialog.waitFor() + await dialog.getByLabel("Username", { exact: true }).fill("fixture") + const box = await dialog.boundingBox() + assert.ok(box && box.x >= 0 && box.x + box.width <= 360) + await page.keyboard.press("Escape") + assert.equal(await dialog.isVisible(), true) + if (process.env.CODENOMAD_AUTH_CAPTURE) await page.screenshot({ path: process.env.CODENOMAD_AUTH_CAPTURE }) + await page.request.post(`${url}/api/auth/login`, { data: { username: "fixture", password: "fixture-only" } }) + await dialog.getByRole("button", { name: "Check connection" }).click() + await dialog.waitFor({ state: "hidden" }) + assert.equal(loginCount, beforeLogin + 1) + assert.equal(mutationAttempts, beforeMutations + 1, "Recovery must not replay the failed project creation") + } finally { await page.close() } +}) diff --git a/packages/ui/tests/browser/fixtures/auth-recovery.tsx b/packages/ui/tests/browser/fixtures/auth-recovery.tsx new file mode 100644 index 000000000..f2aabddae --- /dev/null +++ b/packages/ui/tests/browser/fixtures/auth-recovery.tsx @@ -0,0 +1,32 @@ +import { render } from "solid-js/web" +import PromptInput from "../../../src/components/prompt-input" +import AuthRecoveryDialog from "../../../src/components/auth-recovery-dialog" +import AlertDialog from "../../../src/components/alert-dialog" +import { showAlertDialog } from "../../../src/stores/alerts" +import { ConfigProvider } from "../../../src/stores/preferences" +import { I18nProvider } from "../../../src/lib/i18n" +import { ThemeProvider } from "../../../src/lib/theme" +import { serverApi } from "../../../src/lib/api-client" +import { createInstanceFetch } from "../../../src/lib/sdk-manager" +import { serverEvents } from "../../../src/lib/server-events" +import { addAttachment, getAttachments } from "../../../src/stores/attachments" +import { createFileAttachment } from "../../../src/types/attachment" +import "../../../src/index.css" + +let opens = 0 +serverEvents.onOpen(() => { opens++ }) +render(() => + {}} /> + + +, document.getElementById("root")!) +;(window as any).fixture = { + opens: () => opens, + attach: () => addAttachment("auth-fixture", "session", createFileAttachment("/fixture/notes.txt", "notes.txt")), + attachments: () => getAttachments("auth-fixture", "session").length, + openProject: async () => { + try { await serverApi.createWorkspace({ path: "/fixture" }) } + catch (error) { void showAlertDialog(String(error)) } + }, + upstream401: () => createInstanceFetch(`${location.origin}/workspaces/fixture/instance/`)("http://native/api/session"), +} From c64a06dcab415fb20a289146c43c16fc1ce3eedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 22 Sep 2026 19:00:52 +0200 Subject: [PATCH 2/2] fix(auth): preserve live drafts and recovery modal ownership Address gatekeeper findings by capturing live workspace state before restart reconciliation removes unavailable instances, including fresh pages without a startup snapshot and projects opened after that snapshot. Reopening the folder under a new backend identity restores drafts and attachments through existing preservation hydration. Suspend the generic alert's modal surface during authentication recovery while retaining its pending payload and input state. Fence deferred focus so alerts arriving after expiry cannot steal pointer or keyboard ownership from the visible login form. Extend browser coverage to the real capture and instance lifecycle: remove the old composer after an empty post-restart inventory, reopen with a new ID, and verify recovered content. Exercise late generic alerts and failed-mutation non-replay. All four browser scenarios and 33 targeted unit regressions pass. --- packages/ui/src/components/alert-dialog.tsx | 6 +++-- .../src/lib/hooks/use-app-session-capture.ts | 7 +++++ .../stores/app-session-snapshot-merge.test.ts | 15 +++++++++++ .../src/stores/app-session-snapshot-merge.ts | 11 ++++++-- .../ui/tests/browser/auth-recovery.test.ts | 22 ++++++++++++++++ .../tests/browser/fixtures/auth-recovery.tsx | 26 ++++++++++++++++--- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 5f3897917..4a41b0eef 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -3,6 +3,7 @@ import { Component, Show, createEffect, createSignal } from "solid-js" import { alertDialogState, dismissAlertDialog } from "../stores/alerts" import type { AlertVariant, AlertDialogState } from "../stores/alerts" import { useI18n } from "../lib/i18n" +import { authRecovery } from "../lib/auth-recovery" const variantAccent: Record = { info: { @@ -64,9 +65,10 @@ const AlertDialog: Component = () => { createEffect(() => { const state = alertDialogState() - if (!state) return + if (!state || authRecovery.required()) return queueMicrotask(() => { + if (authRecovery.required() || alertDialogState() !== state) return if (state.type === "prompt") { promptInputRef?.focus() promptInputRef?.select() @@ -105,7 +107,7 @@ const AlertDialog: Component = () => { return ( { // Only handle dismiss if dialog is dismissible (default: true) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.ts b/packages/ui/src/lib/hooks/use-app-session-capture.ts index 4b6c3ee3f..534b9855c 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.ts @@ -228,6 +228,13 @@ export function useAppSessionCapture() { onInstanceLifecycleAuthority((event) => { const lifecycleToken = ++nextInstanceLifecycleToken instanceLifecycleTokens.set(event.instanceId, lifecycleToken) + // A fresh page has no startup snapshot. Capture live work before an + // unavailable workspace is removed during backend-restart reconciliation. + if (!preservation && event.type === "unavailable") { + const captured = captureState(scrollAuthority) + preservation = createRestorableSessionPreservation(captured.state) + captured.tabIds.forEach((id, index) => recordRestoredTab(preservation!, index, id)) + } if (!preservation) { if (event.type === "removed") { const authoritativeState = captureState(scrollAuthority).state diff --git a/packages/ui/src/stores/app-session-snapshot-merge.test.ts b/packages/ui/src/stores/app-session-snapshot-merge.test.ts index 1930f2301..63a185c07 100644 --- a/packages/ui/src/stores/app-session-snapshot-merge.test.ts +++ b/packages/ui/src/stores/app-session-snapshot-merge.test.ts @@ -40,6 +40,21 @@ function workspaceAt(state: RestorableSessionState, index = 0): RestorableWorksp } describe("app session snapshot merge", () => { + it("preserves a live workspace opened after the startup snapshot when the backend loses it", () => { + const preservation = createRestorableSessionPreservation(session([workspace("/old")])) + const current = workspace("/new", 0, { drafts: { prompt: "fresh draft" }, attachments: { prompt: [attachment("file")] } }) + const unavailable = { runtimeTabId: "instance:live", folder: "/new", occurrence: 0 } + markPreservedWorkspaceUnavailable(preservation, unavailable, current) + const merged = mergeRestorableSessionState(empty(), preservation) + assert.equal(workspaceAt(merged, 1).drafts.prompt, "fresh draft") + const reopened = { ...unavailable, runtimeTabId: "instance:reopened" } + assert.deepEqual(getPreservedWorkspaceReopenTarget(preservation, reopened)?.snapshot.attachments, current.attachments) + markPreservedWorkspaceReopened(preservation, reopened) + assert.equal(hasRestoredTabBinding(preservation, 1, reopened.runtimeTabId), true) + markPreservedWorkspaceRemoved(preservation, reopened) + assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 1, "Explicit removal still wins") + }) + for (const settled of [false, true]) it(`does not rebind a ${settled ? "settled" : "pending"} restored window to a later global folder occurrence`, () => { const saved = session([workspace("C:\\work", 3, { activeSessionId: "selected", activeParentSessionId: "selected", drafts: { selected: "window draft" }, diff --git a/packages/ui/src/stores/app-session-snapshot-merge.ts b/packages/ui/src/stores/app-session-snapshot-merge.ts index f77e6cebb..0585e6f83 100644 --- a/packages/ui/src/stores/app-session-snapshot-merge.ts +++ b/packages/ui/src/stores/app-session-snapshot-merge.ts @@ -308,8 +308,15 @@ export function markPreservedWorkspaceUnavailable( current?: RestorableWorkspaceTabState, authority?: RestorableWorkspaceRuntimeAuthority, ): RestorableSessionPreservation { - const index = findWorkspaceSourceIndex(preservation, workspace) - if (index === undefined) return preservation + let index = findWorkspaceSourceIndex(preservation, workspace) + if (index === undefined) { + // Workspaces opened after startup are not present in the saved snapshot. + if (!current) return preservation + index = preservation.sourceTabs.length + preservation.sourceTabs.push(current) + preservation.results.push({ status: "pending" }) + preservation.removalRevisions.push(0) + } if (preservation.results[index]?.status === "removed") return preservation const source = preservation.sourceTabs[index] if (current) preservation.sourceTabs[index] = source?.kind === "workspace" diff --git a/packages/ui/tests/browser/auth-recovery.test.ts b/packages/ui/tests/browser/auth-recovery.test.ts index b76e3f04c..ca6fee268 100644 --- a/packages/ui/tests/browser/auth-recovery.test.ts +++ b/packages/ui/tests/browser/auth-recovery.test.ts @@ -79,6 +79,7 @@ async function setup(width = 1100) { await page.request.post(`${url}/api/auth/login`, { data: { username: "fixture", password: "fixture-only" } }) await page.goto(`${url}/auth-fixture`) await page.waitForFunction(() => (window as any).fixture?.opens() > 0) + await page.evaluate(() => (window as any).fixture.seed()) return { page, errors } } @@ -103,12 +104,33 @@ test("server restart opens recovery via SSE; real login preserves the composer a await dialog.waitFor({ state: "hidden" }) await page.waitForFunction(n => (window as any).fixture.opens() > n, opens) assert.equal(page.url(), previousUrl) + // The restarted backend has no workspaces. Normal reconciliation unmounts + // the old composer; reopening the project under a new ID restores its state. + await composer.waitFor({ state: "hidden" }) + await page.evaluate(() => (window as any).fixture.seed("reopened-fixture")) + await composer.waitFor() + await page.waitForFunction(() => (document.querySelector(".prompt-input-container textarea") as HTMLTextAreaElement)?.value === "UNSENT_DRAFT") assert.equal(await composer.inputValue(), "UNSENT_DRAFT") assert.equal(await page.evaluate(() => (window as any).fixture.attachments()), 1) assert.deepEqual(errors, []) } finally { await page.close() } }) +test("a late generic alert cannot steal focus or pointer events from auth recovery", async () => { + const { page } = await setup() + try { + restart() + const dialog = page.getByRole("dialog", { name: "Sign in to CodeNomad again" }) + await dialog.waitFor() + await page.evaluate(() => (window as any).fixture.lateAlert()) + await dialog.getByLabel("Username", { exact: true }).fill("fixture") + await dialog.getByLabel("Password", { exact: true }).fill("fixture-only") + await dialog.getByRole("button", { name: "Sign in", exact: true }).click() + await dialog.waitFor({ state: "hidden" }) + await page.getByText("Late failure", { exact: true }).waitFor() + } finally { await page.close() } +}) + test("an upstream 401 and an offline server do not ask for CodeNomad credentials", async () => { const { page } = await setup() try { diff --git a/packages/ui/tests/browser/fixtures/auth-recovery.tsx b/packages/ui/tests/browser/fixtures/auth-recovery.tsx index f2aabddae..7a7f26698 100644 --- a/packages/ui/tests/browser/fixtures/auth-recovery.tsx +++ b/packages/ui/tests/browser/fixtures/auth-recovery.tsx @@ -1,4 +1,5 @@ import { render } from "solid-js/web" +import { createSignal, Show } from "solid-js" import PromptInput from "../../../src/components/prompt-input" import AuthRecoveryDialog from "../../../src/components/auth-recovery-dialog" import AlertDialog from "../../../src/components/alert-dialog" @@ -11,19 +12,38 @@ import { createInstanceFetch } from "../../../src/lib/sdk-manager" import { serverEvents } from "../../../src/lib/server-events" import { addAttachment, getAttachments } from "../../../src/stores/attachments" import { createFileAttachment } from "../../../src/types/attachment" +import { addInstance, instances } from "../../../src/stores/instances" +import { attachInstanceTab, getInstanceAppTabId, selectAppTab } from "../../../src/stores/app-tabs" +import { useAppSessionCapture } from "../../../src/lib/hooks/use-app-session-capture" import "../../../src/index.css" let opens = 0 +const [instanceId, setInstanceId] = createSignal("auth-fixture") +const sessionId = "__no_session_draft__" serverEvents.onOpen(() => { opens++ }) +function Composer() { + const capture = useAppSessionCapture() + capture.start() + return + {}} /> + +} render(() => - {}} /> + , document.getElementById("root")!) ;(window as any).fixture = { opens: () => opens, - attach: () => addAttachment("auth-fixture", "session", createFileAttachment("/fixture/notes.txt", "notes.txt")), - attachments: () => getAttachments("auth-fixture", "session").length, + seed: (id = "auth-fixture") => { + setInstanceId(id) + addInstance({ id, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready" }) + attachInstanceTab(id) + selectAppTab(getInstanceAppTabId(id)) + }, + attach: () => addAttachment(instanceId(), sessionId, createFileAttachment("/fixture/notes.txt", "notes.txt")), + attachments: () => getAttachments(instanceId(), sessionId).length, + lateAlert: () => { void showAlertDialog("Late failure") }, openProject: async () => { try { await serverApi.createWorkspace({ path: "/fixture" }) } catch (error) { void showAlertDialog(String(error)) }