From d1ee3644b81d7e6dc8b7db107f6093fb97667d85 Mon Sep 17 00:00:00 2001 From: mincia1110 <283664966+mincia1110@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:09:25 +0900 Subject: [PATCH 1/2] Enable password manager autofill for admin token --- .../src/content/docs/guides/web-dashboard.md | 5 + gui/src/admin-token-dialog.ts | 116 ++++++++++++++++++ gui/src/api.ts | 15 ++- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/tests/admin-token-dialog.test.ts | 67 ++++++++++ gui/tests/api-auth-memory.test.ts | 4 +- 11 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 gui/src/admin-token-dialog.ts create mode 100644 gui/tests/admin-token-dialog.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index a4f224094..0f5a2f000 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -29,6 +29,11 @@ they expire or the proxy restarts. Only a dashboard bound to a non-loopback host the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated `~/.opencodex/admin-api-token` file). +When a remote dashboard needs that credential, it presents a standard password form so a browser +password manager can offer to save and autofill it. The dashboard itself still keeps the token only +in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely +the browser or password manager's decision. + ## What you can do | Area | What it does | diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts new file mode 100644 index 000000000..391fe2bd1 --- /dev/null +++ b/gui/src/admin-token-dialog.ts @@ -0,0 +1,116 @@ +import { DICTS, detectInitial } from "./i18n/shared"; + +const ADMIN_TOKEN_DIALOG_ID = "opencodex-admin-token-dialog"; +const ADMIN_TOKEN_USERNAME = "OpenCodex"; + +/** + * 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. + */ +export function promptForAdminToken(): Promise { + const messages = DICTS[detectInitial()]; + const titleText = messages["auth.adminTokenTitle"]; + + return new Promise((resolve) => { + const previouslyFocused = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + let settled = false; + + const dialog = document.createElement("dialog"); + dialog.id = ADMIN_TOKEN_DIALOG_ID; + dialog.className = "modal-overlay"; + dialog.setAttribute("aria-labelledby", `${ADMIN_TOKEN_DIALOG_ID}-title`); + + const form = document.createElement("form"); + form.className = "modal-card"; + form.method = "post"; + form.action = window.location.href; + form.autocomplete = "on"; + + const heading = document.createElement("div"); + heading.className = "modal-head"; + const title = document.createElement("h3"); + title.id = `${ADMIN_TOKEN_DIALOG_ID}-title`; + title.textContent = titleText; + heading.append(title); + + const accountField = document.createElement("div"); + const accountLabel = document.createElement("label"); + accountLabel.className = "field-label"; + accountLabel.htmlFor = `${ADMIN_TOKEN_DIALOG_ID}-username`; + accountLabel.textContent = ADMIN_TOKEN_USERNAME; + const username = document.createElement("input"); + username.id = accountLabel.htmlFor; + username.className = "input"; + username.type = "text"; + username.name = "username"; + username.autocomplete = "username"; + username.value = ADMIN_TOKEN_USERNAME; + username.readOnly = true; + accountField.append(accountLabel, username); + + const tokenField = document.createElement("div"); + tokenField.style.marginTop = "var(--space-4)"; + const tokenLabel = document.createElement("label"); + tokenLabel.className = "field-label"; + tokenLabel.htmlFor = `${ADMIN_TOKEN_DIALOG_ID}-password`; + tokenLabel.textContent = titleText; + const password = document.createElement("input"); + password.id = tokenLabel.htmlFor; + password.className = "input"; + password.type = "password"; + password.name = "password"; + password.autocomplete = "current-password"; + password.required = true; + password.spellcheck = false; + password.autocapitalize = "none"; + tokenField.append(tokenLabel, password); + + const actions = document.createElement("div"); + actions.className = "modal-actions"; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "btn btn-ghost"; + cancel.textContent = messages["common.cancel"]; + const submit = document.createElement("button"); + submit.type = "submit"; + submit.className = "btn btn-primary"; + submit.textContent = messages["common.ok"]; + actions.append(cancel, submit); + + form.append(heading, accountField, tokenField, actions); + dialog.append(form); + + const finish = (value: string | null): void => { + if (settled) return; + settled = true; + if (dialog.open) dialog.close(); + dialog.remove(); + previouslyFocused?.focus(); + resolve(value); + }; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + const token = password.value.trim(); + if (!token) { + password.value = ""; + password.reportValidity(); + return; + } + finish(token); + }); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(null); + }); + + document.body.append(dialog); + if (typeof dialog.showModal === "function") dialog.showModal(); + else dialog.setAttribute("open", ""); + queueMicrotask(() => password.focus()); + }); +} diff --git a/gui/src/api.ts b/gui/src/api.ts index ec1546551..ac15c8134 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,3 +1,5 @@ +import { promptForAdminToken } from "./admin-token-dialog"; + let installed = false; /** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */ let resolutionInFlight: Promise | null = null; @@ -11,6 +13,9 @@ let rawFetch: typeof fetch | null = null; */ let promptCancelled = false; +type AdminTokenPrompt = () => Promise; +let requestAdminToken: AdminTokenPrompt = promptForAdminToken; + /** Document path re-fetched to mint a fresh loopback GUI session (server injects meta tags). */ const SESSION_REBOOTSTRAP_PATH = "/"; @@ -135,8 +140,9 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke /** * Resolve a token after a 401. Concurrent callers share one in-flight resolution so a dashboard - * fan-out does not open one window.prompt per /api request (#647). Re-reads memoryToken before - * prompting so waiters that wake after another request already stored a token do not re-prompt. + * fan-out opens at most one credential dialog per /api request wave (#647). Re-reads + * memoryToken before prompting so waiters that wake after another request already stored a token + * do not re-prompt. */ async function resolveTokenAfter401(failedToken: string | null): Promise { if (promptCancelled) return null; @@ -150,7 +156,7 @@ async function resolveTokenAfter401(failedToken: string | null): Promise = { "common.remove": "Entfernen", "common.loading": "Lädt…", "common.retry": "Wiederholen", + "auth.adminTokenTitle": "OpenCodex-Admin-Token (OPENCODEX_ADMIN_AUTH_TOKEN)", "theme.label": "Design", "theme.light": "Hell", "theme.dark": "Dunkel", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1a7c9f78e..994eb48db 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -26,6 +26,7 @@ export const en = { "common.remove": "Remove", "common.loading": "Loading…", "common.retry": "Retry", + "auth.adminTokenTitle": "OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)", "app.logoAria": "opencodex logo", "app.claudeOn": "Claude ON", "app.claudeOff": "Claude OFF", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 560ff9e19..ca1c092a4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -26,6 +26,7 @@ export const ja: Record = { "common.remove": "削除", "common.loading": "読み込み中…", "common.retry": "再試行", + "auth.adminTokenTitle": "OpenCodex 管理者トークン (OPENCODEX_ADMIN_AUTH_TOKEN)", "app.logoAria": "opencodex ロゴ", "app.claudeOn": "Claude オン", "app.claudeOff": "Claude オフ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9eaff2faa..37a509531 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -24,6 +24,7 @@ export const ko: Record = { "common.remove": "삭제", "common.loading": "불러오는 중…", "common.retry": "재시도", + "auth.adminTokenTitle": "OpenCodex 관리자 토큰 (OPENCODEX_ADMIN_AUTH_TOKEN)", "theme.label": "테마", "theme.light": "라이트", "theme.dark": "다크", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c637f605a..761d616f3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -26,6 +26,7 @@ export const ru: Record = { "common.remove": "Удалить", "common.loading": "Загрузка…", "common.retry": "Повторить", + "auth.adminTokenTitle": "Токен администратора OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)", "app.logoAria": "Логотип opencodex", "app.claudeOn": "Claude ВКЛ", "app.claudeOff": "Claude ВЫКЛ", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 96d75ea48..bc0098fe9 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -24,6 +24,7 @@ export const zh: Record = { "common.remove": "移除", "common.loading": "加载中…", "common.retry": "重试", + "auth.adminTokenTitle": "OpenCodex 管理员令牌 (OPENCODEX_ADMIN_AUTH_TOKEN)", "theme.label": "主题", "theme.light": "浅色", "theme.dark": "深色", diff --git a/gui/tests/admin-token-dialog.test.ts b/gui/tests/admin-token-dialog.test.ts new file mode 100644 index 000000000..73ce62460 --- /dev/null +++ b/gui/tests/admin-token-dialog.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { promptForAdminToken } from "../src/admin-token-dialog"; + +const globals = ["document", "window", "navigator", "localStorage", "HTMLElement"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "https://dashboard.example/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + HTMLElement: { configurable: true, value: testWindow.HTMLElement }, + }); +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("renders stable password-manager-compatible sign-in fields", async () => { + const pending = promptForAdminToken(); + const dialog = document.querySelector("#opencodex-admin-token-dialog"); + const form = dialog?.querySelector("form"); + const username = form?.elements.namedItem("username") as HTMLInputElement | null; + const password = form?.elements.namedItem("password") as HTMLInputElement | null; + + expect(dialog).not.toBeNull(); + expect(dialog?.querySelector("h3")?.textContent).toBe("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)"); + expect(form?.method).toBe("post"); + expect(form?.autocomplete).toBe("on"); + expect(username?.id).toBe("opencodex-admin-token-dialog-username"); + expect(username?.autocomplete).toBe("username"); + expect(username?.readOnly).toBe(true); + expect(username?.value).toBe("OpenCodex"); + expect(password?.id).toBe("opencodex-admin-token-dialog-password"); + expect(password?.type).toBe("password"); + expect(password?.autocomplete).toBe("current-password"); + expect(password?.required).toBe(true); + + password!.value = " ocx_admin_test "; + form!.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + + expect(await pending).toBe("ocx_admin_test"); + expect(document.querySelector("#opencodex-admin-token-dialog")).toBeNull(); + expect(localStorage.length).toBe(0); +}); + +test("cancel resolves null and restores the previous focus target", async () => { + const focusTarget = document.createElement("button"); + document.body.append(focusTarget); + focusTarget.focus(); + + const pending = promptForAdminToken(); + const dialog = document.querySelector("#opencodex-admin-token-dialog"); + dialog!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); + + expect(await pending).toBeNull(); + expect(document.activeElement).toBe(focusTarget); +}); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 1a3571baf..0a56b7e75 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -19,7 +19,9 @@ beforeEach(() => { fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, }); originalPrompt = window.prompt; - resetApiAuthFetchForTests(); + resetApiAuthFetchForTests(async () => { + return window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null; + }); sessionStorage.clear(); }); From 2411b18a6f59bad549be7afb73670e86631be0f8 Mon Sep 17 00:00:00 2001 From: mincia1110 <283664966+mincia1110@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:41:09 +0900 Subject: [PATCH 2/2] Address admin token review feedback --- .../content/docs/ja/guides/web-dashboard.md | 6 ++ .../content/docs/ko/guides/web-dashboard.md | 6 ++ .../content/docs/ru/guides/web-dashboard.md | 6 ++ .../docs/zh-cn/guides/web-dashboard.md | 6 ++ gui/src/admin-token-dialog.ts | 51 +++++++++++++-- gui/src/api.ts | 20 +++++- gui/src/i18n/de.ts | 4 ++ gui/src/i18n/en.ts | 4 ++ gui/src/i18n/ja.ts | 4 ++ gui/src/i18n/ko.ts | 4 ++ gui/src/i18n/provider.tsx | 13 +++- gui/src/i18n/ru.ts | 4 ++ gui/src/i18n/shared.ts | 11 ++++ gui/src/i18n/zh.ts | 4 ++ gui/tests/admin-token-dialog.test.ts | 65 ++++++++++++++++++- gui/tests/api-auth-memory.test.ts | 31 +++++++++ 16 files changed, 225 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 82229f028..912badc71 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -21,6 +21,12 @@ ocx start bun run dev:gui ``` +## サインイン + +`localhost` や `127.0.0.1` などのループバックアドレスで開いたダッシュボードは、短時間有効な GUI セッションを自動的に受け取るため、通常はトークン入力が不要です。ループバック以外のホストで公開する場合は、`OPENCODEX_ADMIN_AUTH_TOKEN`、または自動生成される `~/.opencodex/admin-api-token` ファイルの管理トークンが必要です。 + +リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 + ## できること | 領域 | 機能 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 1028eb5c0..43080714e 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -21,6 +21,12 @@ ocx start bun run dev:gui ``` +## 로그인 + +`localhost`나 `127.0.0.1` 같은 loopback 주소에서 연 대시보드는 짧게 유지되는 GUI 세션을 자동으로 받으므로 보통 토큰을 입력할 필요가 없습니다. loopback이 아닌 호스트로 공개한 대시보드에는 `OPENCODEX_ADMIN_AUTH_TOKEN` 또는 자동 생성되는 `~/.opencodex/admin-api-token` 파일의 관리자 토큰이 필요합니다. + +원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. + ## 할 수 있는 일 | 영역 | 기능 | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 28e1c03bb..9fb5622e7 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -21,6 +21,12 @@ ocx start bun run dev:gui ``` +## Вход + +При открытии дашборда через loopback-адрес, например `localhost` или `127.0.0.1`, он автоматически получает краткоживущую GUI-сессию, поэтому ввод токена обычно не требуется. Для дашборда на любом другом хосте нужен административный токен из `OPENCODEX_ADMIN_AUTH_TOKEN` или автоматически созданного файла `~/.opencodex/admin-api-token`. + +Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. + ## Возможности | Раздел | Что делает | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 672449473..7a09d8b06 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -20,6 +20,12 @@ ocx start bun run dev:gui ``` +## 登录 + +通过 `localhost`、`127.0.0.1` 等 loopback 地址打开仪表盘时,它会自动获得一个短期 GUI session,因此通常无需输入 token。在非 loopback 主机上公开仪表盘时,必须使用 `OPENCODEX_ADMIN_AUTH_TOKEN` 或自动生成的 `~/.opencodex/admin-api-token` 文件中的管理员 token。 + +远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 + ## 可以完成哪些操作 | 区域 | 作用 | diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index 391fe2bd1..af3cdc80e 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -1,15 +1,21 @@ -import { DICTS, detectInitial } from "./i18n/shared"; +import { DICTS, getActiveLocale, type Locale } from "./i18n/shared"; const ADMIN_TOKEN_DIALOG_ID = "opencodex-admin-token-dialog"; const ADMIN_TOKEN_USERNAME = "OpenCodex"; +export type AdminTokenValidation = "accepted" | "rejected" | "unavailable"; +export type AdminTokenVerifier = (token: string) => Promise; + /** * 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. */ -export function promptForAdminToken(): Promise { - const messages = DICTS[detectInitial()]; +export function promptForAdminToken( + verifyToken: AdminTokenVerifier, + locale: Locale = getActiveLocale(), +): Promise { + const messages = DICTS[locale]; const titleText = messages["auth.adminTokenTitle"]; return new Promise((resolve) => { @@ -40,7 +46,7 @@ export function promptForAdminToken(): Promise { const accountLabel = document.createElement("label"); accountLabel.className = "field-label"; accountLabel.htmlFor = `${ADMIN_TOKEN_DIALOG_ID}-username`; - accountLabel.textContent = ADMIN_TOKEN_USERNAME; + accountLabel.textContent = messages["auth.adminAccountLabel"]; const username = document.createElement("input"); username.id = accountLabel.htmlFor; username.className = "input"; @@ -56,7 +62,7 @@ export function promptForAdminToken(): Promise { const tokenLabel = document.createElement("label"); tokenLabel.className = "field-label"; tokenLabel.htmlFor = `${ADMIN_TOKEN_DIALOG_ID}-password`; - tokenLabel.textContent = titleText; + tokenLabel.textContent = messages["auth.adminTokenFieldLabel"]; const password = document.createElement("input"); password.id = tokenLabel.htmlFor; password.className = "input"; @@ -68,6 +74,11 @@ export function promptForAdminToken(): Promise { password.autocapitalize = "none"; tokenField.append(tokenLabel, password); + const validationError = document.createElement("div"); + validationError.className = "notice notice-err"; + validationError.setAttribute("role", "alert"); + validationError.hidden = true; + const actions = document.createElement("div"); actions.className = "modal-actions"; const cancel = document.createElement("button"); @@ -80,7 +91,7 @@ export function promptForAdminToken(): Promise { submit.textContent = messages["common.ok"]; actions.append(cancel, submit); - form.append(heading, accountField, tokenField, actions); + form.append(heading, accountField, tokenField, validationError, actions); dialog.append(form); const finish = (value: string | null): void => { @@ -100,7 +111,33 @@ export function promptForAdminToken(): Promise { password.reportValidity(); return; } - finish(token); + password.disabled = true; + submit.disabled = true; + validationError.hidden = true; + + void verifyToken(token).then((result) => { + if (settled) return; + if (result === "accepted") { + finish(token); + return; + } + password.value = ""; + password.disabled = false; + submit.disabled = false; + validationError.textContent = result === "rejected" + ? messages["auth.adminTokenRejected"] + : messages["auth.adminTokenUnavailable"]; + validationError.hidden = false; + password.focus(); + }).catch(() => { + if (settled) return; + password.value = ""; + password.disabled = false; + submit.disabled = false; + validationError.textContent = messages["auth.adminTokenUnavailable"]; + validationError.hidden = false; + password.focus(); + }); }); cancel.addEventListener("click", () => finish(null)); dialog.addEventListener("cancel", (event) => { diff --git a/gui/src/api.ts b/gui/src/api.ts index ac15c8134..8cdd45652 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,4 +1,4 @@ -import { promptForAdminToken } from "./admin-token-dialog"; +import { promptForAdminToken, type AdminTokenVerifier } from "./admin-token-dialog"; let installed = false; /** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */ @@ -13,11 +13,13 @@ let rawFetch: typeof fetch | null = null; */ let promptCancelled = false; -type AdminTokenPrompt = () => Promise; +type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => Promise; let requestAdminToken: AdminTokenPrompt = promptForAdminToken; /** Document path re-fetched to mint a fresh loopback GUI session (server injects meta tags). */ const SESSION_REBOOTSTRAP_PATH = "/"; +/** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ +const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; function needsApiAuth(input: RequestInfo | URL): boolean { try { @@ -116,6 +118,18 @@ async function reBootstrapSessionToken(): Promise { } } +async function verifyAdminToken(token: string): ReturnType { + if (!rawFetch) return "unavailable"; + try { + const [input, init] = withToken(ADMIN_TOKEN_VALIDATION_PATH, { cache: "no-store" }, token); + const response = await rawFetch(input, init); + if (response.status === 401) return "rejected"; + return response.ok ? "accepted" : "unavailable"; + } catch { + return "unavailable"; + } +} + function clearLegacySessionToken(): void { try { sessionStorage.removeItem(LEGACY_TOKEN_KEY); @@ -156,7 +170,7 @@ async function resolveTokenAfter401(failedToken: string | null): Promise = { "common.loading": "Lädt…", "common.retry": "Wiederholen", "auth.adminTokenTitle": "OpenCodex-Admin-Token (OPENCODEX_ADMIN_AUTH_TOKEN)", + "auth.adminAccountLabel": "Konto", + "auth.adminTokenFieldLabel": "Admin-Token", + "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.", "theme.label": "Design", "theme.light": "Hell", "theme.dark": "Dunkel", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 994eb48db..0f3ca5eae 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -27,6 +27,10 @@ export const en = { "common.loading": "Loading…", "common.retry": "Retry", "auth.adminTokenTitle": "OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)", + "auth.adminAccountLabel": "Account", + "auth.adminTokenFieldLabel": "Admin token", + "auth.adminTokenRejected": "That admin token was rejected. Check it and try again.", + "auth.adminTokenUnavailable": "The admin token could not be verified. Try again.", "app.logoAria": "opencodex logo", "app.claudeOn": "Claude ON", "app.claudeOff": "Claude OFF", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ca1c092a4..5abcffbd7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -27,6 +27,10 @@ export const ja: Record = { "common.loading": "読み込み中…", "common.retry": "再試行", "auth.adminTokenTitle": "OpenCodex 管理者トークン (OPENCODEX_ADMIN_AUTH_TOKEN)", + "auth.adminAccountLabel": "アカウント", + "auth.adminTokenFieldLabel": "管理者トークン", + "auth.adminTokenRejected": "管理者トークンが拒否されました。確認してもう一度お試しください。", + "auth.adminTokenUnavailable": "管理者トークンを確認できませんでした。もう一度お試しください。", "app.logoAria": "opencodex ロゴ", "app.claudeOn": "Claude オン", "app.claudeOff": "Claude オフ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 37a509531..2a3e06e8a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -25,6 +25,10 @@ export const ko: Record = { "common.loading": "불러오는 중…", "common.retry": "재시도", "auth.adminTokenTitle": "OpenCodex 관리자 토큰 (OPENCODEX_ADMIN_AUTH_TOKEN)", + "auth.adminAccountLabel": "계정", + "auth.adminTokenFieldLabel": "관리자 토큰", + "auth.adminTokenRejected": "관리자 토큰이 거부되었습니다. 확인한 후 다시 시도하세요.", + "auth.adminTokenUnavailable": "관리자 토큰을 확인할 수 없습니다. 다시 시도하세요.", "theme.label": "테마", "theme.light": "라이트", "theme.dark": "다크", diff --git a/gui/src/i18n/provider.tsx b/gui/src/i18n/provider.tsx index 31929ec82..d2973afc1 100644 --- a/gui/src/i18n/provider.tsx +++ b/gui/src/i18n/provider.tsx @@ -1,10 +1,19 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { DICTS, I18nContext, LOCALES, detectInitial, interpolate, type TFn, type TKey, type Vars } from "./shared"; +import { DICTS, I18nContext, LOCALES, detectInitial, interpolate, setActiveLocale, type Locale, type TFn, type TKey, type Vars } from "./shared"; import { en } from "./en"; import { useI18n } from "./shared"; export function LanguageProvider({ children }: { children: ReactNode }) { - const [locale, setLocale] = useState(detectInitial); + const [locale, setLocaleState] = useState(() => { + const initial = detectInitial(); + setActiveLocale(initial); + return initial; + }); + + const setLocale = useCallback((next: Locale) => { + setActiveLocale(next); + setLocaleState(next); + }, []); useEffect(() => { const meta = LOCALES.find(l => l.code === locale) ?? LOCALES[0]; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 761d616f3..86e3c10ec 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -27,6 +27,10 @@ export const ru: Record = { "common.loading": "Загрузка…", "common.retry": "Повторить", "auth.adminTokenTitle": "Токен администратора OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)", + "auth.adminAccountLabel": "Учётная запись", + "auth.adminTokenFieldLabel": "Токен администратора", + "auth.adminTokenRejected": "Токен администратора отклонён. Проверьте его и повторите попытку.", + "auth.adminTokenUnavailable": "Не удалось проверить токен администратора. Повторите попытку.", "app.logoAria": "Логотип opencodex", "app.claudeOn": "Claude ВКЛ", "app.claudeOff": "Claude ВЫКЛ", diff --git a/gui/src/i18n/shared.ts b/gui/src/i18n/shared.ts index 1579a0203..982dd9f9d 100644 --- a/gui/src/i18n/shared.ts +++ b/gui/src/i18n/shared.ts @@ -22,6 +22,8 @@ export const LOCALES: { code: Locale; name: string; htmlLang: string }[] = [ const LANG_KEY = "ocx-lang"; +let activeLocale: Locale | null = null; + export function detectInitial(): Locale { try { const stored = localStorage.getItem(LANG_KEY); @@ -36,6 +38,15 @@ export function detectInitial(): Locale { return "en"; } +/** Current LanguageProvider locale for non-React UI such as the auth fetch dialog. */ +export function getActiveLocale(): Locale { + return activeLocale ?? detectInitial(); +} + +export function setActiveLocale(locale: Locale): void { + activeLocale = locale; +} + export type Vars = Record; export type TFn = (key: TKey, vars?: Vars) => string; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index bc0098fe9..280927b04 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -25,6 +25,10 @@ export const zh: Record = { "common.loading": "加载中…", "common.retry": "重试", "auth.adminTokenTitle": "OpenCodex 管理员令牌 (OPENCODEX_ADMIN_AUTH_TOKEN)", + "auth.adminAccountLabel": "账户", + "auth.adminTokenFieldLabel": "管理员令牌", + "auth.adminTokenRejected": "管理员令牌被拒绝。请检查后重试。", + "auth.adminTokenUnavailable": "无法验证管理员令牌。请重试。", "theme.label": "主题", "theme.light": "浅色", "theme.dark": "深色", diff --git a/gui/tests/admin-token-dialog.test.ts b/gui/tests/admin-token-dialog.test.ts index 73ce62460..09f237e41 100644 --- a/gui/tests/admin-token-dialog.test.ts +++ b/gui/tests/admin-token-dialog.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { promptForAdminToken } from "../src/admin-token-dialog"; +import { setActiveLocale } from "../src/i18n/shared"; const globals = ["document", "window", "navigator", "localStorage", "HTMLElement"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; beforeEach(() => { + setActiveLocale("en"); previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; testWindow = new Window({ url: "https://dashboard.example/" }); Object.defineProperties(globalThis, { @@ -19,6 +21,7 @@ beforeEach(() => { }); afterEach(() => { + setActiveLocale("en"); testWindow.close(); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); @@ -26,7 +29,7 @@ afterEach(() => { }); test("renders stable password-manager-compatible sign-in fields", async () => { - const pending = promptForAdminToken(); + const pending = promptForAdminToken(async () => "accepted"); const dialog = document.querySelector("#opencodex-admin-token-dialog"); const form = dialog?.querySelector("form"); const username = form?.elements.namedItem("username") as HTMLInputElement | null; @@ -37,10 +40,12 @@ test("renders stable password-manager-compatible sign-in fields", async () => { expect(form?.method).toBe("post"); expect(form?.autocomplete).toBe("on"); expect(username?.id).toBe("opencodex-admin-token-dialog-username"); + expect(form?.querySelector(`label[for="${username?.id}"]`)?.textContent).toBe("Account"); expect(username?.autocomplete).toBe("username"); expect(username?.readOnly).toBe(true); expect(username?.value).toBe("OpenCodex"); expect(password?.id).toBe("opencodex-admin-token-dialog-password"); + expect(form?.querySelector(`label[for="${password?.id}"]`)?.textContent).toBe("Admin token"); expect(password?.type).toBe("password"); expect(password?.autocomplete).toBe("current-password"); expect(password?.required).toBe(true); @@ -58,10 +63,66 @@ test("cancel resolves null and restores the previous focus target", async () => document.body.append(focusTarget); focusTarget.focus(); - const pending = promptForAdminToken(); + const pending = promptForAdminToken(async () => "accepted"); const dialog = document.querySelector("#opencodex-admin-token-dialog"); dialog!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); expect(await pending).toBeNull(); expect(document.activeElement).toBe(focusTarget); }); + +test("keeps the dialog open for whitespace and rejected tokens until one is accepted", async () => { + const attempts: string[] = []; + let settled = false; + const pending = promptForAdminToken(async (token) => { + attempts.push(token); + return token === "valid-token" ? "accepted" : "rejected"; + }); + void pending.then(() => { + settled = true; + }); + + const dialog = document.querySelector("#opencodex-admin-token-dialog")!; + const form = dialog.querySelector("form")!; + const password = form.elements.namedItem("password") as HTMLInputElement; + + password.value = " "; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + await Promise.resolve(); + expect(attempts).toEqual([]); + expect(settled).toBe(false); + expect(dialog.isConnected).toBe(true); + + password.value = "wrong-token"; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + expect(attempts).toEqual(["wrong-token"]); + expect(settled).toBe(false); + expect(dialog.isConnected).toBe(true); + expect(dialog.querySelector('[role="alert"]')?.textContent).toContain("rejected"); + + password.value = "valid-token"; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + expect(await pending).toBe("valid-token"); + expect(attempts).toEqual(["wrong-token", "valid-token"]); + expect(dialog.isConnected).toBe(false); +}); + +test("uses the active UI locale instead of re-detecting browser storage", async () => { + localStorage.setItem("ocx-lang", "en"); + setActiveLocale("ko"); + + const pending = promptForAdminToken(async () => "accepted"); + const dialog = document.querySelector("#opencodex-admin-token-dialog")!; + const form = dialog.querySelector("form")!; + const username = form.elements.namedItem("username") as HTMLInputElement; + const password = form.elements.namedItem("password") as HTMLInputElement; + + expect(dialog.querySelector("h3")?.textContent).toContain("관리자 토큰"); + expect(form.querySelector(`label[for="${username.id}"]`)?.textContent).toBe("계정"); + expect(form.querySelector(`label[for="${password.id}"]`)?.textContent).toBe("관리자 토큰"); + + dialog.dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); + expect(await pending).toBeNull(); +}); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 0a56b7e75..a735ce62e 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -84,6 +84,37 @@ test("prompted API tokens stay memory-only and are not written to sessionStorage expect(sessionStorage.length).toBe(0); }); +test("validates prompted tokens with a safe read before retrying the failed request", async () => { + const validationResults: string[] = []; + const seenRequests: Array<[string, string | null]> = []; + resetApiAuthFetchForTests(async (verifyToken) => { + validationResults.push(await verifyToken("wrong-token")); + validationResults.push(await verifyToken("fresh-token")); + return "fresh-token"; + }); + + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); + const key = new Headers(init?.headers).get("X-OpenCodex-API-Key"); + seenRequests.push([url.pathname, key]); + if (url.pathname === "/api/settings" && key === "fresh-token") { + return new Response("{}", { status: 200 }); + } + if (url.pathname === "/api/config" && key === "fresh-token") { + return new Response("{}", { status: 200 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(200); + expect(validationResults).toEqual(["rejected", "accepted"]); + expect(seenRequests).toContainEqual(["/api/settings", "wrong-token"]); + expect(seenRequests).toContainEqual(["/api/settings", "fresh-token"]); + expect(seenRequests).not.toContainEqual(["/api/config", "wrong-token"]); + expect(sessionStorage.length).toBe(0); +}); + test("cross-origin /api/* requests do not receive the API key or token prompt", async () => { let promptCalls = 0; let phase: "seed" | "cross" = "seed";