Skip to content
Open
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
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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ja/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。

## できること

| 領域 | 機能 |
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ko/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다.

## 할 수 있는 일

| 영역 | 기능 |
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ru/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`; решение о сохранении полностью остаётся за браузером или менеджером паролей.

## Возможности

| Раздел | Что делает |
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;是否持久保存完全由浏览器或密码管理器决定。

## 可以完成哪些操作

| 区域 | 作用 |
Expand Down
153 changes: 153 additions & 0 deletions gui/src/admin-token-dialog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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<AdminTokenValidation>;

/**
* 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(
verifyToken: AdminTokenVerifier,
locale: Locale = getActiveLocale(),
): Promise<string | null> {
const messages = DICTS[locale];
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 = messages["auth.adminAccountLabel"];
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 = messages["auth.adminTokenFieldLabel"];
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 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");
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, validationError, 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;
}
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) => {
event.preventDefault();
finish(null);
});

document.body.append(dialog);
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
queueMicrotask(() => password.focus());
});
}
29 changes: 25 additions & 4 deletions gui/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { promptForAdminToken, type AdminTokenVerifier } 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,8 +13,13 @@ let rawFetch: typeof fetch | null = null;
*/
let promptCancelled = false;

type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => 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 = "/";
/** 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 {
Expand Down Expand Up @@ -111,6 +118,18 @@ async function reBootstrapSessionToken(): Promise<string | null> {
}
}

async function verifyAdminToken(token: string): ReturnType<AdminTokenVerifier> {
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);
Expand All @@ -135,8 +154,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 +170,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(verifyAdminToken);
if (prompted) {
storeToken(prompted);
return prompted;
Expand Down Expand Up @@ -202,12 +222,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;
}
5 changes: 5 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ 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)",
"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",
Expand Down
5 changes: 5 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const en = {
"common.remove": "Remove",
"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",
Expand Down
5 changes: 5 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const ja: Record<TKey, string> = {
"common.remove": "削除",
"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 オフ",
Expand Down
5 changes: 5 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const ko: Record<TKey, string> = {
"common.remove": "삭제",
"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": "다크",
Expand Down
13 changes: 11 additions & 2 deletions gui/src/i18n/provider.tsx
Original file line number Diff line number Diff line change
@@ -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];
Expand Down
5 changes: 5 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const ru: Record<TKey, string> = {
"common.remove": "Удалить",
"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 ВЫКЛ",
Expand Down
11 changes: 11 additions & 0 deletions gui/src/i18n/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<string, string | number>;
export type TFn = (key: TKey, vars?: Vars) => string;

Expand Down
Loading
Loading