233 lines
7.9 KiB
TypeScript
233 lines
7.9 KiB
TypeScript
/**
|
|
* ApiKeysPanel — grid of saved API keys with add button and empty state.
|
|
*/
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { useStore } from '@renderer/store';
|
|
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import { ApiKeyCard } from './ApiKeyCard';
|
|
import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
|
|
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
|
|
|
interface ApiKeysPanelProps {
|
|
projectPath: string | null;
|
|
projectLabel: string | null;
|
|
}
|
|
|
|
export const ApiKeysPanel = ({
|
|
projectPath,
|
|
projectLabel,
|
|
}: ApiKeysPanelProps): React.JSX.Element => {
|
|
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } =
|
|
useStore(
|
|
useShallow((s) => ({
|
|
apiKeys: s.apiKeys,
|
|
apiKeysLoading: s.apiKeysLoading,
|
|
apiKeysError: s.apiKeysError,
|
|
storageStatus: s.apiKeyStorageStatus,
|
|
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
|
cliStatus: s.cliStatus,
|
|
}))
|
|
);
|
|
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
|
|
|
useEffect(() => {
|
|
void fetchStorageStatus();
|
|
}, [fetchStorageStatus]);
|
|
|
|
const handleAdd = () => {
|
|
setEditingKey(null);
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleEdit = (key: ApiKeyEntry) => {
|
|
setEditingKey(key);
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleDialogClose = () => {
|
|
setDialogOpen(false);
|
|
setEditingKey(null);
|
|
};
|
|
|
|
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
|
|
const providerKeyCards = useMemo(() => {
|
|
if (!cliStatus?.providers?.length) {
|
|
return [];
|
|
}
|
|
|
|
return (
|
|
[
|
|
{
|
|
providerId: 'anthropic',
|
|
label: 'Anthropic runtime',
|
|
envVar: 'ANTHROPIC_API_KEY',
|
|
},
|
|
{
|
|
providerId: 'codex',
|
|
label: 'Codex runtime',
|
|
envVar: 'OPENAI_API_KEY',
|
|
},
|
|
] as const
|
|
).flatMap((item) => {
|
|
const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId);
|
|
if (!provider) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
...item,
|
|
authenticated: provider.authenticated,
|
|
apiKeyConfigured: provider.connection?.apiKeyConfigured ?? false,
|
|
sourceLabel: provider.connection?.apiKeySourceLabel ?? null,
|
|
statusMessage: provider.statusMessage ?? null,
|
|
},
|
|
];
|
|
});
|
|
}, [cliStatus]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{providerKeyCards.length > 0 && (
|
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
{providerKeyCards.map((provider) => (
|
|
<div
|
|
key={provider.providerId}
|
|
className="bg-surface-raised/30 rounded-lg border border-border p-4"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-text">{provider.label}</p>
|
|
<p className="mt-0.5 font-mono text-[11px] text-text-muted">{provider.envVar}</p>
|
|
</div>
|
|
<span
|
|
className={`rounded-full px-2 py-0.5 text-[11px] ${
|
|
provider.authenticated
|
|
? 'bg-emerald-500/10 text-emerald-300'
|
|
: provider.apiKeyConfigured
|
|
? 'bg-blue-500/10 text-blue-300'
|
|
: 'bg-amber-500/10 text-amber-300'
|
|
}`}
|
|
>
|
|
{provider.authenticated
|
|
? 'Connected'
|
|
: provider.apiKeyConfigured
|
|
? 'Key configured'
|
|
: 'Key missing'}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-xs text-text-muted">
|
|
{provider.sourceLabel
|
|
? `Current source: ${provider.sourceLabel}.`
|
|
: 'No stored or environment key detected for this provider.'}
|
|
{provider.statusMessage ? ` ${provider.statusMessage}` : ''}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Header row */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
|
Securely store API keys for auto-filling when installing MCP servers.
|
|
{storageStatus && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
{isOsKeychain ? (
|
|
<Info className="size-3.5 shrink-0 cursor-help text-text-muted" />
|
|
) : (
|
|
<AlertTriangle className="size-3.5 shrink-0 cursor-help text-amber-400" />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-xs">
|
|
{isOsKeychain ? (
|
|
<p>
|
|
Keys are encrypted via {storageStatus.backend} and stored with restricted file
|
|
permissions (owner-only).
|
|
</p>
|
|
) : (
|
|
<p>
|
|
OS keychain unavailable — keys are encrypted locally with AES-256. For stronger
|
|
protection, install a keyring service (gnome-keyring, kwallet).
|
|
</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
|
|
<Plus className="size-3.5" />
|
|
Add API Key
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{apiKeysError && (
|
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
|
{apiKeysError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Skeleton loading */}
|
|
{apiKeysLoading && apiKeys.length === 0 && (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{Array.from({ length: 3 }, (_, i) => (
|
|
<div
|
|
key={i}
|
|
className="skeleton-card flex flex-col gap-2 rounded-lg border border-border p-4"
|
|
style={{ animationDelay: `${i * 80}ms` }}
|
|
>
|
|
<div className="h-4 w-32 rounded bg-surface-raised" />
|
|
<div className="h-3 w-24 rounded bg-surface-raised" />
|
|
<div className="h-3 w-40 rounded bg-surface-raised" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!apiKeysLoading && apiKeys.length === 0 && (
|
|
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
|
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
|
<Key className="size-5 text-text-muted" />
|
|
</div>
|
|
<p className="text-sm text-text-secondary">No API keys saved</p>
|
|
<p className="text-xs text-text-muted">
|
|
Add keys to auto-fill environment variables when installing MCP servers.
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={handleAdd} className="mt-2 gap-1.5">
|
|
<Plus className="size-3.5" />
|
|
Add your first key
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Key cards grid */}
|
|
{apiKeys.length > 0 && (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{apiKeys.map((key) => (
|
|
<ApiKeyCard key={key.id} apiKey={key} onEdit={handleEdit} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Form dialog */}
|
|
<ApiKeyFormDialog
|
|
open={dialogOpen}
|
|
editingKey={editingKey}
|
|
currentProjectPath={projectPath}
|
|
currentProjectLabel={projectLabel}
|
|
onClose={handleDialogClose}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|