UI_REDESIGN chunk 4 was never finished: 39 app/component files still used raw Tailwind greys, kept readable in light mode only by the !important override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both. Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2, 500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised, border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder. Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700, text-white and marketing pages left alone. text-[9px] -> text-[10px]. Frontend unverified: no Node on the authoring box (#207). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
6.7 KiB
TypeScript
156 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { listApiKeys, createApiKey, revokeApiKey, type ApiKeyRecord } from "@/lib/apiKeys";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Badge } from "@/components/ui/Badge";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
|
|
function fmtDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
}
|
|
|
|
function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated: (r: ApiKeyRecord) => void }) {
|
|
const [name, setName] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [rawKey, setRawKey] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
try {
|
|
const { record, rawKey } = await createApiKey(name);
|
|
onCreated(record);
|
|
setRawKey(rawKey);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function copy() {
|
|
if (!rawKey) return;
|
|
navigator.clipboard?.writeText(rawKey).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
|
}
|
|
|
|
if (rawKey) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
|
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
|
<h2 className="text-white font-semibold">Key created</h2>
|
|
<p className="text-xs text-ink-2">
|
|
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
|
<code className="text-ink-2">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
|
</p>
|
|
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
|
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button variant="secondary" onClick={copy} fullWidth>{copied ? "Copied!" : "Copy key"}</Button>
|
|
<Button onClick={onClose} fullWidth>Done</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
|
<Card padding="lg" className="w-full max-w-md">
|
|
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="text-xs text-ink-2 block mb-1">Label</label>
|
|
<input
|
|
required value={name} onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g. Ops dashboard integration"
|
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button type="submit" disabled={saving} fullWidth>{saving ? "Creating…" : "Create key"}</Button>
|
|
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ApiKeysSettingsPage() {
|
|
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
useEffect(() => { listApiKeys().then(setKeys).finally(() => setLoading(false)); }, []);
|
|
|
|
async function handleRevoke(id: string) {
|
|
await revokeApiKey(id);
|
|
setKeys((prev) => prev.map((k) => (k.key_id === id ? { ...k, revoked: true } : k)));
|
|
}
|
|
|
|
const active = keys.filter((k) => !k.revoked);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
|
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
|
<p className="text-ink-2 text-xs mt-1 leading-relaxed">
|
|
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
|
demo module (<code className="text-ink-2">lib/apiKeys.ts</code>) so the flow can be reviewed end to
|
|
end. See that file for the exact backend routes a real integration needs.
|
|
</p>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<CreateKeyModal onClose={() => setShowCreate(false)} onCreated={(r) => setKeys((prev) => [...prev, r])} />
|
|
)}
|
|
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-ink-muted">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
|
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
|
</div>
|
|
|
|
{!loading && keys.length === 0 ? (
|
|
<EmptyState title="No API keys yet" description="Create one to authenticate external integrations against the DRB API." />
|
|
) : (
|
|
<Card padding="none" className="overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
|
|
<th className="px-4 py-3 text-left">Label</th>
|
|
<th className="px-4 py-3 text-left">Key</th>
|
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Last used</th>
|
|
<th className="px-4 py-3 text-left">Status</th>
|
|
<th className="px-4 py-3 w-20"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{keys.map((k) => (
|
|
<tr key={k.key_id} className="border-b border-line last:border-0">
|
|
<td className="px-4 py-3 text-white">{k.name}</td>
|
|
<td className="px-4 py-3 text-ink-muted font-mono text-xs">{k.key_prefix}…</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
|
<td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
|
<td className="px-4 py-3">
|
|
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{!k.revoked && (
|
|
<button onClick={() => handleRevoke(k.key_id)} className="text-xs text-red-500 hover:text-red-400 transition-colors">
|
|
Revoke
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|