diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 657d67994..0f3daa799 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -284,7 +284,7 @@ export default function App() { {page === "usage" && } {page === "storage" && } {page === "codex-auth" && } - {page === "api" && } + {page === "api" && } {page === "claude" && } diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx new file mode 100644 index 000000000..7b088b767 --- /dev/null +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -0,0 +1,239 @@ +/** + * ApiKeysWorkspace — rail + main workspace for the API Keys tab, mirroring the + * Providers workspace DNA. Left rail lists Overview + active keys; the main pane + * shows either the overview (endpoint, generate form, usage example) or a per-key + * detail view. + */ +import { useRef, useState } from "react"; +import { IconCheck, IconPlus, IconTrash, IconX } from "../../icons"; +import { useT } from "../../i18n"; + +export interface ApiKeyEntry { + id: string; + name: string; + prefix: string; + createdAt: string; +} + +export interface ApiKeysWorkspaceProps { + keys: ApiKeyEntry[]; + endpoint: string; + localeTag?: string; + creating: boolean; + newKey: string | null; + copied: boolean; + /** Resolves true only after a successful create so the draft name can be cleared. */ + onCreate: (name: string) => Promise; + onDismissNewKey: () => void; + onCopyNewKey: () => void; + onDelete: (id: string) => void; +} + +function formatCreatedDate(iso: string, localeTag?: string): string { + return new Date(iso).toLocaleDateString(localeTag); +} + +export default function ApiKeysWorkspace({ + keys, + endpoint, + localeTag, + creating, + newKey, + copied, + onCreate, + onDismissNewKey, + onCopyNewKey, + onDelete, +}: ApiKeysWorkspaceProps) { + const t = useT(); + const [selectedId, setSelectedId] = useState(null); + const [newName, setNewName] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(false); + const createInFlight = useRef(false); + + const selected = keys.find(k => k.id === selectedId) ?? null; + + const handleCreate = async () => { + if (creating || createInFlight.current) return; + createInFlight.current = true; + try { + const ok = await onCreate(newName); + if (ok) setNewName(""); + } finally { + createInFlight.current = false; + } + }; + + const handleDeleteClick = () => { + if (!selected) return; + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + onDelete(selected.id); + setConfirmDelete(false); + setSelectedId(null); + }; + + const showOverview = () => { + setSelectedId(null); + setConfirmDelete(false); + }; + + return ( +
+
+ + +
+ {selected ? ( +
+ +
+

{selected.name}

+ + {confirmDelete ? ( + <> + + + + ) : ( + + )} + +
+ {confirmDelete && ( +

{t("api.workspace.deleteConfirm")}

+ )} +
+

{t("api.workspace.keyDetails")}

+
+
+
{t("api.colName")}
+
{selected.name}
+
+
+
{t("api.workspace.keyPrefix")}
+
{selected.prefix}
+
+
+
{t("api.colCreated")}
+
{formatCreatedDate(selected.createdAt, localeTag)}
+
+
+
+
+ ) : ( + <> + {keys.length > 0 && ( +
{t("api.workspace.selectKeyHint")}
+ )} + + {newKey && ( +
+

{t("api.newKeyTitle")}

+

{t("api.newKeyNote")}

+
+ {newKey} + + +
+
+ )} + +
+

{t("api.endpoint")}

+ {endpoint} +

{t("api.endpointNote")}

+
+ +
+

{t("api.generateTitle")}

+
+ setNewName(e.target.value)} + onKeyDown={e => { + if ( + e.key !== "Enter" + || e.nativeEvent.isComposing + || creating + || createInFlight.current + ) { + return; + } + e.preventDefault(); + void handleCreate(); + }} + /> + +
+
+ +
+

{t("api.usageTitle")}

+
{`curl ${endpoint} \\
+  -H "Authorization: Bearer ocx_YOUR_KEY_HERE" \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "model": "gpt-5.4",
+    "input": ${JSON.stringify(t("api.usageSampleInput"))}
+  }'`}
+
+ + )} +
+
+
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 23b7e6355..a9c1af9b6 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -715,6 +715,15 @@ export const de = { "api.confirm": "Bestätigen", "api.deleteAria": "API-Schlüssel löschen", "api.usageTitle": "Nutzungsbeispiel", + "api.usageSampleInput": "Hallo, Welt!", + "api.workspace.overview": "Übersicht", + "api.workspace.details": "API-Schlüsseldetails", + "api.workspace.keyDetails": "Schlüsseldetails", + "api.workspace.keyPrefix": "Schlüssel-Präfix", + "api.workspace.deleteKey": "Schlüssel löschen", + "api.workspace.deleteConfirm": "Möchten Sie diesen Schlüssel wirklich löschen? Dies kann nicht rückgängig gemacht werden.", + "api.workspace.noKeysHint": "Noch keine API-Schlüssel. Erstellen Sie einen, um zu beginnen.", + "api.workspace.selectKeyHint": "Wählen Sie einen Schlüssel aus der Liste, um seine Details anzuzeigen.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "GPT, Gemini und andere Modelle in Claude Code verwenden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 772cc84d3..9b621aaa8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1022,6 +1022,15 @@ export const en = { "api.confirm": "Confirm", "api.deleteAria": "Delete API key", "api.usageTitle": "Usage example", + "api.usageSampleInput": "Hello, world!", + "api.workspace.overview": "Overview", + "api.workspace.details": "API key details", + "api.workspace.keyDetails": "Key details", + "api.workspace.keyPrefix": "Key prefix", + "api.workspace.deleteKey": "Delete key", + "api.workspace.deleteConfirm": "Are you sure you want to delete this key? This cannot be undone.", + "api.workspace.noKeysHint": "No API keys yet. Generate one to get started.", + "api.workspace.selectKeyHint": "Select a key from the list to view its details.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Use GPT, Gemini, and other models inside Claude Code.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2adafa1de..6bae2249d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -977,6 +977,15 @@ export const ja: Record = { "api.confirm": "確認", "api.deleteAria": "API キーを削除", "api.usageTitle": "使用例", + "api.usageSampleInput": "こんにちは、世界!", + "api.workspace.overview": "概要", + "api.workspace.details": "APIキーの詳細", + "api.workspace.keyDetails": "キーの詳細", + "api.workspace.keyPrefix": "キーのプレフィックス", + "api.workspace.deleteKey": "キーを削除", + "api.workspace.deleteConfirm": "このキーを削除してもよろしいですか?この操作は元に戻せません。", + "api.workspace.noKeysHint": "API キーがまだありません。作成して始めましょう。", + "api.workspace.selectKeyHint": "一覧からキーを選択すると、詳細が表示されます。", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Claude Code 内で GPT、Gemini などのモデルを使用します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 777ed16c9..644247ea0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -737,6 +737,15 @@ export const ko: Record = { "api.confirm": "확인", "api.deleteAria": "API 키 삭제", "api.usageTitle": "사용 예시", + "api.usageSampleInput": "안녕하세요, 세계!", + "api.workspace.overview": "개요", + "api.workspace.details": "API 키 세부 정보", + "api.workspace.keyDetails": "키 세부 정보", + "api.workspace.keyPrefix": "키 접두사", + "api.workspace.deleteKey": "키 삭제", + "api.workspace.deleteConfirm": "이 키를 삭제하시겠습니까? 되돌릴 수 없습니다.", + "api.workspace.noKeysHint": "아직 API 키가 없습니다. 키를 생성하여 시작하세요.", + "api.workspace.selectKeyHint": "목록에서 키를 선택하여 세부 정보를 확인하세요.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Claude Code에서 GPT, Gemini 등 다른 모델도 쓸 수 있게 해줍니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e3efe039a..f44f3dd94 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1022,6 +1022,15 @@ export const ru: Record = { "api.confirm": "Подтвердить", "api.deleteAria": "Удалить API-ключ", "api.usageTitle": "Пример использования", + "api.usageSampleInput": "Привет, мир!", + "api.workspace.overview": "Обзор", + "api.workspace.details": "Сведения об API-ключе", + "api.workspace.keyDetails": "Сведения о ключе", + "api.workspace.keyPrefix": "Префикс ключа", + "api.workspace.deleteKey": "Удалить ключ", + "api.workspace.deleteConfirm": "Вы уверены, что хотите удалить этот ключ? Это действие нельзя отменить.", + "api.workspace.noKeysHint": "API-ключей пока нет. Создайте один, чтобы начать.", + "api.workspace.selectKeyHint": "Выберите ключ из списка, чтобы просмотреть его сведения.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Используйте GPT, Gemini и другие модели внутри Claude Code.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9b2e162d0..f2d73745b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -737,6 +737,15 @@ export const zh: Record = { "api.confirm": "确认", "api.deleteAria": "删除 API 密钥", "api.usageTitle": "使用示例", + "api.usageSampleInput": "你好,世界!", + "api.workspace.overview": "概览", + "api.workspace.details": "API 密钥详情", + "api.workspace.keyDetails": "密钥详情", + "api.workspace.keyPrefix": "密钥前缀", + "api.workspace.deleteKey": "删除密钥", + "api.workspace.deleteConfirm": "确定要删除此密钥吗?此操作无法撤销。", + "api.workspace.noKeysHint": "还没有 API 密钥。生成一个以开始使用。", + "api.workspace.selectKeyHint": "从列表中选择一个密钥以查看其详情。", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "在 Claude Code 中使用 GPT、Gemini 等其他模型。", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 5046643c4..5dbf804be 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,6 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { IconPlus, IconX, IconCheck } from "../icons"; import { useI18n, LOCALES } from "../i18n/shared"; +import { readViewMode, type ViewMode } from "../view-mode"; +import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace"; interface ApiKeyEntry { id: string; @@ -13,7 +15,7 @@ function formatCreatedDate(iso: string, localeTag?: string): string { return new Date(iso).toLocaleDateString(localeTag); } -export default function ApiKeys({ apiBase }: { apiBase: string }) { +export default function ApiKeys({ apiBase, viewMode }: { apiBase: string; viewMode?: ViewMode }) { const { t, locale } = useI18n(); const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; const [keys, setKeys] = useState([]); @@ -23,6 +25,8 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const [newKey, setNewKey] = useState(null); const [copied, setCopied] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); + const creatingRef = useRef(false); + const workspaceView = (viewMode ?? readViewMode()) === "workspace"; const fetchKeys = useCallback(async () => { try { @@ -44,21 +48,28 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const responseEndpoint = endpoint || "http://127.0.0.1:10100/v1/responses"; - const handleCreate = async () => { + const handleCreate = async (name?: string): Promise => { + if (creatingRef.current) return false; + creatingRef.current = true; setCreating(true); try { + const effectiveName = name ?? newName; const res = await fetch(`${apiBase}/api/keys`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newName || "default" }), + body: JSON.stringify({ name: effectiveName || "default" }), }); - if (res.ok) { - const data = await res.json(); - setNewKey(data.key); - setNewName(""); - fetchKeys(); - } + if (!res.ok) return false; + const data = await res.json() as { key?: unknown }; + if (typeof data.key !== "string" || data.key.length === 0) return false; + setNewKey(data.key); + setNewName(""); + void fetchKeys(); + return true; + } catch { + return false; } finally { + creatingRef.current = false; setCreating(false); } }; @@ -84,6 +95,28 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { // Subtitle carries two inline chips; split the localized string on both tokens. const subtitleParts = t("api.subtitle").split(/\{authHeader\}|\{altHeader\}/); + if (workspaceView) { + return ( +
+
+

{t("api.title")}

+
+ handleCreate(name)} + onDismissNewKey={() => setNewKey(null)} + onCopyNewKey={copyKey} + onDelete={id => { void handleDelete(id); }} + /> +
+ ); + } + return (
@@ -131,7 +164,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { onChange={e => setNewName(e.target.value)} className="input" /> -
@@ -178,7 +211,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { -H "Content-Type: application/json" \\ -d '{ "model": "gpt-5.4", - "input": "Hello, world!" + "input": ${JSON.stringify(t("api.usageSampleInput"))} }'`}
diff --git a/gui/src/styles-apikeys-workspace.css b/gui/src/styles-apikeys-workspace.css new file mode 100644 index 000000000..2eddf9fe1 --- /dev/null +++ b/gui/src/styles-apikeys-workspace.css @@ -0,0 +1,346 @@ +/* ============================================================================ + apikeys-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) + Namespace: apikeys-workspace- | widgets: awi- + Uses only design tokens from styles.css. No gradients. + ============================================================================ */ + +.main-inner:has(.apikeys-workspace-shell) { + max-width: 1200px; +} + +.apikeys-workspace-shell { + width: 100%; + min-width: 0; + container-type: inline-size; + container-name: apikeys-workspace; +} + +.apikeys-workspace-root { + display: grid; + grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); + gap: var(--space-4); + width: 100%; + max-width: 100%; + min-height: 480px; +} + +/* ── Rail ─────────────────────────────────────────────── */ + +.apikeys-workspace-rail { + display: flex; + flex-direction: column; + gap: var(--space-3); + border-right: 1px solid var(--border); + padding-right: var(--space-3); + min-width: 0; +} + +.apikeys-workspace-rail-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.apikeys-workspace-rail-title { + font-size: var(--text-body); + font-weight: var(--weight-semibold); + color: var(--text); +} + +.apikeys-workspace-rail-count { + font-family: var(--mono); + font-size: 11px; + color: var(--faint); + font-variant-numeric: tabular-nums; +} + +.apikeys-workspace-rail-list { + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + max-height: 560px; + min-height: 0; +} + +.apikeys-workspace-rail-row { + display: flex; + flex-direction: column; + gap: var(--space-0-5); + width: 100%; + min-height: 46px; + appearance: none; + background: none; + border: none; + border-radius: var(--radius-sm); + padding: var(--space-1-5) var(--space-2); + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + overflow: hidden; + transition: background var(--motion-fast), box-shadow var(--motion-fast); +} + +.apikeys-workspace-rail-row:hover { + background: var(--surface); +} + +.apikeys-workspace-rail-row--selected { + background: var(--accent-soft); + box-shadow: inset 0 0 0 1px var(--border); +} + +.apikeys-workspace-rail-row:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: -2px; +} + +.apikeys-workspace-rail-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-body); + font-weight: var(--weight-medium); + line-height: var(--leading-ui); +} + +.apikeys-workspace-rail-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-caption); + line-height: var(--leading-ui); + color: var(--muted); + font-family: var(--mono); +} + +.apikeys-workspace-rail-empty { + font-size: 12px; + padding: 8px 4px; + color: var(--muted); +} + +/* ── Main ─────────────────────────────────────────────── */ + +.apikeys-workspace-main { + min-width: 0; + max-width: 100%; + overflow: hidden; + padding: 0 var(--space-2); +} + +.awi-section { + margin-bottom: 20px; +} + +.awi-section-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + margin: 0 0 8px 0; +} + +.awi-endpoint { + display: block; + font-family: var(--mono); + font-size: 0.82rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + word-break: break-all; + color: var(--text); +} + +.awi-endpoint-note { + font-size: 12px; + color: var(--muted); + margin-top: 6px; +} + +.awi-generate-row { + display: flex; + align-items: center; + gap: 8px; +} + +.awi-generate-row .input { + flex: 1 1 auto; + min-width: 0; +} + +.awi-newkey { + border: 1px solid var(--accent); + background: var(--accent-soft); + border-radius: var(--radius-sm); + padding: 12px; + margin-bottom: 20px; +} + +.awi-newkey-title { + font-weight: var(--weight-semibold); + margin: 0 0 4px 0; + font-size: var(--text-body); +} + +.awi-newkey-note { + font-size: 12px; + color: var(--muted); + margin: 0 0 10px 0; +} + +.awi-newkey-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.awi-newkey-code { + flex: 1 1 auto; + min-width: 0; + font-family: var(--mono); + font-size: 0.8rem; + word-break: break-all; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; +} + +.awi-kv { + display: flex; + flex-direction: column; + margin: 0; +} + +.awi-kv-row { + display: flex; + gap: 12px; + padding: 5px 0; + font-size: 0.84rem; + line-height: 1.4; +} + +.awi-kv-row dt { + flex-shrink: 0; + width: 130px; + color: var(--muted); + font-weight: 400; +} + +.awi-kv-row dd { + margin: 0; + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.awi-kv-row dd code { + font-size: 0.8rem; +} + +.awi-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.awi-back { + margin-bottom: 8px; +} + +.awi-detail-title { + font-size: 1.15rem; + font-weight: 600; + margin: 0; + min-width: 0; + word-break: break-all; +} + +.awi-detail-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.awi-usage-pre { + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.5; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px; + overflow-x: auto; + color: var(--text); + margin: 0; +} + +.awi-empty { + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 32px 20px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.awi-hint { + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px 8px; + color: var(--muted); + font-size: 13px; +} + +/* ── Responsive ───────────────────────────────────────── */ +/* Keep @container and @media stacking rules in sync when changing breakpoints. */ + +@container apikeys-workspace (max-width: 720px) { + .apikeys-workspace-root { + grid-template-columns: minmax(0, 1fr); + min-height: auto; + } + .apikeys-workspace-rail { + border-right: none; + padding-right: 0; + border-bottom: 1px solid var(--border); + padding-bottom: var(--space-3); + } + .apikeys-workspace-rail-list { + max-height: 320px; + } + .apikeys-workspace-main { + padding: var(--space-2) 0 0; + } +} + +@media (max-width: 768px) { + .apikeys-workspace-root { + grid-template-columns: minmax(0, 1fr); + min-height: auto; + } + .apikeys-workspace-rail { + border-right: none; + padding-right: 0; + border-bottom: 1px solid var(--border); + padding-bottom: var(--space-3); + } + .apikeys-workspace-rail-list { + max-height: 320px; + } + .apikeys-workspace-main { + padding: var(--space-2) 0 0; + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index dc4b7bff6..89e4d020c 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -17,6 +17,7 @@ @import "./styles-dashboard-workspace.css"; @import "./styles-subagents-workspace.css"; @import "./styles-usage-workspace.css"; +@import "./styles-apikeys-workspace.css"; :root { /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ diff --git a/gui/tests/apikeys-workspace.test.ts b/gui/tests/apikeys-workspace.test.ts new file mode 100644 index 000000000..5add98107 --- /dev/null +++ b/gui/tests/apikeys-workspace.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; + +test("ApiKeys uses global viewMode (no per-page toggle) and workspace shell", async () => { + const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text(); + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + + expect(page).toContain("viewMode"); + expect(page).toContain("readViewMode"); + expect(page).toContain("ApiKeysWorkspace"); + expect(page).toContain('workspaceView = (viewMode ?? readViewMode()) === "workspace"'); + expect(page).not.toContain("ocx-apikeys-view"); + expect(page).not.toContain("pws.workspaceToggle"); + expect(page).not.toContain("pws.classicToggle"); + + expect(app).toContain(""); + expect(css).toContain('@import "./styles-apikeys-workspace.css"'); +}); + +test("ApiKeys workspace avoids nested main and stacks via container query", async () => { + const src = await Bun.file(new URL("../src/components/apikeys-workspace/ApiKeysWorkspace.tsx", import.meta.url)).text(); + const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text(); + const css = await Bun.file(new URL("../src/styles-apikeys-workspace.css", import.meta.url)).text(); + + expect(src).toContain('
"); + expect(src).toContain("e.nativeEvent.isComposing"); + expect(page).toContain("creatingRef"); + expect(page).toContain("if (creatingRef.current) return false"); + + expect(css).toContain("container-name: apikeys-workspace"); + expect(css).toContain("container-type: inline-size"); + expect(css).toContain("@container apikeys-workspace (max-width: 720px)"); + expect(css).toContain("@media (max-width: 768px)"); + expect(css).toContain("overflow-wrap: anywhere"); + expect(css).not.toContain("word-break: break-word"); +}); diff --git a/gui/tests/apikeys-workspace.test.tsx b/gui/tests/apikeys-workspace.test.tsx new file mode 100644 index 000000000..dc075db0c --- /dev/null +++ b/gui/tests/apikeys-workspace.test.tsx @@ -0,0 +1,355 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ApiKeysWorkspace, { + type ApiKeyEntry, + type ApiKeysWorkspaceProps, +} from "../src/components/apikeys-workspace/ApiKeysWorkspace"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +const sampleKeys: ApiKeyEntry[] = [ + { id: "k1", name: "alpha", prefix: "ocx_aaa", createdAt: "2026-01-01T00:00:00.000Z" }, + { id: "k2", name: "beta", prefix: "ocx_bbb", createdAt: "2026-01-02T00:00:00.000Z" }, +]; + +const FULL_SECRET = "ocx_FULL_SECRET_ONLY_ONCE"; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + 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 }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +function setInputValue(input: HTMLInputElement, value: string): void { + const proto = Object.getPrototypeOf(input) as HTMLInputElement; + const protoSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set; + protoSetter?.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); +} + +async function mountWorkspace( + props: Partial & Pick, +): Promise<{ root: Root; container: HTMLElement; rerender: (next: Partial) => Promise }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + let latest: ApiKeysWorkspaceProps = { + keys: sampleKeys, + endpoint: "http://127.0.0.1:10100/v1/responses", + creating: false, + newKey: null, + copied: false, + onDismissNewKey: () => {}, + onCopyNewKey: () => {}, + onDelete: () => {}, + ...props, + }; + + function Harness({ value }: { value: ApiKeysWorkspaceProps }) { + return ( + + + + ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + return { + root, + container, + async rerender(next) { + latest = { ...latest, ...next }; + await act(async () => { + root.render(); + }); + }, + }; +} + +function overviewButton(container: HTMLElement): HTMLButtonElement { + return [...container.querySelectorAll(".apikeys-workspace-rail-row")] + .find(el => el.textContent?.includes("Overview"))!; +} + +function keyButton(container: HTMLElement, name: string): HTMLButtonElement { + return [...container.querySelectorAll(".apikeys-workspace-rail-row")] + .find(el => el.querySelector(".apikeys-workspace-rail-name")?.textContent === name)!; +} + +function nameInput(container: HTMLElement): HTMLInputElement { + return container.querySelector('input[aria-label="Key name (optional)"]')!; +} + +function generateButton(container: HTMLElement): HTMLButtonElement { + return [...container.querySelectorAll("button")] + .find(el => /Generate|Creating/.test(el.textContent ?? ""))!; +} + +test("workspace overview navigation preserves pending secret and resets delete confirm", async () => { + const { root, container } = await mountWorkspace({ + newKey: FULL_SECRET, + onCreate: async () => false, + }); + + expect(container.textContent).toContain("Endpoint"); + expect(container.textContent).toContain("Generate key"); + expect(container.textContent).toContain("Usage example"); + expect(container.querySelector(".awi-newkey-code")?.textContent).toBe(FULL_SECRET); + expect(container.querySelector("main")).toBeNull(); + expect(container.querySelector('[role="main"]')).toBeNull(); + expect(container.querySelector("section.apikeys-workspace-main")?.getAttribute("aria-label")).toBe("API key details"); + + await act(async () => { keyButton(container, "alpha").click(); }); + expect(container.textContent).toContain("Key details"); + expect(container.textContent).toContain("ocx_aaa"); + expect(container.textContent).not.toContain(FULL_SECRET); + + await act(async () => { + [...container.querySelectorAll("button")].find(b => b.textContent?.includes("Delete key"))!.click(); + }); + expect(container.textContent).toContain("Are you sure you want to delete this key?"); + + await act(async () => { overviewButton(container).click(); }); + expect(container.textContent).toContain("Endpoint"); + expect(container.textContent).toContain("Generate key"); + expect(container.querySelector(".awi-newkey-code")?.textContent).toBe(FULL_SECRET); + expect(container.textContent).not.toContain("Are you sure you want to delete this key?"); + + await act(async () => { root.unmount(); }); +}); + +test("workspace single-flight: repeated Enter and Generate while creating call onCreate once", async () => { + let calls = 0; + let resolveCreate!: (ok: boolean) => void; + const pending = new Promise(resolve => { resolveCreate = resolve; }); + + const { root, container, rerender } = await mountWorkspace({ + creating: false, + onCreate: async () => { + calls += 1; + return pending; + }, + }); + + await act(async () => { + setInputValue(nameInput(container), "once"); + nameInput(container).dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(calls).toBe(1); + + await rerender({ creating: true }); + expect(generateButton(container).disabled).toBe(true); + expect(nameInput(container).disabled).toBe(true); + + await act(async () => { + nameInput(container).dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + generateButton(container).click(); + }); + expect(calls).toBe(1); + + await act(async () => { + const event = new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true }); + Object.defineProperty(event, "isComposing", { configurable: true, value: true }); + nameInput(container).dispatchEvent(event); + }); + // Even if composing flag is ignored by React's synthetic path here, creating/disabled still blocks. + expect(calls).toBe(1); + + await act(async () => { + resolveCreate(true); + await pending; + }); + + await act(async () => { root.unmount(); }); +}); + +test("workspace clears draft name only after successful create", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + let createImpl: (name: string) => Promise = async () => false; + const createCalls: string[] = []; + + function Host() { + const [creating, setCreating] = useState(false); + return ( + + {}} + onCopyNewKey={() => {}} + onDelete={() => {}} + onCreate={async (name) => { + createCalls.push(name); + setCreating(true); + try { + return await createImpl(name); + } finally { + setCreating(false); + } + }} + /> + + ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + await act(async () => { + setInputValue(nameInput(container), "keep-me"); + }); + expect(nameInput(container).value).toBe("keep-me"); + + createImpl = async () => false; + await act(async () => { generateButton(container).click(); }); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(createCalls.at(-1)).toBe("keep-me"); + expect(nameInput(container).value).toBe("keep-me"); + + createImpl = async () => true; + await act(async () => { generateButton(container).click(); }); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(createCalls.at(-1)).toBe("keep-me"); + expect(nameInput(container).value).toBe(""); + + await act(async () => { root.unmount(); }); +}); + +test("IME composition Enter does not submit", async () => { + let calls = 0; + const { root, container } = await mountWorkspace({ + onCreate: async () => { + calls += 1; + return true; + }, + }); + + await act(async () => { + setInputValue(nameInput(container), "ime"); + const event = new testWindow.KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "isComposing", { configurable: true, get: () => true }); + Object.defineProperty(event, "keyCode", { configurable: true, value: 229 }); + nameInput(container).dispatchEvent(event); + }); + + // React reads event.nativeEvent.isComposing from the dispatched KeyboardEvent when available. + // If happy-dom doesn't populate nativeEvent, the component still checks e.nativeEvent.isComposing — + // verify the handler path by also ensuring creating/disabled paths work above. + // Force through React's onKeyDown by constructing an event React will treat as composing: + await act(async () => { + const input = nameInput(container); + const handler = (input as unknown as { onclick?: unknown }).onclick; + void handler; + const reactPropsKey = Object.keys(input).find(k => k.startsWith("__reactProps$")); + if (reactPropsKey) { + const props = (input as unknown as Record void }>)[reactPropsKey]; + props?.onKeyDown?.({ + key: "Enter", + preventDefault() {}, + nativeEvent: { isComposing: true }, + }); + } + }); + expect(calls).toBe(0); + + await act(async () => { root.unmount(); }); +}); + +test("parent creatingRef blocks duplicate submissions before rerender", async () => { + const creatingRef = { current: false }; + let posts = 0; + const handleCreate = async (): Promise => { + if (creatingRef.current) return false; + creatingRef.current = true; + try { + posts += 1; + await Promise.resolve(); + return true; + } finally { + creatingRef.current = false; + } + }; + + const first = handleCreate(); + const second = handleCreate(); + expect(await Promise.all([first, second])).toEqual([true, false]); + expect(posts).toBe(1); +}); + +test("App keeps the sole main landmark; workspace never nests main", async () => { + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const workspace = await Bun.file(new URL("../src/components/apikeys-workspace/ApiKeysWorkspace.tsx", import.meta.url)).text(); + const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text(); + + expect(app).toContain('
{ + const { root, container } = await mountWorkspace({ + newKey: FULL_SECRET, + onCreate: async () => false, + }); + const rail = container.querySelector(".apikeys-workspace-rail")!; + expect(rail.textContent).not.toContain(FULL_SECRET); + expect(rail.textContent).toContain("ocx_aaa"); + expect(container.querySelector(".awi-newkey-code")?.textContent).toBe(FULL_SECRET); + + await act(async () => { keyButton(container, "beta").click(); }); + expect(container.querySelector(".awi-detail")?.textContent).not.toContain(FULL_SECRET); + expect(container.querySelector(".awi-detail")?.textContent).toContain("ocx_bbb"); + + await act(async () => { overviewButton(container).click(); }); + expect(container.querySelector(".awi-newkey-code")?.textContent).toBe(FULL_SECRET); + + await act(async () => { root.unmount(); }); +});