diff --git a/frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx b/frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx index ac776cbf54..8aaeb25895 100644 --- a/frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx +++ b/frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx @@ -7,11 +7,13 @@ import { faSpinnerThird, faCopy, faCheck, + faKey, faTrash, } from "@fortawesome/pro-solid-svg-icons"; import { UserApiKeysApi, type ApiKeyItem, + type CreatedApiKey, type UserInfo, } from "@storyteller/api"; import { toast } from "../toast/toast"; @@ -38,6 +40,8 @@ export function ApiKeySection(_props: ApiKeySectionProps) { const [creating, setCreating] = useState(false); const [editingToken, setEditingToken] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); + // The full secret, shown exactly once right after a key is created. + const [createdKey, setCreatedKey] = useState(null); const load = async (pageIndex: number) => { setLoading(true); @@ -47,7 +51,10 @@ export function ApiKeySection(_props: ApiKeySectionProps) { }); setLoading(false); if (response.success && response.data) { - setKeys(response.data.api_keys); + // The list includes soft-deleted keys; only show live ones. + setKeys( + response.data.api_keys.filter((k) => k.maybe_deleted_at === null), + ); setPage(response.pagination?.current ?? pageIndex); setTotalPages(Math.max(1, response.pagination?.total_page_count ?? 1)); } else { @@ -61,9 +68,10 @@ export function ApiKeySection(_props: ApiKeySectionProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleCreated = () => { + const handleCreated = (created: CreatedApiKey) => { setCreating(false); - // The new key shows up at the top of the first page. + // Reveal the full secret once; the truncated key then appears in the list. + setCreatedKey(created); load(0); }; @@ -103,6 +111,13 @@ export function ApiKeySection(_props: ApiKeySectionProps) { )} + {createdKey && ( + setCreatedKey(null)} + /> + )} + {creating && ( void; + onCreated: (created: CreatedApiKey) => void; onCancel: () => void; }) { const [name, setName] = useState(""); @@ -211,9 +226,9 @@ function CreateKeyForm({ }); setSubmitting(false); - if (response.success) { + if (response.success && response.data) { toast.success("API key created."); - onCreated(); + onCreated(response.data); } else { setError(response.errorMessage ?? "Could not create API key."); } @@ -249,6 +264,62 @@ function CreateKeyForm({ ); } +function NewKeyReveal({ + created, + onDismiss, +}: { + created: CreatedApiKey; + onDismiss: () => void; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(created.api_key); + setCopied(true); + toast.success("Copied to clipboard."); + } catch { + toast.error("Could not copy. Select and copy it manually."); + } + }; + + return ( +
+
+ + "{created.name}" created +
+

+ Copy your key now. For security, you won't be able to see it again. +

+
+ + {created.api_key} + + +
+
+ +
+
+ ); +} + function ApiKeyRow({ api, item, @@ -266,19 +337,6 @@ function ApiKeyRow({ onUpdated: () => void; onRequestDelete: () => void; }) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(item.api_key); - setCopied(true); - toast.success("Copied to clipboard."); - window.setTimeout(() => setCopied(false), 1500); - } catch { - toast.error("Could not copy. Select and copy it manually."); - } - }; - return (
@@ -290,21 +348,14 @@ function ApiKeyRow({

)}
- + {/* Truncated, display-only — the full secret is only shown on create. */} + {item.api_key} - + + {formatDate(item.created_at)} +
- - {formatDate(item.created_at)} -
{!isEditing && (
diff --git a/frontend/libs/api/src/lib/UserApiKeysApi.ts b/frontend/libs/api/src/lib/UserApiKeysApi.ts index 7f3dca4352..4e640c2ea1 100644 --- a/frontend/libs/api/src/lib/UserApiKeysApi.ts +++ b/frontend/libs/api/src/lib/UserApiKeysApi.ts @@ -5,10 +5,23 @@ export interface ApiKeyItem { token: string; name: string; maybe_description: string | null; - // The full key value, returned on every list so users can copy it any time. + // TRUNCATED key value (display-only). The full secret is only ever returned + // once, from CreateApiKey — never from the list. api_key: string; created_at: string; updated_at: string; + // Soft-delete timestamp; null means the key is live. The list includes + // deleted keys, so the UI filters these out. + maybe_deleted_at: string | null; +} + +// Create response: the full secret, shown to the user exactly once, plus the +// `token` used for subsequent update/delete calls. +export interface CreatedApiKey { + token: string; + name: string; + maybe_description: string | null; + api_key: string; } export interface ApiKeysPage { @@ -60,26 +73,66 @@ export class UserApiKeysApi extends ApiManager { .catch((err) => ({ success: false, errorMessage: err.message })); } + // Single-key lookup. Not currently used by the settings UI (the list already + // carries everything), but provided for parity with the backend. + // TODO: confirm the response wrapper field name (`api_key_info` assumed). + public GetApiKey({ + token, + }: { + token: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`; + return this.jsonFetch< + { success: boolean; api_key_info?: ApiKeyItem } & ErrorBody + >(endpoint, { method: "GET" }) + .then((response) => { + if (!response.success || !response.api_key_info) { + return { + success: false, + errorMessage: response.message ?? this.statusFallback(response), + }; + } + return { success: true, data: response.api_key_info }; + }) + .catch((err) => ({ success: false, errorMessage: err.message })); + } + public CreateApiKey({ name, maybeDescription, }: { name: string; maybeDescription?: string; - }): Promise> { + }): Promise> { const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/create`; - return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, { + return this.jsonFetch< + { + success: boolean; + token?: string; + name?: string; + maybe_description?: string | null; + api_key?: string; + } & ErrorBody + >(endpoint, { method: "POST", body: { name, maybe_description: maybeDescription ?? null }, }) .then((response) => { - if (!response.success) { + if (!response.success || !response.token || !response.api_key) { return { success: false, errorMessage: response.message ?? this.statusFallback(response), }; } - return { success: true }; + return { + success: true, + data: { + token: response.token, + name: response.name ?? name, + maybe_description: response.maybe_description ?? null, + api_key: response.api_key, + }, + }; }) .catch((err) => ({ success: false, errorMessage: err.message })); } @@ -93,7 +146,7 @@ export class UserApiKeysApi extends ApiManager { }): Promise> { const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`; return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, { - method: "POST", + method: "PUT", body: { maybe_description: maybeDescription }, }) .then((response) => {