Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
mincia1110 marked this conversation as resolved.
## What you can do

| Area | What it does |
Expand Down
116 changes: 116 additions & 0 deletions gui/src/admin-token-dialog.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
const messages = DICTS[detectInitial()];
Comment thread
mincia1110 marked this conversation as resolved.
Outdated
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);
Comment thread
mincia1110 marked this conversation as resolved.

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);
Comment thread
mincia1110 marked this conversation as resolved.
Outdated
});
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());
});
}
15 changes: 11 additions & 4 deletions gui/src/api.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> | null = null;
Expand All @@ -11,6 +13,9 @@ let rawFetch: typeof fetch | null = null;
*/
let promptCancelled = false;

type AdminTokenPrompt = () => Promise<string | null>;
let requestAdminToken: AdminTokenPrompt = promptForAdminToken;

/** Document path re-fetched to mint a fresh loopback GUI session (server injects meta tags). */
const SESSION_REBOOTSTRAP_PATH = "/";

Expand Down Expand Up @@ -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<string | null> {
if (promptCancelled) return null;
Expand All @@ -150,7 +156,7 @@ async function resolveTokenAfter401(failedToken: string | null): Promise<string
const renewed = await reBootstrapSessionToken();
if (renewed) return renewed;

const prompted = window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null;
const prompted = await requestAdminToken();
if (prompted) {
storeToken(prompted);
return prompted;
Expand Down Expand Up @@ -202,12 +208,13 @@ export function installApiAuthFetch(): void {
}

/** Test-only: allow a fresh `installApiAuthFetch()` in the same module instance. */
export function resetApiAuthFetchForTests(): void {
export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = promptForAdminToken): void {
installed = false;
memoryToken = null;
memoryCsrfToken = null;
memorySessionOrigin = null;
resolutionInFlight = null;
rawFetch = null;
promptCancelled = false;
requestAdminToken = adminTokenPrompt;
}
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const de: Record<TKey, string> = {
"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",
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 @@ -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",
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 @@ -26,6 +26,7 @@ export const ja: Record<TKey, string> = {
"common.remove": "削除",
"common.loading": "読み込み中…",
"common.retry": "再試行",
"auth.adminTokenTitle": "OpenCodex 管理者トークン (OPENCODEX_ADMIN_AUTH_TOKEN)",
"app.logoAria": "opencodex ロゴ",
"app.claudeOn": "Claude オン",
"app.claudeOff": "Claude オフ",
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 @@ -24,6 +24,7 @@ export const ko: Record<TKey, string> = {
"common.remove": "삭제",
"common.loading": "불러오는 중…",
"common.retry": "재시도",
"auth.adminTokenTitle": "OpenCodex 관리자 토큰 (OPENCODEX_ADMIN_AUTH_TOKEN)",
"theme.label": "테마",
"theme.light": "라이트",
"theme.dark": "다크",
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 @@ -26,6 +26,7 @@ export const ru: Record<TKey, string> = {
"common.remove": "Удалить",
"common.loading": "Загрузка…",
"common.retry": "Повторить",
"auth.adminTokenTitle": "Токен администратора OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)",
"app.logoAria": "Логотип opencodex",
"app.claudeOn": "Claude ВКЛ",
"app.claudeOff": "Claude ВЫКЛ",
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 @@ -24,6 +24,7 @@ export const zh: Record<TKey, string> = {
"common.remove": "移除",
"common.loading": "加载中…",
"common.retry": "重试",
"auth.adminTokenTitle": "OpenCodex 管理员令牌 (OPENCODEX_ADMIN_AUTH_TOKEN)",
"theme.label": "主题",
"theme.light": "浅色",
"theme.dark": "深色",
Expand Down
67 changes: 67 additions & 0 deletions gui/tests/admin-token-dialog.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDialogElement>("#opencodex-admin-token-dialog");
const form = dialog?.querySelector<HTMLFormElement>("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<HTMLDialogElement>("#opencodex-admin-token-dialog");
dialog!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true }));

expect(await pending).toBeNull();
expect(document.activeElement).toBe(focusTarget);
});
Comment thread
mincia1110 marked this conversation as resolved.
4 changes: 3 additions & 1 deletion gui/tests/api-auth-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Loading