From 3f5bc6edf887f869f47867c88bbeeebb131cd35d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 15 Jun 2026 21:16:44 +0700 Subject: [PATCH] Webapp: settings modal feature flagged api key section --- .../settings-modal/ApiKeySection.tsx | 513 ++++++++++++++++++ .../settings-modal/SettingsModal.tsx | 78 ++- frontend/libs/api/src/index.ts | 1 + frontend/libs/api/src/lib/UserApiKeysApi.ts | 156 ++++++ .../libs/api/src/lib/enums/UserFeatures.ts | 1 + 5 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx create mode 100644 frontend/libs/api/src/lib/UserApiKeysApi.ts diff --git a/frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx b/frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx new file mode 100644 index 0000000000..ac776cbf54 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/components/settings-modal/ApiKeySection.tsx @@ -0,0 +1,513 @@ +import { useEffect, useState } from "react"; +import { Button } from "@storyteller/ui-button"; +import { Input } from "@storyteller/ui-input"; +import { Modal } from "@storyteller/ui-modal"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faSpinnerThird, + faCopy, + faCheck, + faTrash, +} from "@fortawesome/pro-solid-svg-icons"; +import { + UserApiKeysApi, + type ApiKeyItem, + type UserInfo, +} from "@storyteller/api"; +import { toast } from "../toast/toast"; + +const NAME_MAX = 255; +const DESCRIPTION_MAX = 512; +const PAGE_SIZE = 10; + +const INPUT_CLASS = + "w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"; + +interface ApiKeySectionProps { + user: UserInfo; +} + +export function ApiKeySection(_props: ApiKeySectionProps) { + const [api] = useState(() => new UserApiKeysApi()); + + const [keys, setKeys] = useState([]); + const [page, setPage] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + + const [creating, setCreating] = useState(false); + const [editingToken, setEditingToken] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + + const load = async (pageIndex: number) => { + setLoading(true); + const response = await api.ListApiKeys({ + pageSize: PAGE_SIZE, + pageIndex, + }); + setLoading(false); + if (response.success && response.data) { + setKeys(response.data.api_keys); + setPage(response.pagination?.current ?? pageIndex); + setTotalPages(Math.max(1, response.pagination?.total_page_count ?? 1)); + } else { + toast.error(response.errorMessage ?? "Could not load API keys."); + } + }; + + useEffect(() => { + load(0); + // Load once on mount; pagination drives subsequent loads explicitly. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleCreated = () => { + setCreating(false); + // The new key shows up at the top of the first page. + load(0); + }; + + const handleDeleteConfirmed = async () => { + if (!pendingDelete) return; + const target = pendingDelete; + setPendingDelete(null); + const response = await api.DeleteApiKey({ token: target.token }); + if (response.success) { + toast.success("API key deleted."); + // Reload the current page; step back if we just emptied it. + const nextPage = keys.length === 1 && page > 0 ? page - 1 : page; + load(nextPage); + } else { + toast.error(response.errorMessage ?? "Could not delete API key."); + } + }; + + return ( +
+
+
+

API keys

+

+ Create and manage keys to access the API programmatically. +

+
+ {!creating && ( + + )} +
+ + {creating && ( + setCreating(false)} + /> + )} + +
+ + {loading ? ( +
+ +
+ ) : keys.length === 0 ? ( +
+ You don't have any API keys yet. +
+ ) : ( +
+ {keys.map((item) => ( + setEditingToken(item.token)} + onCloseEdit={() => setEditingToken(null)} + onUpdated={() => { + setEditingToken(null); + load(page); + }} + onRequestDelete={() => setPendingDelete(item)} + /> + ))} +
+ )} + + {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} + + +
+ )} + + setPendingDelete(null)} + onConfirm={handleDeleteConfirmed} + /> +
+ ); +} + +function CreateKeyForm({ + api, + onCreated, + onCancel, +}: { + api: UserApiKeysApi; + onCreated: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const trimmedName = name.trim(); + const canSubmit = trimmedName.length > 0; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!canSubmit) { + setError("Enter a name for the key."); + return; + } + + setSubmitting(true); + const trimmedDescription = description.trim(); + const response = await api.CreateApiKey({ + name: trimmedName, + maybeDescription: + trimmedDescription.length > 0 ? trimmedDescription : undefined, + }); + setSubmitting(false); + + if (response.success) { + toast.success("API key created."); + onCreated(); + } else { + setError(response.errorMessage ?? "Could not create API key."); + } + }; + + return ( +
+ setName(e.target.value)} + placeholder="Name (required)" + maxLength={NAME_MAX} + autoFocus + inputClassName={INPUT_CLASS} + /> + setDescription(e.target.value)} + placeholder="Description (optional)" + maxLength={DESCRIPTION_MAX} + inputClassName={INPUT_CLASS} + /> + {error && } + + + ); +} + +function ApiKeyRow({ + api, + item, + isEditing, + onOpenEdit, + onCloseEdit, + onUpdated, + onRequestDelete, +}: { + api: UserApiKeysApi; + item: ApiKeyItem; + isEditing: boolean; + onOpenEdit: () => void; + onCloseEdit: () => void; + 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 ( +
+
+
+

{item.name}

+ {item.maybe_description && ( +

+ {item.maybe_description} +

+ )} +
+ + {item.api_key} + + +
+ + {formatDate(item.created_at)} + +
+ {!isEditing && ( +
+ + +
+ )} +
+ {isEditing && ( + + )} +
+ ); +} + +function EditDescriptionForm({ + api, + item, + onUpdated, + onCancel, +}: { + api: UserApiKeysApi; + item: ApiKeyItem; + onUpdated: () => void; + onCancel: () => void; +}) { + const initial = item.maybe_description ?? ""; + const [value, setValue] = useState(initial); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const trimmed = value.trim(); + const isDirty = trimmed !== initial.trim(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!isDirty) { + onCancel(); + return; + } + + setSubmitting(true); + const response = await api.UpdateApiKey({ + token: item.token, + maybeDescription: trimmed.length > 0 ? trimmed : null, + }); + setSubmitting(false); + + if (response.success) { + toast.success("API key updated."); + onUpdated(); + } else { + setError(response.errorMessage ?? "Could not update API key."); + } + }; + + return ( +
+ setValue(e.target.value)} + placeholder="Description (optional)" + maxLength={DESCRIPTION_MAX} + autoFocus + inputClassName={INPUT_CLASS} + /> + {error && } + + + ); +} + +function DeleteConfirmModal({ + item, + onCancel, + onConfirm, +}: { + item: ApiKeyItem | null; + onCancel: () => void; + onConfirm: () => void; +}) { + const [deleting, setDeleting] = useState(false); + + const handleConfirm = async () => { + setDeleting(true); + await onConfirm(); + setDeleting(false); + }; + + return ( + +
+
+

Delete API key

+

+ Delete "{item?.name}"? Any application using this key will stop + working. This can't be undone. +

+
+
+ + +
+
+
+ ); +} + +function FormActions({ + onCancel, + submitting, + disabled, + submitLabel, +}: { + onCancel: () => void; + submitting: boolean; + disabled: boolean; + submitLabel: string; +}) { + return ( +
+ + +
+ ); +} + +function FormError({ message }: { message: string }) { + return

{message}

; +} + +function formatDate(raw: string): string { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return raw; + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx b/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx index e3d0f39fdd..59bb1d9986 100644 --- a/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx +++ b/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx @@ -1,36 +1,65 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Modal } from "@storyteller/ui-modal"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCog, faUser } from "@fortawesome/pro-solid-svg-icons"; +import { faCog, faUser, faKey } from "@fortawesome/pro-solid-svg-icons"; import { Switch } from "@storyteller/ui-switch"; +import { USER_FEATURE_FLAGS } from "@storyteller/api"; import { twMerge } from "tailwind-merge"; import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store"; import { useSession } from "../../lib/session"; import { AccountSection } from "./AccountSection"; +import { ApiKeySection } from "./ApiKeySection"; -type Tab = "general" | "account"; +type Tab = "general" | "account" | "apiKeys"; interface SettingsModalProps { isOpen: boolean; onClose: () => void; } -const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [ +const BASE_TABS: { id: Tab; label: string; icon: typeof faCog }[] = [ { id: "general", label: "General", icon: faCog }, { id: "account", label: "Account", icon: faUser }, ]; +const API_KEYS_TAB: { id: Tab; label: string; icon: typeof faCog } = { + id: "apiKeys", + label: "API Keys", + icon: faKey, +}; + export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { + const { user } = useSession(); const [tab, setTab] = useState("general"); + const hasApiKeyFlag = !!user?.maybe_feature_flags?.includes( + USER_FEATURE_FLAGS.API_KEY, + ); + + const tabs = useMemo( + () => (hasApiKeyFlag ? [...BASE_TABS, API_KEYS_TAB] : BASE_TABS), + [hasApiKeyFlag], + ); + useEffect(() => { if (isOpen) setTab("general"); }, [isOpen]); - const activeLabel = TABS.find((t) => t.id === tab)?.label ?? ""; + // If the only-conditional tab disappears (e.g. flag revoked while open), + // fall back to a visible tab. + useEffect(() => { + if (!tabs.some((t) => t.id === tab)) setTab("general"); + }, [tabs, tab]); + + const activeLabel = tabs.find((t) => t.id === tab)?.label ?? ""; return ( - +
@@ -39,7 +68,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {

- {TABS.map((t) => ( + {tabs.map((t) => (
@@ -82,12 +112,16 @@ function GeneralPanel() {

Enter to generate

- When on, pressing Enter submits the prompt and Shift+Enter - adds a new line. When off (default), both Enter and - Shift+Enter add a new line, use the button to submit. + When on, pressing Enter submits the prompt and Shift+Enter adds a + new line. When off (default), both Enter and Shift+Enter add a new + line, use the button to submit.

- +
); @@ -116,3 +150,25 @@ function AccountPanel() { ); } + +function ApiKeysPanel() { + const { user, authChecked } = useSession(); + + if (!authChecked) { + return
Loading API keys...
; + } + + if (!user) { + return ( +
+ You need to be signed in to manage API keys. +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/libs/api/src/index.ts b/frontend/libs/api/src/index.ts index f2700e7e84..35b1730f59 100644 --- a/frontend/libs/api/src/index.ts +++ b/frontend/libs/api/src/index.ts @@ -17,6 +17,7 @@ export * from "./lib/PromptsApi.js"; export * from "./lib/TtsApi.js"; export * from "./lib/UserBookmarksApi.js"; export * from "./lib/PasswordResetApi.js"; +export * from "./lib/UserApiKeysApi.js"; export * from "./lib/UserReferralCodesApi.js"; export * from "./lib/UsersApi.js"; export * from "./lib/VideoApi.js"; diff --git a/frontend/libs/api/src/lib/UserApiKeysApi.ts b/frontend/libs/api/src/lib/UserApiKeysApi.ts new file mode 100644 index 0000000000..7f3dca4352 --- /dev/null +++ b/frontend/libs/api/src/lib/UserApiKeysApi.ts @@ -0,0 +1,156 @@ +import { ApiManager, ApiResponse, buildSessionHeaders } from "./ApiManager.js"; +import { FetchProxy as fetch } from "@storyteller/tauri-utils"; + +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. + api_key: string; + created_at: string; + updated_at: string; +} + +export interface ApiKeysPage { + current: number; + total_page_count: number; +} + +interface ErrorBody { + success: boolean; + error_code?: number; + error_code_str?: string; + message?: string; +} + +export class UserApiKeysApi extends ApiManager { + public ListApiKeys({ + pageSize, + pageIndex, + }: { + pageSize?: number; + pageIndex?: number; + } = {}): Promise> { + const query = new URLSearchParams(); + if (pageSize !== undefined) query.set("page_size", String(pageSize)); + if (pageIndex !== undefined) query.set("page_index", String(pageIndex)); + const suffix = query.toString() ? `?${query.toString()}` : ""; + const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/list${suffix}`; + return this.jsonFetch<{ + success: boolean; + api_keys?: ApiKeyItem[]; + pagination?: ApiKeysPage; + } & ErrorBody>(endpoint, { method: "GET" }) + .then((response) => { + if (!response.success) { + return { + success: false, + errorMessage: response.message ?? this.statusFallback(response), + }; + } + return { + success: true, + data: { api_keys: response.api_keys ?? [] }, + pagination: response.pagination ?? { + current: 0, + total_page_count: 1, + }, + }; + }) + .catch((err) => ({ success: false, errorMessage: err.message })); + } + + public CreateApiKey({ + name, + maybeDescription, + }: { + name: string; + maybeDescription?: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/create`; + return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, { + method: "POST", + body: { name, maybe_description: maybeDescription ?? null }, + }) + .then((response) => { + if (!response.success) { + return { + success: false, + errorMessage: response.message ?? this.statusFallback(response), + }; + } + return { success: true }; + }) + .catch((err) => ({ success: false, errorMessage: err.message })); + } + + public UpdateApiKey({ + token, + maybeDescription, + }: { + token: string; + maybeDescription: string | null; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`; + return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, { + method: "POST", + body: { maybe_description: maybeDescription }, + }) + .then((response) => { + if (!response.success) { + return { + success: false, + errorMessage: response.message ?? this.statusFallback(response), + }; + } + return { success: true }; + }) + .catch((err) => ({ success: false, errorMessage: err.message })); + } + + public DeleteApiKey({ token }: { token: string }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`; + return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, { + method: "DELETE", + }) + .then((response) => { + if (!response.success) { + return { + success: false, + errorMessage: response.message ?? this.statusFallback(response), + }; + } + return { success: true }; + }) + .catch((err) => ({ success: false, errorMessage: err.message })); + } + + // Parses JSON for both 2xx and 4xx so `BadInputWithSimpleMessage` text + // reaches the caller. The base ApiManager.fetch throws on non-2xx. + private async jsonFetch( + endpoint: string, + { method, body }: { method: string; body?: unknown }, + ): Promise { + const response = await fetch(endpoint, { + method, + headers: buildSessionHeaders({ + Accept: "application/json", + "Content-Type": "application/json", + }), + credentials: "include", + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + try { + return JSON.parse(text) as T; + } catch { + throw new Error(text || `Request failed with status ${response.status}`); + } + } + + private statusFallback(response: ErrorBody): string { + if (response.error_code_str) return response.error_code_str; + if (response.error_code) return `Request failed (${response.error_code})`; + return "Request failed"; + } +} diff --git a/frontend/libs/api/src/lib/enums/UserFeatures.ts b/frontend/libs/api/src/lib/enums/UserFeatures.ts index b24342762e..c07efbaa65 100644 --- a/frontend/libs/api/src/lib/enums/UserFeatures.ts +++ b/frontend/libs/api/src/lib/enums/UserFeatures.ts @@ -1,4 +1,5 @@ export enum USER_FEATURE_FLAGS { + API_KEY = "api_key", EXPLORE_MEDIA = "explore_media", REFERRALS = "referrals", STUDIO = "studio",