key-row.tsx163 lines · main
1'use client';
2
3import { useState, useTransition } from 'react';
4
5import { RevokeButton } from './revoke-button';
6
7interface ApiKey {
8 id: string;
9 name: string;
10 suffix: string;
11 createdAt: string;
12 lastUsedAt: string | null;
13 expiresAt: string | null;
14 revokedAt: string | null;
15}
16
17interface Props {
18 apiKey: ApiKey;
19 onRevoke: (keyId: string) => Promise<void>;
20 onRename: (keyId: string, name: string) => Promise<void>;
21}
22
23/**
24 * A single active API-key row with inline rename. The secret itself is
25 * never re-displayable (hashed at rest), so the only editable field is
26 * the human-readable name.
27 */
28export function KeyRow({ apiKey, onRevoke, onRename }: Props) {
29 const [editing, setEditing] = useState(false);
30 const [name, setName] = useState(apiKey.name);
31 const [error, setError] = useState<string | null>(null);
32 const [pending, startTransition] = useTransition();
33
34 function save() {
35 const trimmed = name.trim();
36 if (!trimmed || trimmed === apiKey.name) {
37 setEditing(false);
38 setName(apiKey.name);
39 return;
40 }
41 setError(null);
42 startTransition(async () => {
43 try {
44 await onRename(apiKey.id, trimmed);
45 setEditing(false);
46 } catch (err) {
47 setError(err instanceof Error ? err.message : 'rename failed');
48 }
49 });
50 }
51
52 function cancel() {
53 setEditing(false);
54 setName(apiKey.name);
55 setError(null);
56 }
57
58 // Compute expiry state for the inline pill. Soft warning at <=7d,
59 // hard warning when already past the expires_at timestamp (the api
60 // already rejects requests with such keys; the dashboard shows them
61 // explicitly so the operator notices + revokes/replaces).
62 const expiryState = expiryStateFor(apiKey.expiresAt);
63
64 return (
65 <li className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3">
66 <div className="min-w-0 flex-1">
67 {editing ? (
68 <div className="flex items-center gap-2">
69 <input
70 autoFocus
71 value={name}
72 onChange={(e) => setName(e.currentTarget.value)}
73 onKeyDown={(e) => {
74 if (e.key === 'Enter') save();
75 if (e.key === 'Escape') cancel();
76 }}
77 maxLength={80}
78 disabled={pending}
79 className="max-w-xs rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2 py-1 font-mono text-sm outline-none focus:border-[var(--color-primary)] disabled:opacity-50"
80 />
81 <button
82 type="button"
83 disabled={pending}
84 onClick={save}
85 className="rounded-md bg-[var(--color-primary)] px-2 py-1 font-mono text-xs font-medium text-[var(--color-text-inverse)] disabled:opacity-50"
86 >
87 {pending ? 'saving...' : 'save'}
88 </button>
89 <button
90 type="button"
91 disabled={pending}
92 onClick={cancel}
93 className="font-mono text-xs text-[var(--color-text-muted)]"
94 >
95 cancel
96 </button>
97 </div>
98 ) : (
99 <div className="flex flex-wrap items-baseline gap-2">
100 <button
101 type="button"
102 onClick={() => setEditing(true)}
103 title="click to rename"
104 className="text-left font-mono text-sm hover:text-[var(--color-primary)]"
105 >
106 {apiKey.name}
107 </button>
108 {expiryState ? <ExpiryPill state={expiryState} /> : null}
109 </div>
110 )}
111 <p className="mt-0.5 font-mono text-xs text-[var(--color-text-subtle)]">
112 brk_•••{apiKey.suffix} · created {new Date(apiKey.createdAt).toISOString().slice(0, 10)}
113 {apiKey.lastUsedAt
114 ? ` · last used ${new Date(apiKey.lastUsedAt).toISOString().slice(0, 10)}`
115 : ' · never used'}
116 {apiKey.expiresAt
117 ? ` · expires ${new Date(apiKey.expiresAt).toISOString().slice(0, 10)}`
118 : null}
119 </p>
120 {error ? (
121 <p role="alert" className="mt-1 font-mono text-xs text-red-400">
122 {error}
123 </p>
124 ) : null}
125 </div>
126 <RevokeButton keyId={apiKey.id} onRevoke={onRevoke} />
127 </li>
128 );
129}
130
131type ExpiryState =
132 | { kind: 'expired'; daysAgo: number }
133 | { kind: 'expiring'; daysLeft: number };
134
135function expiryStateFor(expiresAt: string | null): ExpiryState | null {
136 if (!expiresAt) return null;
137 const ms = new Date(expiresAt).getTime() - Date.now();
138 const days = Math.round(ms / 86_400_000);
139 if (days < 0) return { kind: 'expired', daysAgo: -days };
140 if (days <= 7) return { kind: 'expiring', daysLeft: days };
141 return null;
142}
143
144function ExpiryPill({ state }: { state: ExpiryState }) {
145 if (state.kind === 'expired') {
146 return (
147 <span
148 title="this key is past its expires_at and is rejected by the api — revoke and replace"
149 className="rounded-full border border-[var(--color-error)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-error)]"
150 >
151 expired · {state.daysAgo}d ago
152 </span>
153 );
154 }
155 return (
156 <span
157 title="this key expires within 7 days — rotate before it does to avoid an interruption"
158 className="rounded-full border border-[var(--color-warning)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-warning)]"
159 >
160 expires in {state.daysLeft}d
161 </span>
162 );
163}