api update

This commit is contained in:
bflatastic committed 2026-06-15 21:26:33 +07:00
1 parent 3f5bc6edf8
commit ce7b180c7e
2 files changed
+141 -37

No files matched your search

@@ -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<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<ApiKeyItem | null>(null);
// The full secret, shown exactly once right after a key is created.
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(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) {
)}
</div>
{createdKey && (
<NewKeyReveal
created={createdKey}
onDismiss={() => setCreatedKey(null)}
/>
)}
{creating && (
<CreateKeyForm
api={api}
@@ -182,7 +197,7 @@ function CreateKeyForm({
onCancel,
}: {
api: UserApiKeysApi;
onCreated: () => 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 (
<div className="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/10 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<FontAwesomeIcon icon={faKey} className="opacity-80" />
<span>"{created.name}" created</span>
</div>
<p className="text-xs opacity-80">
Copy your key now. For security, you won't be able to see it again.
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-white">
{created.api_key}
</code>
<Button
type="button"
variant="secondary"
className="h-9 px-3 shrink-0"
onClick={handleCopy}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1.5" />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<div className="flex justify-end pt-0.5">
<Button
type="button"
variant="secondary"
className="h-8 px-3"
onClick={onDismiss}
>
Done
</Button>
</div>
</div>
);
}
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 (
<div className="flex flex-col gap-2 rounded-lg border border-ui-panel-border p-3">
<div className="flex items-start justify-between gap-3">
@@ -290,21 +348,14 @@ function ApiKeyRow({
</p>
)}
<div className="flex items-center gap-2 pt-1">
<code className="min-w-0 flex-1 truncate rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-white/80">
{/* Truncated, display-only — the full secret is only shown on create. */}
<code className="min-w-0 truncate rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-white/70">
{item.api_key}
</code>
<button
type="button"
onClick={handleCopy}
title="Copy key"
className="shrink-0 rounded p-1 text-white/60 transition-colors hover:bg-white/[0.06] hover:text-white"
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
</button>
<span className="text-[10px] uppercase tracking-wider text-white/40">
{formatDate(item.created_at)}
</span>
</div>
<span className="pt-0.5 text-[10px] uppercase tracking-wider text-white/40">
{formatDate(item.created_at)}
</span>
</div>
{!isEditing && (
<div className="flex shrink-0 items-center gap-2">
+59 -6
View File
@@ -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<ApiResponse<ApiKeyItem>> {
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<ApiResponse<null>> {
}): Promise<ApiResponse<CreatedApiKey>> {
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<ApiResponse<null>> {
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) => {