From 29d04820124a3dcf50e2663bd026e97ca1242fe9 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Mon, 14 Sep 2026 21:37:27 +0400 Subject: [PATCH 1/3] feat(dashboard): opt-in remember admin token on this device for standalone sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone home-screen web apps on iOS never receive browser password AutoFill or save prompts — WebKit does not render them outside the browser chrome (#4644). This adds an opt-in checkbox on the admin-token dialog that stores the accepted token in localStorage so the next visit signs in without re-prompting. The token is cleared when the checkbox is unchecked or a remembered token is rejected by the server. --- gui/src/admin-token-dialog.ts | 31 +++++++++++++++++++- gui/src/api.ts | 10 ++++++- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/tests/admin-token-dialog.test.ts | 31 ++++++++++++++++++++ gui/tests/api-auth-memory.test.ts | 44 +++++++++++++++++++++++++++- 13 files changed, 122 insertions(+), 3 deletions(-) diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index cb383fe7d6..406a131e91 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -7,6 +7,22 @@ const ADMIN_TOKEN_DOCS_URL = "https://opencodex.me/guides/web-dashboard/#finding export type AdminTokenValidation = "accepted" | "rejected" | "unavailable"; export type AdminTokenVerifier = (token: string) => Promise; +const REMEMBERED_ADMIN_TOKEN_KEY = "opencodex.remembered-admin-token"; + +/** + * Opt-in plaintext persistence in localStorage: this is what makes sign-in + * work in iOS standalone home-screen web apps, where Safari never offers + * password AutoFill or save. Readable by any script on this origin; the + * dashboard bundles no third-party scripts. + */ +export function getRememberedAdminToken(): string | null { + try { return localStorage.getItem(REMEMBERED_ADMIN_TOKEN_KEY); } catch { return null; } +} + +export function clearRememberedAdminToken(): void { + try { localStorage.removeItem(REMEMBERED_ADMIN_TOKEN_KEY); } catch { /* storage may be disabled */ } +} + /** * Ask for the management credential with a real sign-in form so browsers and * password managers can offer save/autofill. OpenCodex itself still keeps the @@ -91,6 +107,14 @@ export function promptForAdminToken( help.append(" ", docsLink); tokenField.append(help); + const rememberField = document.createElement("label"); + rememberField.className = "field-label"; + rememberField.style.cssText = "display:flex;gap:8px;align-items:center;margin-top:var(--space-4);"; + const remember = document.createElement("input"); + remember.id = `${ADMIN_TOKEN_DIALOG_ID}-remember`; + remember.type = "checkbox"; + rememberField.append(remember, document.createTextNode(messages["auth.adminTokenRemember"])); + const validationError = document.createElement("div"); validationError.className = "notice notice-err"; validationError.setAttribute("role", "alert"); @@ -108,7 +132,7 @@ export function promptForAdminToken( submit.textContent = messages["common.ok"]; actions.append(cancel, submit); - form.append(heading, accountField, tokenField, validationError, actions); + form.append(heading, accountField, tokenField, rememberField, validationError, actions); dialog.append(form); /* @@ -147,6 +171,11 @@ export function promptForAdminToken( void verifyToken(token).then((result) => { if (settled) return; if (result === "accepted") { + if (remember.checked) { + try { localStorage.setItem(REMEMBERED_ADMIN_TOKEN_KEY, token); } catch { /* storage may be disabled */ } + } else { + clearRememberedAdminToken(); + } finish(token); return; } diff --git a/gui/src/api.ts b/gui/src/api.ts index 1e99ed6f73..54ef9ac7e7 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,4 +1,4 @@ -import { promptForAdminToken, type AdminTokenVerifier } from "./admin-token-dialog"; +import { clearRememberedAdminToken, getRememberedAdminToken, promptForAdminToken, type AdminTokenVerifier } from "./admin-token-dialog"; import { createBoundedFetch } from "./bounded-fetch"; import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiTarget, type ApiTargets } from "./api-targets"; @@ -283,6 +283,14 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, state.promptCancelled = true; return null; } + const remembered = getRememberedAdminToken(); + if (remembered && remembered !== failedToken) { + if (await verifyAdminToken(plane, remembered) === "accepted") { + state.session = { token: remembered, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin }; + return remembered; + } + clearRememberedAdminToken(); + } const prompted = await requestAdminToken(token => verifyAdminToken(plane, token)); if (prompted) { state.session = { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin }; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index dd5ca2633c..9e2bd48005 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -155,6 +155,7 @@ export const de: Record = { "auth.adminTokenTitle": "OpenCodex-Admin-Token (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "Konto", "auth.adminTokenFieldLabel": "Admin-Token", + "auth.adminTokenRemember": "Auf diesem Gerät merken", "auth.adminTokenRejected": "Der Admin-Token wurde abgelehnt. Prüfen Sie ihn und versuchen Sie es erneut.", "auth.adminTokenUnavailable": "Der Admin-Token konnte nicht überprüft werden. Versuchen Sie es erneut.", "auth.adminTokenHelp": "Dies ist der Admin-Token der OpenCodex-Verwaltungs-API, kein Anbieter-API-Schlüssel. Beim ersten Start schreibt der Proxy ihn nach ~/.opencodex/admin-api-token (oder $OPENCODEX_HOME/admin-api-token); OPENCODEX_ADMIN_AUTH_TOKEN hat Vorrang.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6723d74b81..6804067825 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -90,6 +90,7 @@ export const en = { "auth.adminTokenTitle": "OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "Account", "auth.adminTokenFieldLabel": "Admin token", + "auth.adminTokenRemember": "Remember on this device", "auth.adminTokenRejected": "That admin token was rejected. Check it and try again.", "auth.adminTokenUnavailable": "The admin token could not be verified. Try again.", "auth.adminTokenHelp": "This is the OpenCodex management admin token, not a provider API key. The proxy writes it to ~/.opencodex/admin-api-token (or $OPENCODEX_HOME/admin-api-token) on first start, and OPENCODEX_ADMIN_AUTH_TOKEN overrides it.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 43d956906f..42e5a49899 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -87,6 +87,7 @@ export const fr: Record = { "auth.adminTokenTitle": "Jeton d’administration OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "Compte", "auth.adminTokenFieldLabel": "Jeton d’administration", + "auth.adminTokenRemember": "Mémoriser sur cet appareil", "auth.adminTokenRejected": "Ce jeton d’administration a été refusé. Vérifiez-le et réessayez.", "auth.adminTokenUnavailable": "Le jeton d’administration n’a pas pu être vérifié. Réessayez.", "auth.adminTokenHelp": "Il s’agit du jeton d’administration de l’API de gestion OpenCodex, pas d’une clé API de fournisseur. Au premier démarrage, le proxy l’écrit dans ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token), et OPENCODEX_ADMIN_AUTH_TOKEN a la priorité.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d60148bb60..19d231af70 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -157,6 +157,7 @@ export const ja: Record = { "auth.adminTokenTitle": "OpenCodex 管理者トークン (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "アカウント", "auth.adminTokenFieldLabel": "管理者トークン", + "auth.adminTokenRemember": "この端末で記憶する", "auth.adminTokenRejected": "管理者トークンが拒否されました。確認してもう一度お試しください。", "auth.adminTokenUnavailable": "管理者トークンを確認できませんでした。もう一度お試しください。", "auth.adminTokenHelp": "これは OpenCodex 管理 API の管理者トークンで、プロバイダーの API キーではありません。プロキシは初回起動時に ~/.opencodex/admin-api-token(または $OPENCODEX_HOME/admin-api-token)へ書き込み、OPENCODEX_ADMIN_AUTH_TOKEN を設定するとそちらが優先されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index be26daf40e..0457eb5106 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -155,6 +155,7 @@ export const ko: Record = { "auth.adminTokenTitle": "OpenCodex 관리자 토큰 (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "계정", "auth.adminTokenFieldLabel": "관리자 토큰", + "auth.adminTokenRemember": "이 기기에서 기억하기", "auth.adminTokenRejected": "관리자 토큰이 거부되었습니다. 확인한 후 다시 시도하세요.", "auth.adminTokenUnavailable": "관리자 토큰을 확인할 수 없습니다. 다시 시도하세요.", "auth.adminTokenHelp": "이 값은 OpenCodex 관리 API의 관리자 토큰이며 공급자 API 키가 아닙니다. 프록시가 처음 실행될 때 ~/.opencodex/admin-api-token(또는 $OPENCODEX_HOME/admin-api-token)에 기록하고, OPENCODEX_ADMIN_AUTH_TOKEN을 설정하면 그 값이 우선합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9691f8a49f..1e7531e438 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -157,6 +157,7 @@ export const ru: Record = { "auth.adminTokenTitle": "Токен администратора OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "Учётная запись", "auth.adminTokenFieldLabel": "Токен администратора", + "auth.adminTokenRemember": "Запомнить на этом устройстве", "auth.adminTokenRejected": "Токен администратора отклонён. Проверьте его и повторите попытку.", "auth.adminTokenUnavailable": "Не удалось проверить токен администратора. Повторите попытку.", "auth.adminTokenHelp": "Это административный токен управляющего API OpenCodex, а не ключ API провайдера. При первом запуске прокси записывает его в ~/.opencodex/admin-api-token (или $OPENCODEX_HOME/admin-api-token), а OPENCODEX_ADMIN_AUTH_TOKEN переопределяет это значение.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 95336fa32f..2329db90d2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -89,6 +89,7 @@ export const tr: Record = { "auth.adminTokenTitle": "OpenCodex yönetici jetonu (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "Hesap", "auth.adminTokenFieldLabel": "Yönetici jetonu", + "auth.adminTokenRemember": "Bu cihazda hatırla", "auth.adminTokenRejected": "Bu yönetici jetonu reddedildi. Kontrol edip tekrar deneyin.", "auth.adminTokenUnavailable": "Yönetici jetonu doğrulanamadı. Tekrar deneyin.", "auth.adminTokenHelp": "Bu, sağlayıcı API anahtarı değil, OpenCodex yönetim API’sinin yönetici jetonudur. Proxy ilk açılışta bunu ~/.opencodex/admin-api-token (veya $OPENCODEX_HOME/admin-api-token) dosyasına yazar; OPENCODEX_ADMIN_AUTH_TOKEN bu değeri geçersiz kılar.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 19f1e50ce2..59fc8ca7b5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2155,6 +2155,7 @@ export const zhTW: Record = { "auth.adminTokenTitle": "OpenCodex 管理員金鑰 (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "帳號", "auth.adminTokenFieldLabel": "管理員金鑰", + "auth.adminTokenRemember": "在此裝置上記住", "auth.adminTokenRejected": "該管理員金鑰被拒絕。請檢查後再試一次。", "auth.adminTokenUnavailable": "無法驗證管理員金鑰。請再試一次。", "auth.adminTokenHelp": "這是 OpenCodex 管理 API 的管理員金鑰,不是服務商 API 金鑰。代理首次啟動時會寫入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),設定 OPENCODEX_ADMIN_AUTH_TOKEN 可覆寫該值。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e081d027f3..ec444be0b0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -155,6 +155,7 @@ export const zh: Record = { "auth.adminTokenTitle": "OpenCodex 管理员令牌 (OPENCODEX_ADMIN_AUTH_TOKEN)", "auth.adminAccountLabel": "账户", "auth.adminTokenFieldLabel": "管理员令牌", + "auth.adminTokenRemember": "在此设备上记住", "auth.adminTokenRejected": "管理员令牌被拒绝。请检查后重试。", "auth.adminTokenUnavailable": "无法验证管理员令牌。请重试。", "auth.adminTokenHelp": "这是 OpenCodex 管理 API 的管理员令牌,不是服务商 API 密钥。代理首次启动时会写入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),设置 OPENCODEX_ADMIN_AUTH_TOKEN 可覆盖该值。", diff --git a/gui/tests/admin-token-dialog.test.ts b/gui/tests/admin-token-dialog.test.ts index 849753833c..a58ebd79a5 100644 --- a/gui/tests/admin-token-dialog.test.ts +++ b/gui/tests/admin-token-dialog.test.ts @@ -109,6 +109,37 @@ test("keeps the dialog open for whitespace and rejected tokens until one is acce expect(dialog.isConnected).toBe(false); }); +test("checked remember box persists the accepted token for standalone sign-in", async () => { + const pending = promptForAdminToken(async () => "accepted"); + const dialog = document.querySelector("#opencodex-admin-token-dialog")!; + const form = dialog.querySelector("form")!; + const password = form.elements.namedItem("password") as HTMLInputElement; + const remember = form.elements.namedItem("opencodex-admin-token-dialog-remember") as HTMLInputElement; + + expect(remember.type).toBe("checkbox"); + password.value = "stored-token"; + remember.checked = true; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + + expect(await pending).toBe("stored-token"); + expect(localStorage.getItem("opencodex.remembered-admin-token")).toBe("stored-token"); +}); + +test("unchecked remember box clears any stale remembered token", async () => { + localStorage.setItem("opencodex.remembered-admin-token", "stale-token"); + + const pending = promptForAdminToken(async () => "accepted"); + const dialog = document.querySelector("#opencodex-admin-token-dialog")!; + const form = dialog.querySelector("form")!; + const password = form.elements.namedItem("password") as HTMLInputElement; + + password.value = "fresh-token"; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + + expect(await pending).toBe("fresh-token"); + expect(localStorage.getItem("opencodex.remembered-admin-token")).toBeNull(); +}); + test("uses the active UI locale instead of re-detecting browser storage", async () => { localStorage.setItem("ocx-lang", "en"); setActiveLocale("ko"); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 96c618f6bd..4b62d11e29 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -4,7 +4,7 @@ import { configureApiTargets, fetchAudioUpload, installApiAuthFetch, installApiS import { targetsFromMachineStatus, type MachineStatusV1 } from "../src/api-targets"; const LEGACY_TOKEN_KEY = "opencodex-api-token"; -const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; +const globals = ["document", "window", "navigator", "sessionStorage", "localStorage", "fetch"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; let originalPrompt: typeof window.prompt; @@ -17,6 +17,7 @@ beforeEach(() => { window: { configurable: true, value: testWindow }, navigator: { configurable: true, value: testWindow.navigator }, sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + localStorage: { configurable: true, value: testWindow.localStorage }, fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, }); originalPrompt = window.prompt; @@ -133,6 +134,47 @@ test("prompted API tokens stay memory-only and are not written to sessionStorage expect(sessionStorage.length).toBe(0); }); +test("remembered token is verified and used silently without prompting", async () => { + declareManagementAuthRequired(); + localStorage.setItem("opencodex.remembered-admin-token", "remembered-token"); + let promptCalls = 0; + window.prompt = () => { promptCalls += 1; return "prompt-token"; }; + + const seenTokens: Array = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const key = new Headers(init?.headers).get("X-OpenCodex-API-Key"); + seenTokens.push(key); + if (key === "remembered-token") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const res = await fetch("/api/config"); + expect(res.status).toBe(200); + expect(promptCalls).toBe(0); + expect(seenTokens).toContain("remembered-token"); + expect(localStorage.getItem("opencodex.remembered-admin-token")).toBe("remembered-token"); +}); + +test("rejected remembered token is cleared and the prompt takes over", async () => { + declareManagementAuthRequired(); + localStorage.setItem("opencodex.remembered-admin-token", "stale-token"); + let promptCalls = 0; + window.prompt = () => { promptCalls += 1; return "fresh-token"; }; + + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const key = new Headers(init?.headers).get("X-OpenCodex-API-Key"); + if (key === "fresh-token") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const res = await fetch("/api/config"); + expect(res.status).toBe(200); + expect(promptCalls).toBe(1); + expect(localStorage.getItem("opencodex.remembered-admin-token")).toBeNull(); +}); + test("validates prompted tokens with a safe read before retrying the failed request", async () => { declareManagementAuthRequired(); const validationResults: string[] = []; From 2532da57473f0d9a07174f08fc2366a7a3b56660 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Tue, 15 Sep 2026 11:33:31 +0400 Subject: [PATCH 2/3] fix(dashboard): address review findings on remembered admin token handling - JSDoc no longer claims memory-only storage - remember checkbox gets a form-control name - checkbox is pre-checked when a remembered token exists - 401 recovery: revoked current token is cleared immediately without verification - 401 recovery: unavailable (5xx/network) keeps the stored token instead of deleting it - 4 new regression tests covering all of the above (28 total, 0 fail) Co-authored-by: x3M3x --- gui/src/admin-token-dialog.ts | 7 +++-- gui/src/api.ts | 19 +++++++++---- gui/tests/admin-token-dialog.test.ts | 16 +++++++++++ gui/tests/api-auth-memory.test.ts | 41 ++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index 406a131e91..6295e9793e 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -25,8 +25,9 @@ export function clearRememberedAdminToken(): void { /** * Ask for the management credential with a real sign-in form so browsers and - * password managers can offer save/autofill. OpenCodex itself still keeps the - * submitted token in memory only; persistence remains entirely browser-owned. + * password managers can offer save/autofill. OpenCodex keeps the submitted + * token in memory only unless the user explicitly opts in to remembering it + * on this device (see the remember checkbox below). */ export function promptForAdminToken( verifyToken: AdminTokenVerifier, @@ -112,7 +113,9 @@ export function promptForAdminToken( rememberField.style.cssText = "display:flex;gap:8px;align-items:center;margin-top:var(--space-4);"; const remember = document.createElement("input"); remember.id = `${ADMIN_TOKEN_DIALOG_ID}-remember`; + remember.name = "remember"; remember.type = "checkbox"; + if (getRememberedAdminToken()) remember.checked = true; rememberField.append(remember, document.createTextNode(messages["auth.adminTokenRemember"])); const validationError = document.createElement("div"); diff --git a/gui/src/api.ts b/gui/src/api.ts index 54ef9ac7e7..a946a47e28 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -284,12 +284,21 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, return null; } const remembered = getRememberedAdminToken(); - if (remembered && remembered !== failedToken) { - if (await verifyAdminToken(plane, remembered) === "accepted") { - state.session = { token: remembered, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin }; - return remembered; + if (remembered) { + if (remembered === failedToken) { + // The stored token just caused this 401: it is revoked. Clear it + // now so it cannot linger until the next visit. + clearRememberedAdminToken(); + } else { + const verdict = await verifyAdminToken(plane, remembered); + if (verdict === "accepted") { + state.session = { token: remembered, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin }; + return remembered; + } + if (verdict === "rejected") clearRememberedAdminToken(); + // "unavailable" (network/server error) leaves the stored token + // intact: a transient outage must not delete a valid credential. } - clearRememberedAdminToken(); } const prompted = await requestAdminToken(token => verifyAdminToken(plane, token)); if (prompted) { diff --git a/gui/tests/admin-token-dialog.test.ts b/gui/tests/admin-token-dialog.test.ts index a58ebd79a5..b4d6b6dbae 100644 --- a/gui/tests/admin-token-dialog.test.ts +++ b/gui/tests/admin-token-dialog.test.ts @@ -132,7 +132,10 @@ test("unchecked remember box clears any stale remembered token", async () => { const dialog = document.querySelector("#opencodex-admin-token-dialog")!; const form = dialog.querySelector("form")!; const password = form.elements.namedItem("password") as HTMLInputElement; + const remember = form.elements.namedItem("remember") as HTMLInputElement; + expect(remember.checked).toBe(true); // pre-checked because a stored value exists + remember.checked = false; password.value = "fresh-token"; form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); @@ -140,6 +143,19 @@ test("unchecked remember box clears any stale remembered token", async () => { expect(localStorage.getItem("opencodex.remembered-admin-token")).toBeNull(); }); +test("remember checkbox has a form control name for consistency", async () => { + const pending = promptForAdminToken(async () => "accepted"); + const dialog = document.querySelector("#opencodex-admin-token-dialog")!; + const form = dialog.querySelector("form")!; + const remember = form.elements.namedItem("remember") as HTMLInputElement; + const password = form.elements.namedItem("password") as HTMLInputElement; + + expect(remember.name).toBe("remember"); + password.value = "x"; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + await pending; +}); + test("uses the active UI locale instead of re-detecting browser storage", async () => { localStorage.setItem("ocx-lang", "en"); setActiveLocale("ko"); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 4b62d11e29..3b1ff32676 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -175,6 +175,47 @@ test("rejected remembered token is cleared and the prompt takes over", async () expect(localStorage.getItem("opencodex.remembered-admin-token")).toBeNull(); }); +test("unavailable remembered token survives a transient server error", async () => { + declareManagementAuthRequired(); + localStorage.setItem("opencodex.remembered-admin-token", "good-token"); + let promptCalls = 0; + window.prompt = () => { promptCalls += 1; return null; }; + + // Validation endpoint returns 503 (unavailable), not 401 (rejected). + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const key = new Headers(init?.headers).get("X-OpenCodex-API-Key"); + const url = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/"); + if (url.pathname === "/api/settings" && key === "good-token") { + return new Response("overloaded", { status: 503 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + await fetch("/api/config"); + expect(promptCalls).toBe(1); // fell through to prompt + expect(localStorage.getItem("opencodex.remembered-admin-token")).toBe("good-token"); // NOT cleared +}); + +test("remembered token that caused the current 401 is cleared immediately", async () => { + declareManagementAuthRequired(); + localStorage.setItem("opencodex.remembered-admin-token", "revoked-token"); + let promptCalls = 0; + window.prompt = () => { promptCalls += 1; return "fresh-token"; }; + + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const key = new Headers(init?.headers).get("X-OpenCodex-API-Key"); + if (key === "fresh-token") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const res = await fetch("/api/config"); + expect(res.status).toBe(200); + expect(promptCalls).toBe(1); + expect(localStorage.getItem("opencodex.remembered-admin-token")).toBeNull(); +}); + test("validates prompted tokens with a safe read before retrying the failed request", async () => { declareManagementAuthRequired(); const validationResults: string[] = []; From 7eaef95e145c5e35f6e6c4e6f185e66e97408308 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Wed, 16 Sep 2026 21:30:46 +0400 Subject: [PATCH 3/3] docs(pr): add admin-token remember checkbox screenshot --- .github/pr-assets/admin-token-remember.png | Bin 0 -> 30888 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-assets/admin-token-remember.png diff --git a/.github/pr-assets/admin-token-remember.png b/.github/pr-assets/admin-token-remember.png new file mode 100644 index 0000000000000000000000000000000000000000..e3ccbf0509e848cedf7db884a4ee9349d8a78890 GIT binary patch literal 30888 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