Skip to content
Merged
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
2 changes: 1 addition & 1 deletion gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ export default function App() {
{page === "usage" && <Usage apiBase={API_BASE} viewMode={viewMode} />}
{page === "storage" && <Storage apiBase={API_BASE} />}
{page === "codex-auth" && <CodexAuth apiBase={API_BASE} />}
{page === "api" && <ApiKeys apiBase={API_BASE} />}
{page === "api" && <ApiKeys apiBase={API_BASE} viewMode={viewMode} />}
{page === "claude" && <ClaudeCode apiBase={API_BASE} />}
</ErrorBoundary>
</div>
Expand Down
239 changes: 239 additions & 0 deletions gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
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<string | null>(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 (
<div className="apikeys-workspace-shell">
<div className="apikeys-workspace-root">
<aside className="apikeys-workspace-rail" aria-label={t("api.title")}>
<div className="apikeys-workspace-rail-header">
<span className="apikeys-workspace-rail-title">{t("api.activeKeys", { count: keys.length })}</span>
<span className="apikeys-workspace-rail-count">{keys.length}</span>
</div>
<div className="apikeys-workspace-rail-list">
<button
type="button"
className={`apikeys-workspace-rail-row${selectedId === null ? " apikeys-workspace-rail-row--selected" : ""}`}
onClick={showOverview}
aria-current={selectedId === null ? "page" : undefined}
>
<span className="apikeys-workspace-rail-name">{t("api.workspace.overview")}</span>
</button>
{keys.length === 0 ? (
<span className="apikeys-workspace-rail-empty">{t("api.workspace.noKeysHint")}</span>
) : (
keys.map(k => (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a path back to the API-key overview

After a user selects any key, selectedId stays non-null and every rail action only selects another key, so the overview containing key generation, endpoint information, and the one-time new-key banner becomes unreachable until the page is remounted or the key is deleted. This can also hide an uncopied newly generated secret after the user selects its rail entry; add an Overview rail row or a back action that calls setSelectedId(null).

Useful? React with 👍 / 👎.

<button
key={k.id}
type="button"
className={`apikeys-workspace-rail-row${selectedId === k.id ? " apikeys-workspace-rail-row--selected" : ""}`}
onClick={() => { setSelectedId(k.id); setConfirmDelete(false); }}
aria-current={selectedId === k.id ? "page" : undefined}
>
<span className="apikeys-workspace-rail-name">{k.name}</span>
<span className="apikeys-workspace-rail-meta">{k.prefix} · {formatCreatedDate(k.createdAt, localeTag)}</span>
</button>
))
)}
</div>
</aside>

<section className="apikeys-workspace-main" aria-label={t("api.workspace.details")}>
{selected ? (
<div className="awi-detail">
<button type="button" className="btn btn-ghost btn-sm awi-back" onClick={showOverview}>
{t("modal.back")}
</button>
<div className="awi-detail-head">
<h2 className="awi-detail-title">{selected.name}</h2>
<span className="awi-detail-actions">
{confirmDelete ? (
<>
<button type="button" className="btn btn-danger btn-sm" onClick={handleDeleteClick}>
<IconTrash /> {t("api.confirm")}
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setConfirmDelete(false)}>
{t("common.cancel")}
</button>
</>
) : (
<button type="button" className="btn btn-danger btn-sm" onClick={handleDeleteClick} aria-label={t("api.deleteAria")}>
<IconTrash /> {t("api.workspace.deleteKey")}
</button>
)}
</span>
</div>
{confirmDelete && (
<p className="muted" style={{ fontSize: 13, marginTop: 0 }}>{t("api.workspace.deleteConfirm")}</p>
)}
<div className="awi-section">
<h3 className="awi-section-title">{t("api.workspace.keyDetails")}</h3>
<dl className="awi-kv">
<div className="awi-kv-row">
<dt>{t("api.colName")}</dt>
<dd>{selected.name}</dd>
</div>
<div className="awi-kv-row">
<dt>{t("api.workspace.keyPrefix")}</dt>
<dd><code>{selected.prefix}</code></dd>
</div>
<div className="awi-kv-row">
<dt>{t("api.colCreated")}</dt>
<dd>{formatCreatedDate(selected.createdAt, localeTag)}</dd>
</div>
</dl>
</div>
</div>
) : (
<>
{keys.length > 0 && (
<div className="awi-hint">{t("api.workspace.selectKeyHint")}</div>
)}

{newKey && (
<div className="awi-newkey">
<h3 className="awi-newkey-title">{t("api.newKeyTitle")}</h3>
<p className="awi-newkey-note">{t("api.newKeyNote")}</p>
<div className="awi-newkey-row">
<code className="awi-newkey-code">{newKey}</code>
<button type="button" className="btn btn-sm btn-ghost" onClick={onCopyNewKey}>
{copied ? <><IconCheck /> {t("api.copied")}</> : t("api.copy")}
</button>
<button type="button" className="btn btn-sm btn-ghost" onClick={onDismissNewKey} aria-label={t("api.dismiss")}>
<IconX />
</button>
</div>
</div>
)}

<div className="awi-section">
<h3 className="awi-section-title">{t("api.endpoint")}</h3>
<code className="awi-endpoint">{endpoint}</code>
<p className="awi-endpoint-note">{t("api.endpointNote")}</p>
</div>

<div className="awi-section">
<h3 className="awi-section-title">{t("api.generateTitle")}</h3>
<div className="awi-generate-row">
<input
type="text"
className="input"
placeholder={t("api.keyNamePlaceholder")}
aria-label={t("api.keyNamePlaceholder")}
value={newName}
disabled={creating}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (
e.key !== "Enter"
|| e.nativeEvent.isComposing
|| creating
|| createInFlight.current
) {
return;
}
e.preventDefault();
void handleCreate();
}}
/>
<button type="button" className="btn btn-primary" onClick={() => { void handleCreate(); }} disabled={creating}>
<IconPlus /> {creating ? t("api.generating") : t("api.generate")}
</button>
</div>
</div>

<div className="awi-section">
<h3 className="awi-section-title">{t("api.usageTitle")}</h3>
<pre className="awi-usage-pre">{`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"))}
}'`}</pre>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</>
)}
</section>
</div>
</div>
);
}
9 changes: 9 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
9 changes: 9 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
9 changes: 9 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,15 @@ export const ja: Record<TKey, string> = {
"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 などのモデルを使用します。",
Expand Down
9 changes: 9 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,15 @@ export const ko: Record<TKey, string> = {
"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 등 다른 모델도 쓸 수 있게 해줍니다.",
Expand Down
9 changes: 9 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,15 @@ export const ru: Record<TKey, string> = {
"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.",
Expand Down
9 changes: 9 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,15 @@ export const zh: Record<TKey, string> = {
"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 等其他模型。",
Expand Down
Loading
Loading