|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useState, useEffect, useCallback } from "react"; |
| 4 | +import { Copy, Trash2, Plus, Eye, EyeOff } from "lucide-react"; |
| 5 | + |
| 6 | +type ApiKey = { |
| 7 | + id: string; |
| 8 | + name: string; |
| 9 | + prefix: string; |
| 10 | + created_at: string; |
| 11 | + last_used_at: string | null; |
| 12 | +}; |
| 13 | + |
| 14 | +type RevealedKey = { |
| 15 | + id: string; |
| 16 | + key: string; |
| 17 | +}; |
| 18 | + |
| 19 | +function fmt(iso: string): string { |
| 20 | + return new Date(iso).toLocaleString(); |
| 21 | +} |
| 22 | + |
| 23 | +function CopyButton({ text }: { text: string }) { |
| 24 | + const [copied, setCopied] = useState(false); |
| 25 | + const copy = () => { |
| 26 | + navigator.clipboard.writeText(text); |
| 27 | + setCopied(true); |
| 28 | + setTimeout(() => setCopied(false), 1500); |
| 29 | + }; |
| 30 | + return ( |
| 31 | + <button |
| 32 | + onClick={copy} |
| 33 | + className="ml-1 inline-flex items-center rounded p-0.5 text-neutral-500 hover:text-neutral-200 transition" |
| 34 | + title="Copy" |
| 35 | + > |
| 36 | + <Copy size={13} /> |
| 37 | + {copied && <span className="ml-1 text-[10px] text-emerald-400">copied</span>} |
| 38 | + </button> |
| 39 | + ); |
| 40 | +} |
| 41 | + |
| 42 | +export default function KeysPage() { |
| 43 | + const [keys, setKeys] = useState<ApiKey[]>([]); |
| 44 | + const [loading, setLoading] = useState(true); |
| 45 | + const [creating, setCreating] = useState(false); |
| 46 | + const [newName, setNewName] = useState(""); |
| 47 | + const [revealed, setRevealed] = useState<RevealedKey | null>(null); |
| 48 | + const [error, setError] = useState<string | null>(null); |
| 49 | + |
| 50 | + const fetchKeys = useCallback(async () => { |
| 51 | + try { |
| 52 | + const res = await fetch("/api/cp/v1/keys", { credentials: "include" }); |
| 53 | + if (!res.ok) throw new Error(`${res.status}`); |
| 54 | + const data = await res.json(); |
| 55 | + setKeys(data.keys ?? []); |
| 56 | + } catch { |
| 57 | + setError("Failed to load API keys."); |
| 58 | + } finally { |
| 59 | + setLoading(false); |
| 60 | + } |
| 61 | + }, []); |
| 62 | + |
| 63 | + useEffect(() => { |
| 64 | + fetchKeys(); |
| 65 | + }, [fetchKeys]); |
| 66 | + |
| 67 | + const createKey = async () => { |
| 68 | + if (!newName.trim()) return; |
| 69 | + setCreating(true); |
| 70 | + setError(null); |
| 71 | + try { |
| 72 | + const res = await fetch("/api/cp/v1/keys", { |
| 73 | + method: "POST", |
| 74 | + credentials: "include", |
| 75 | + headers: { "Content-Type": "application/json" }, |
| 76 | + body: JSON.stringify({ name: newName.trim() }), |
| 77 | + }); |
| 78 | + if (!res.ok) throw new Error(`${res.status}`); |
| 79 | + const data = await res.json(); |
| 80 | + setRevealed({ id: data.id, key: data.key }); |
| 81 | + setNewName(""); |
| 82 | + await fetchKeys(); |
| 83 | + } catch { |
| 84 | + setError("Failed to create API key."); |
| 85 | + } finally { |
| 86 | + setCreating(false); |
| 87 | + } |
| 88 | + }; |
| 89 | + |
| 90 | + const deleteKey = async (id: string) => { |
| 91 | + if (!confirm("Revoke this key? This cannot be undone.")) return; |
| 92 | + try { |
| 93 | + const res = await fetch(`/api/cp/v1/keys/${id}`, { |
| 94 | + method: "DELETE", |
| 95 | + credentials: "include", |
| 96 | + }); |
| 97 | + if (!res.ok) throw new Error(`${res.status}`); |
| 98 | + setKeys((prev) => prev.filter((k) => k.id !== id)); |
| 99 | + if (revealed?.id === id) setRevealed(null); |
| 100 | + } catch { |
| 101 | + setError("Failed to revoke key."); |
| 102 | + } |
| 103 | + }; |
| 104 | + |
| 105 | + return ( |
| 106 | + <div className="max-w-3xl"> |
| 107 | + <div className="flex items-end justify-between"> |
| 108 | + <div> |
| 109 | + <h1 className="text-2xl font-semibold tracking-tight">API Keys</h1> |
| 110 | + <p className="mt-1 text-sm text-neutral-400"> |
| 111 | + Keys authenticate the{" "} |
| 112 | + <span className="font-mono text-neutral-300">ControlPlaneClient</span> in the OSS library. |
| 113 | + </p> |
| 114 | + </div> |
| 115 | + </div> |
| 116 | + |
| 117 | + {/* Create key */} |
| 118 | + <div className="mt-8 rounded-xl border border-neutral-900 bg-neutral-950/40 p-5"> |
| 119 | + <h2 className="text-sm font-medium text-neutral-200">Create a new key</h2> |
| 120 | + <div className="mt-3 flex gap-2"> |
| 121 | + <input |
| 122 | + value={newName} |
| 123 | + onChange={(e) => setNewName(e.target.value)} |
| 124 | + onKeyDown={(e) => e.key === "Enter" && createKey()} |
| 125 | + placeholder="Key name (e.g. production)" |
| 126 | + className="flex-1 rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 placeholder-neutral-600 focus:border-neutral-600 focus:outline-none" |
| 127 | + /> |
| 128 | + <button |
| 129 | + onClick={createKey} |
| 130 | + disabled={creating || !newName.trim()} |
| 131 | + className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3.5 py-2 text-xs font-medium text-black hover:bg-neutral-200 transition disabled:opacity-40" |
| 132 | + > |
| 133 | + <Plus size={13} /> |
| 134 | + {creating ? "Creating…" : "Create"} |
| 135 | + </button> |
| 136 | + </div> |
| 137 | + |
| 138 | + {/* One-time reveal */} |
| 139 | + {revealed && ( |
| 140 | + <div className="mt-4 rounded-lg border border-emerald-900/60 bg-emerald-950/30 p-4"> |
| 141 | + <div className="flex items-center justify-between"> |
| 142 | + <p className="text-xs font-medium text-emerald-400"> |
| 143 | + Copy this key now — it won't be shown again. |
| 144 | + </p> |
| 145 | + <button |
| 146 | + onClick={() => setRevealed(null)} |
| 147 | + className="text-xs text-neutral-500 hover:text-neutral-300" |
| 148 | + > |
| 149 | + dismiss |
| 150 | + </button> |
| 151 | + </div> |
| 152 | + <div className="mt-2 flex items-center gap-1 font-mono text-sm text-emerald-300"> |
| 153 | + <span className="break-all">{revealed.key}</span> |
| 154 | + <CopyButton text={revealed.key} /> |
| 155 | + </div> |
| 156 | + </div> |
| 157 | + )} |
| 158 | + </div> |
| 159 | + |
| 160 | + {error && ( |
| 161 | + <p className="mt-3 text-xs text-red-400">{error}</p> |
| 162 | + )} |
| 163 | + |
| 164 | + {/* Key list */} |
| 165 | + <div className="mt-6"> |
| 166 | + {loading ? ( |
| 167 | + <div className="py-10 text-center text-sm text-neutral-600">Loading…</div> |
| 168 | + ) : keys.length === 0 ? ( |
| 169 | + <div className="rounded-xl border border-neutral-900 bg-neutral-950/40 p-10 text-center"> |
| 170 | + <div className="text-sm font-medium text-neutral-200">No keys yet</div> |
| 171 | + <p className="mt-2 text-xs text-neutral-500"> |
| 172 | + Create your first key above to start sending events. |
| 173 | + </p> |
| 174 | + </div> |
| 175 | + ) : ( |
| 176 | + <div className="overflow-hidden rounded-xl border border-neutral-900 bg-neutral-950/40"> |
| 177 | + <table className="w-full text-left text-sm"> |
| 178 | + <thead className="border-b border-neutral-900 bg-neutral-950/80 text-xs uppercase tracking-wider text-neutral-500"> |
| 179 | + <tr> |
| 180 | + <th className="px-4 py-3 font-medium">Name</th> |
| 181 | + <th className="px-4 py-3 font-medium">Prefix</th> |
| 182 | + <th className="px-4 py-3 font-medium">Created</th> |
| 183 | + <th className="px-4 py-3 font-medium">Last used</th> |
| 184 | + <th className="px-4 py-3 font-medium" /> |
| 185 | + </tr> |
| 186 | + </thead> |
| 187 | + <tbody> |
| 188 | + {keys.map((k) => ( |
| 189 | + <tr |
| 190 | + key={k.id} |
| 191 | + className="border-b border-neutral-900 last:border-0 hover:bg-neutral-950 transition" |
| 192 | + > |
| 193 | + <td className="px-4 py-3 text-neutral-100">{k.name}</td> |
| 194 | + <td className="px-4 py-3 font-mono text-xs text-neutral-400"> |
| 195 | + {k.prefix}… |
| 196 | + <CopyButton text={k.prefix} /> |
| 197 | + </td> |
| 198 | + <td className="px-4 py-3 text-neutral-400 text-xs">{fmt(k.created_at)}</td> |
| 199 | + <td className="px-4 py-3 text-neutral-500 text-xs"> |
| 200 | + {k.last_used_at ? fmt(k.last_used_at) : "Never"} |
| 201 | + </td> |
| 202 | + <td className="px-4 py-3 text-right"> |
| 203 | + <button |
| 204 | + onClick={() => deleteKey(k.id)} |
| 205 | + className="inline-flex items-center gap-1 rounded p-1 text-neutral-600 hover:text-red-400 transition" |
| 206 | + title="Revoke" |
| 207 | + > |
| 208 | + <Trash2 size={14} /> |
| 209 | + </button> |
| 210 | + </td> |
| 211 | + </tr> |
| 212 | + ))} |
| 213 | + </tbody> |
| 214 | + </table> |
| 215 | + </div> |
| 216 | + )} |
| 217 | + </div> |
| 218 | + </div> |
| 219 | + ); |
| 220 | +} |
0 commit comments