Skip to content
Closed
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
13 changes: 12 additions & 1 deletion docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,18 @@ they have been synchronized. See

## Remote Hub sessions, keys, and usage

In **Connect → API Keys**, every row of the key table has its own delete button, which asks for confirmation in place, and clicking a key shows its full value with a **Copy** button. Revealing a key needs a signed-in dashboard session; the admin token cannot read key values.
In **Connect → API Keys**, every row of the key table has its own delete button, which asks for confirmation in place. Clicking a key can show its full value with a **Copy** button only from an independently authorized session.

### Reading an existing API key

Showing a stored key's full value requires a dashboard session established by explicit pairing or
trusted Tailscale identity. An automatic local dashboard session can still show the masked key list,
but cannot reveal existing values; a raw admin token cannot call this session-only action either.
Use the existing [dashboard pairing flow](/guides/remote-hub/#pairing-this-browser-with-a-hub) to establish an
operator-authorized session before requesting a stored value; a refused reveal states the requirement
on the page and, on the same-origin standalone transport, offers the local pairing form in place. Pair
again if that session expires or is revoked. Ordinary dashboard sign-in, key creation, rotation, and
deletion are unchanged.

The dashboard's management plane is separate from direct client→hub model traffic. **Connect → API Keys** shows pending rotations, displays a replacement secret only once, and requires explicit commit or abort. Browser logout invalidates only the current remote session. Connected usage is the hub store filtered by the client's `apiKeyId`; disconnected usage is local, with no mirroring.

Expand Down
10 changes: 10 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 @@ -139,6 +139,16 @@ Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적

대시보드 관리 API와 클라이언트에서 허브로 가는 모델 요청은 서로 다른 경로입니다. **연결 → API 키**에서는 진행 중인 키 교체를 확인하고, 새 키를 한 번만 표시하며, 확정 또는 취소를 직접 눌러야 합니다. 브라우저 로그아웃은 현재 원격 세션만 끝냅니다. 연결 중 사용량은 허브에서 해당 `apiKeyId`만 보고, 연결 해제 후에는 로컬 기록을 보며 서로 복제하지 않습니다.

### 기존 API 키의 전체 값 조회

저장된 키의 전체 값을 보려면 명시적인 페어링이나 신뢰된 Tailscale 인증으로 발급된
대시보드 세션이 필요합니다. 자동 발급된 로컬 세션은 마스킹된 목록을 계속 볼 수 있지만,
기존 키의 전체 값은 조회할 수 없습니다. 관리자 토큰도 이 세션 전용 동작을 호출하지 못합니다.
기존 대시보드 페어링 절차로 세션을 승인한 뒤 조회하세요. 거부된 조회는 페이지에서
요구 사항을 안내하고, 동일 오리진 스탠드얼론 전송에서는 로컬 페어링 폼을 그 자리에
제공합니다. 세션이 만료되거나 해제되면 다시 페어링해야 합니다. 일반 대시보드
접속과 키 생성·교체·삭제는 변경하지 않습니다.

선택기에는 활성화된 네이티브 및 라우팅 모델과 Codex 전역 reasoning 단계가 표시됩니다. API는
선택한 강도가 전역 단계에 있는지 검사하고, Codex는 다시 대상 카탈로그 항목이 그 강도를 지원하는지
검사합니다.
Expand Down
48 changes: 43 additions & 5 deletions gui/src/components/apikeys-workspace/ApiKeysListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@
import { useEffect, useRef, useState } from "react";
import { IconTrash } from "../../icons";
import { useT } from "../../i18n/shared";
import { formatCreatedDate, type ApiKeyEntry } from "../../pages/api-keys-utils";
import { formatCreatedDate, type ApiKeyEntry, type RevealKeyResult } from "../../pages/api-keys-utils";
import type { UsageReadMetadata } from "../../usage-summary-resource";
import { UsageIncompleteNotice } from "../usage-incomplete-notice";
import { Notice } from "../../ui";
import { isStandaloneRuntime, standaloneApiTargets } from "../../api-targets";
import { ConnectPairingForm } from "../../connect-pairing";

export default function ApiKeysListPanel({
keys,
Expand All @@ -27,6 +30,7 @@ export default function ApiKeysListPanel({
attributionSince,
usageMetadata,
localeTag,
apiBase,
busy,
onSelect,
onDelete,
Expand All @@ -39,18 +43,22 @@ export default function ApiKeysListPanel({
attributionSince?: string;
usageMetadata?: UsageReadMetadata;
localeTag?: string;
/** Management API origin a reveal denial pairs this dashboard against. */
apiBase: string;
/** A mutation is in flight; its result is bound to one key, so navigation waits. */
busy: boolean;
onSelect: (id: string) => void;
/** Resolves true only when the key is really gone. */
onDelete?: (id: string) => Promise<boolean>;
/** The full key, or null when the server would not hand it over. */
onReveal?: (id: string) => Promise<string | null>;
/** The reveal outcome: the full key, a standing refusal, or a transient failure. */
onReveal?: (id: string) => Promise<RevealKeyResult>;
}) {
const t = useT();
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [revealPendingId, setRevealPendingId] = useState<string | null>(null);
const [revealFailedId, setRevealFailedId] = useState<string | null>(null);
/** The row whose reveal the server refused — kept so pairing can retry it. */
const [revealDeniedId, setRevealDeniedId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [copyFailedId, setCopyFailedId] = useState<string | null>(null);
const copiedTimer = useRef<number | null>(null);
Expand Down Expand Up @@ -79,19 +87,31 @@ export default function ApiKeysListPanel({

const rowLocked = busy || deletingId !== null;

// The same gate RemoteLink applies to its local-pairing offer: only the
// literal same-origin loopback transport the standalone grant mint accepts.
// Anywhere else the refusal gets the explanation alone — a form here would
// mint a session for an origin the server did not bind.
const localPairingTarget = standaloneApiTargets(apiBase).shared;
const canPairLocally = isStandaloneRuntime()
&& window.location.protocol === "http:"
&& ["127.0.0.1", "[::1]"].includes(window.location.hostname)
&& localPairingTarget.serverOrigin === window.location.origin;

const toggleReveal = async (k: ApiKeyEntry) => {
const id = k.id;
setRevealFailedId(null);
setRevealDeniedId(null);
if (currentReveal(k) !== undefined) {
setRevealed(({ [id]: _hidden, ...rest }) => rest);
return;
}
if (!onReveal || revealPendingId) return;
setRevealPendingId(id);
try {
const full = await onReveal(id);
const result = await onReveal(id);
if (deletedIds.current.has(id)) return;
if (full) setRevealed(prev => ({ ...prev, [id]: full }));
if (result.ok) setRevealed(prev => ({ ...prev, [id]: result.key }));
else if (result.kind === "denied") setRevealDeniedId(id);
else setRevealFailedId(id);
} finally {
setRevealPendingId(null);
Expand Down Expand Up @@ -156,6 +176,24 @@ export default function ApiKeysListPanel({
</div>

<UsageIncompleteNotice data={usageMetadata} />
{revealDeniedId !== null && (
<>
<Notice tone="warn">{t("api.key.revealDenied")}</Notice>
{/* Pairing upgrades this exact session; onConnected retries the
refused reveal so the click that surfaced the form completes. */}
{canPairLocally && (
<ConnectPairingForm
local
target={localPairingTarget}
onConnected={() => {
setRevealDeniedId(null);
const denied = keys.find(k => k.id === revealDeniedId);
if (denied) void toggleReveal(denied);
}}
/>
)}
</>
)}
{keysLoading ? (
<div className="api-active-keys-skeleton" role="status" aria-label={t("common.loading")} />
) : keys.length === 0 ? (
Expand Down
8 changes: 6 additions & 2 deletions gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type ApiKeyEntry,
type ApiSurfacesInfo,
type ModelTests,
type RevealKeyResult,
} from "../../pages/api-keys-utils";
import {
ApiKeysEndpointsPanel,
Expand Down Expand Up @@ -74,8 +75,10 @@ export interface ApiKeysWorkspaceProps {
onDismissNewKey: () => void;
onCopyKey: () => void;
onDelete: (id: string) => Promise<boolean>;
/** Full key for a click-to-reveal in the key table; absent hides the control. */
onRevealKey?: (id: string) => Promise<string | null>;
/** Full key for a click-to-reveal in the key table; absent hides the control.
* The result discriminates a standing 403 refusal — the list answers it with
* the pairing surface, not a retry hint — from a transient failure. */
onRevealKey?: (id: string) => Promise<RevealKeyResult>;
onRename: (id: string, name: string) => Promise<boolean>;
onRotationStart?: (id: string) => Promise<boolean>;
onRotationCommit?: (id: string, rotationId: string) => Promise<boolean>;
Expand Down Expand Up @@ -533,6 +536,7 @@ export default function ApiKeysWorkspace({
attributionSince={attributionSince}
usageMetadata={usageMetadata}
localeTag={localeTag}
apiBase={apiBase}
busy={mutationPending}
onDelete={onDelete}
onReveal={onRevealKey}
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 @@ -2283,6 +2283,7 @@ export const de: Record<TKey, string> = {
"api.key.revealHint": "Klicken, um den vollständigen Schlüssel anzuzeigen",
"api.key.hideHint": "Klicken, um den vollständigen Schlüssel auszublenden",
"api.key.revealFailed": "Der vollständige Schlüssel konnte nicht geladen werden.",
"api.key.revealDenied": "Das Anzeigen eines gespeicherten Schlüssels erfordert eine vom Betreiber autorisierte Sitzung. Koppeln Sie diesen Browser, um fortzufahren, oder melden Sie sich über eine vertrauenswürdige Identität an.",
"api.key.copyFailedShort": "Kopieren fehlgeschlagen. Markiere den Schlüssel und kopiere ihn manuell.",
"api.key.deleteRowAria": "{name} löschen",
"api.key.deleteShort": "Löschen",
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 @@ -2879,6 +2879,7 @@ export const en = {
"api.key.revealHint": "Click to show the full key",
"api.key.hideHint": "Click to hide the full key",
"api.key.revealFailed": "Could not load the full key.",
"api.key.revealDenied": "Showing a stored key requires an operator-authorized session. Pair this browser to continue, or sign in through a trusted identity.",
"api.key.copyFailedShort": "Could not copy. Select the key and copy it manually.",
"api.key.deleteRowAria": "Delete {name}",
"api.key.deleteShort": "Delete",
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 @@ -2795,6 +2795,7 @@ export const fr: Record<TKey, string> = {
"api.key.revealHint": "Cliquez pour afficher la clé complète",
"api.key.hideHint": "Cliquez pour masquer la clé complète",
"api.key.revealFailed": "Impossible de charger la clé complète.",
"api.key.revealDenied": "Afficher une clé enregistrée nécessite une session autorisée par l'opérateur. Associez ce navigateur pour continuer, ou connectez-vous via une identité de confiance.",
"api.key.copyFailedShort": "Copie impossible. Sélectionnez la clé et copiez-la manuellement.",
"api.key.deleteRowAria": "Supprimer {name}",
"api.key.deleteShort": "Supprimer",
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 @@ -2722,6 +2722,7 @@ export const ja: Record<TKey, string> = {
"api.key.revealHint": "クリックでキー全体を表示",
"api.key.hideHint": "クリックでキー全体を隠す",
"api.key.revealFailed": "キー全体を読み込めませんでした。",
"api.key.revealDenied": "保存済みキーの表示には、オペレーターが認可したセッションが必要です。続行するにはこのブラウザーをペアリングするか、信頼された ID でサインインしてください。",
"api.key.copyFailedShort": "コピーできませんでした。キーを選択して手動でコピーしてください。",
"api.key.deleteRowAria": "{name} を削除",
"api.key.deleteShort": "削除",
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 @@ -2330,6 +2330,7 @@ export const ko: Record<TKey, string> = {
"api.key.revealHint": "클릭하면 전체 키가 보입니다",
"api.key.hideHint": "클릭하면 전체 키를 숨깁니다",
"api.key.revealFailed": "전체 키를 불러오지 못했습니다.",
"api.key.revealDenied": "저장된 키를 표시하려면 운영자가 승인한 세션이 필요합니다. 이 브라우저를 페어링해 계속하거나, 신뢰된 인증으로 로그인하세요.",
"api.key.copyFailedShort": "복사하지 못했습니다. 키를 선택해 직접 복사하세요.",
"api.key.deleteRowAria": "{name} 삭제",
"api.key.deleteShort": "삭제",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2838,6 +2838,7 @@ export const pt: Record<TKey, string> = {
"api.key.revealHint": "Clique para mostrar a chave completa",
"api.key.hideHint": "Clique para ocultar a chave completa",
"api.key.revealFailed": "Não foi possível carregar a chave completa.",
"api.key.revealDenied": "Mostrar uma chave armazenada exige uma sessão autorizada pelo operador. Emparelhe este navegador para continuar ou entre com uma identidade confiável.",
"api.key.copyFailedShort": "Não foi possível copiar. Selecione a chave e copie-a manualmente.",
"api.key.deleteRowAria": "Excluir {name}",
"api.key.deleteShort": "Excluir",
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 @@ -2812,6 +2812,7 @@ export const ru: Record<TKey, string> = {
"api.key.revealHint": "Нажмите, чтобы показать ключ полностью",
"api.key.hideHint": "Нажмите, чтобы скрыть полный ключ",
"api.key.revealFailed": "Не удалось загрузить полный ключ.",
"api.key.revealDenied": "Чтобы показать сохранённый ключ, требуется сеанс, авторизованный оператором. Создайте пару для этого браузера, чтобы продолжить, или войдите через доверенную учётную запись.",
"api.key.copyFailedShort": "Не удалось скопировать. Выделите ключ и скопируйте его вручную.",
"api.key.deleteRowAria": "Удалить {name}",
"api.key.deleteShort": "Удалить",
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 @@ -2814,6 +2814,7 @@ export const tr: Record<TKey, string> = {
"api.key.revealHint": "Tam anahtarı göstermek için tıklayın",
"api.key.hideHint": "Tam anahtarı gizlemek için tıklayın",
"api.key.revealFailed": "Tam anahtar yüklenemedi.",
"api.key.revealDenied": "Kaydedilmiş bir anahtarı göstermek, operatörün yetkilendirdiği bir oturum gerektirir. Devam etmek için bu tarayıcıyı eşleştirin veya güvenilir bir kimlikle oturum açın.",
"api.key.copyFailedShort": "Kopyalanamadı. Anahtarı seçip elle kopyalayın.",
"api.key.deleteRowAria": "{name} sil",
"api.key.deleteShort": "Sil",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2813,6 +2813,7 @@ export const vi: Record<TKey, string> = {
"api.key.revealHint": "Nhấp để hiện toàn bộ khóa",
"api.key.hideHint": "Nhấp để ẩn toàn bộ khóa",
"api.key.revealFailed": "Không tải được toàn bộ khóa.",
"api.key.revealDenied": "Hiển thị khóa đã lưu yêu cầu phiên được vận hành cho phép. Ghép nối trình duyệt này để tiếp tục, hoặc đăng nhập qua danh tính tin cậy.",
"api.key.copyFailedShort": "Không sao chép được. Hãy chọn key và sao chép thủ công.",
"api.key.deleteRowAria": "Xóa {name}",
"api.key.deleteShort": "Xóa",
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 @@ -2068,6 +2068,7 @@ export const zhTW: Record<TKey, string> = {
"api.key.revealHint": "點擊顯示完整金鑰",
"api.key.hideHint": "點擊隱藏完整金鑰",
"api.key.revealFailed": "無法載入完整金鑰。",
"api.key.revealDenied": "顯示已儲存的金鑰需要經操作員授權的工作階段。請配對此瀏覽器以繼續,或透過受信任的身分登入。",
"api.key.copyFailedShort": "無法複製。請選取金鑰後手動複製。",
"api.key.deleteRowAria": "刪除 {name}",
"api.key.deleteShort": "刪除",
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 @@ -2303,6 +2303,7 @@ export const zh: Record<TKey, string> = {
"api.key.revealHint": "点击显示完整密钥",
"api.key.hideHint": "点击隐藏完整密钥",
"api.key.revealFailed": "无法加载完整密钥。",
"api.key.revealDenied": "显示已存储的密钥需要经操作员授权的会话。请配对此浏览器以继续,或通过受信任的身份登录。",
"api.key.copyFailedShort": "无法复制。请选中密钥后手动复制。",
"api.key.deleteRowAria": "删除 {name}",
"api.key.deleteShort": "删除",
Expand Down
16 changes: 11 additions & 5 deletions gui/src/pages/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type ApiKeyEntry,
type ModelTestResult,
type ModelTests,
type RevealKeyResult,
} from "./api-keys-utils";

interface KeysResponse extends UsageReadMetadata {
Expand Down Expand Up @@ -322,8 +323,10 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
};

/** The full key for one row. Read-only, but bounded like the mutations so a
* stalled connection releases the cell's pending state. */
const handleReveal = async (id: string): Promise<string | null> => {
* stalled connection releases the cell's pending state. The 403 standing
* refusal is reported apart from transient failures so the list can offer
* the remedy — pairing — instead of a bare "try again". */
const handleReveal = async (id: string): Promise<RevealKeyResult> => {
const bounded = createBoundedFetch(MUTATION_TIMEOUT_MS);
try {
const res = await fetch(`${apiBase}/api/keys/reveal`, {
Expand All @@ -333,11 +336,14 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
signal: bounded.signal,
cache: "no-store",
});
if (!res.ok) return null;
if (res.status === 403) return { ok: false, kind: "denied" };
if (!res.ok) return { ok: false, kind: "failed" };
const body = await res.json() as { key?: unknown };
return typeof body.key === "string" && body.key ? body.key : null;
return typeof body.key === "string" && body.key
? { ok: true, key: body.key }
: { ok: false, kind: "failed" };
} catch {
return null;
return { ok: false, kind: "failed" };
} finally {
bounded.clear();
}
Expand Down
11 changes: 11 additions & 0 deletions gui/src/pages/api-keys-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export interface ApiKeyEntry {
usage: ApiKeyUsage;
}

/**
* Outcome of a stored-key reveal. `denied` is the server's standing answer to a
* dashboard session without stored-secret authority — a loopback session may
* list masked keys but cannot disclose one until the operator pairs this
* browser or signs in through a trusted identity. `failed` is the transient
* remainder: timeout, unreachable server, malformed answer.
*/
export type RevealKeyResult =
| { ok: true; key: string }
| { ok: false; kind: "denied" | "failed" };

/**
* A usage object the GUI can actually render. Coercing a malformed one to zeroes
* would state "used zero times" about data we could not read — the exact false
Expand Down
1 change: 1 addition & 0 deletions gui/tests/apikeys-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ async function mount(props: Partial<ApiKeysWorkspaceProps>): Promise<HTMLDivElem
const container = document.createElement("div");
document.body.append(container);
const value: ApiKeysWorkspaceProps = {
apiBase: "",
keys: [{
id: "k1",
name: "alpha",
Expand Down
Loading
Loading