diff --git a/.github/pr-assets/admin-token-remember.png b/.github/pr-assets/admin-token-remember.png new file mode 100644 index 0000000000..e3ccbf0509 Binary files /dev/null and b/.github/pr-assets/admin-token-remember.png differ diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index cb383fe7d6..6295e9793e 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -7,10 +7,27 @@ 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 - * 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, @@ -91,6 +108,16 @@ 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.name = "remember"; + remember.type = "checkbox"; + if (getRememberedAdminToken()) remember.checked = true; + rememberField.append(remember, document.createTextNode(messages["auth.adminTokenRemember"])); + const validationError = document.createElement("div"); validationError.className = "notice notice-err"; validationError.setAttribute("role", "alert"); @@ -108,7 +135,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 +174,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..a946a47e28 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,23 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, state.promptCancelled = true; return null; } + const remembered = getRememberedAdminToken(); + 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. + } + } 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..b4d6b6dbae 100644 --- a/gui/tests/admin-token-dialog.test.ts +++ b/gui/tests/admin-token-dialog.test.ts @@ -109,6 +109,53 @@ 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; + 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 })); + + expect(await pending).toBe("fresh-token"); + 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 96c618f6bd..3b1ff32676 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,88 @@ 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("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[] = [];