Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions gui/src/admin-token-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdminTokenValidation>;

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,
Expand Down Expand Up @@ -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");
Expand All @@ -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);

/*
Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 18 additions & 1 deletion gui/src/api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 };
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const de: Record<TKey, string> = {
"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.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const fr: Record<TKey, string> = {
"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é.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const ja: Record<TKey, string> = {
"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 を設定するとそちらが優先されます。",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const ko: Record<TKey, string> = {
"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을 설정하면 그 값이 우선합니다.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const ru: Record<TKey, string> = {
"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 переопределяет это значение.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const tr: Record<TKey, string> = {
"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.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2153,6 +2153,7 @@ export const zhTW: Record<TKey, string> = {
"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 可覆寫該值。",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const zh: Record<TKey, string> = {
"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 可覆盖该值。",
Expand Down
47 changes: 47 additions & 0 deletions gui/tests/admin-token-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDialogElement>("#opencodex-admin-token-dialog")!;
const form = dialog.querySelector<HTMLFormElement>("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<HTMLDialogElement>("#opencodex-admin-token-dialog")!;
const form = dialog.querySelector<HTMLFormElement>("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<HTMLDialogElement>("#opencodex-admin-token-dialog")!;
const form = dialog.querySelector<HTMLFormElement>("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");
Expand Down
85 changes: 84 additions & 1 deletion gui/tests/api-auth-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<string | null> = [];
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[] = [];
Expand Down
Loading