2026-08-23 18:18:17 -04:00

277 lines
9.5 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { PortalMenu } from "@/client/components/PortalMenu";
import { CopyButton } from "@/client/features/ai-mcp/SetupControls";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client";
// Better Auth rejects longer names with INVALID_NAME_LENGTH.
const MAX_KEY_NAME_LENGTH = 32;
export function ApiKeySettings() {
const queryClient = useQueryClient();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [name, setName] = useState("");
const [createdKey, setCreatedKey] = useState<string | null>(null);
const mcpUrl =
typeof window === "undefined"
? "https://app.openseo.so/mcp"
: `${window.location.origin}/mcp`;
const apiKeysQuery = useQuery({
queryKey: ["apiKeys"],
queryFn: async () => {
const result = await authClient.apiKey.list();
if (result.error) {
throw new Error(result.error.message ?? "Failed to load API keys");
}
return result.data.apiKeys.map((key) => ({
id: key.id,
name: key.name,
start: key.start,
createdAt: new Date(key.createdAt),
lastRequest: key.lastRequest ? new Date(key.lastRequest) : null,
}));
},
});
const createMutation = useMutation({
mutationFn: async (keyName: string) => {
const result = await authClient.apiKey.create({ name: keyName });
if (result.error || !result.data?.key) {
throw new Error(result.error?.message ?? "Failed to create the key");
}
return result.data.key;
},
onSuccess: (key) => {
setCreatedKey(key);
setName("");
captureClientEvent("mcp:api_key_created");
void queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
onError: (error) => {
toast.error(getStandardErrorMessage(error));
},
});
const revokeMutation = useMutation({
mutationFn: async (keyId: string) => {
const result = await authClient.apiKey.delete({ keyId });
if (result.error) {
throw new Error(result.error.message ?? "Failed to revoke the key");
}
},
onSuccess: () => {
captureClientEvent("mcp:api_key_revoked");
toast.success("API key revoked");
void queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
onError: (error) => {
toast.error(getStandardErrorMessage(error));
},
});
const apiKeys = apiKeysQuery.data ?? [];
const closeCreateModal = () => {
setIsCreateOpen(false);
setCreatedKey(null);
setName("");
};
return (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">API keys</h2>
<div className="flex items-start justify-between gap-6">
<div>
<p className="text-sm">
Authenticate MCP clients when OAuth doesn't work
</p>
<p className="mt-1 text-sm text-base-content/60">
Use this for remote agents like Hermes where the normal login flow
doesn't work.
</p>
<p className="mt-1 text-sm">
<a
className="link link-primary"
href="https://openseo.so/docs/mcp"
target="_blank"
rel="noreferrer"
>
Setup guide
</a>
</p>
</div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setIsCreateOpen(true)}
>
Create API key
</button>
</div>
{apiKeysQuery.isError ? (
<p className="text-sm text-error">We couldn't load your API keys.</p>
) : apiKeys.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created</th>
<th>Last used</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="hover">
<td className="max-w-[220px] truncate font-medium">
{key.name || "Unnamed key"}
</td>
<td
className="font-mono text-xs text-base-content/70"
data-ph-mask
>
{key.start || "oseo_"}…
</td>
<td className="text-xs text-base-content/70">
{key.createdAt.toLocaleDateString()}
</td>
<td className="text-xs text-base-content/70">
{key.lastRequest
? key.lastRequest.toLocaleDateString()
: "Never"}
</td>
<td>
<PortalMenu
ariaLabel={`Actions for ${key.name || "API key"}`}
>
{(close) => (
<li>
<button
className="text-error"
disabled={
revokeMutation.isPending &&
revokeMutation.variables === key.id
}
onClick={() => {
close();
if (
window.confirm(
`Revoke "${key.name || "Unnamed key"}"? Clients using it will stop working.`,
)
) {
revokeMutation.mutate(key.id);
}
}}
>
<Trash2 className="size-3.5" />
Revoke key
</button>
</li>
)}
</PortalMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{isCreateOpen ? (
<div className="modal modal-open">
<div className="modal-box max-w-md">
{createdKey ? (
<>
<h3 className="text-lg font-bold">Copy your new API key</h3>
<p className="mt-2 text-sm text-base-content/60">
It won't be shown again. Send it as{" "}
<span className="font-mono text-xs">
Authorization: Bearer
</span>{" "}
to <span className="font-mono text-xs">{mcpUrl}</span>.
</p>
<div className="mt-4 flex items-center gap-2">
<code
className="min-w-0 flex-1 overflow-x-auto rounded bg-base-200 px-2.5 py-2 font-mono text-xs"
data-ph-mask
>
{createdKey}
</code>
<CopyButton
value={createdKey}
successMessage="API key copied"
iconOnly
/>
</div>
<div className="modal-action">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={closeCreateModal}
>
Done
</button>
</div>
</>
) : (
<form
onSubmit={(event) => {
event.preventDefault();
if (name.trim()) createMutation.mutate(name.trim());
}}
>
<h3 className="text-lg font-bold">Create API key</h3>
<label className="form-control mt-4 w-full">
<span className="label-text pb-1 text-xs text-base-content/60">
Name
</span>
<input
className="input input-sm input-bordered w-full"
placeholder="Claude Code on laptop"
value={name}
maxLength={MAX_KEY_NAME_LENGTH}
onChange={(event) => setName(event.currentTarget.value)}
required
autoFocus
/>
</label>
<div className="modal-action">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={closeCreateModal}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={createMutation.isPending || !name.trim()}
>
{createMutation.isPending ? "Creating…" : "Create"}
</button>
</div>
</form>
)}
</div>
{/* No backdrop close on the reveal step: the key is shown once. */}
{createdKey ? (
<div className="modal-backdrop" />
) : (
<div className="modal-backdrop" onClick={closeCreateModal} />
)}
</div>
) : null}
</section>
);
}