@syncular/console 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +67 -0
- package/src/App.tsx +44 -0
- package/src/hooks/ConnectionContext.tsx +213 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useConsoleApi.ts +926 -0
- package/src/hooks/useInstanceContext.ts +31 -0
- package/src/hooks/useLiveEvents.ts +267 -0
- package/src/hooks/useLocalStorage.ts +40 -0
- package/src/hooks/usePartitionContext.ts +31 -0
- package/src/hooks/usePreferences.ts +72 -0
- package/src/hooks/useRequestEvents.ts +35 -0
- package/src/hooks/useTimeRange.ts +34 -0
- package/src/index.ts +8 -0
- package/src/layout.tsx +240 -0
- package/src/lib/api.ts +26 -0
- package/src/lib/topology.ts +102 -0
- package/src/lib/types.ts +228 -0
- package/src/mount.tsx +39 -0
- package/src/pages/Command.tsx +382 -0
- package/src/pages/Config.tsx +1190 -0
- package/src/pages/Fleet.tsx +242 -0
- package/src/pages/Ops.tsx +753 -0
- package/src/pages/Stream.tsx +854 -0
- package/src/pages/index.ts +5 -0
- package/src/routeTree.ts +18 -0
- package/src/routes/__root.tsx +6 -0
- package/src/routes/config.tsx +9 -0
- package/src/routes/fleet.tsx +9 -0
- package/src/routes/index.tsx +9 -0
- package/src/routes/investigate-commit.tsx +14 -0
- package/src/routes/investigate-event.tsx +14 -0
- package/src/routes/ops.tsx +9 -0
- package/src/routes/stream.tsx +9 -0
- package/src/sentry.ts +70 -0
- package/src/styles/globals.css +1 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
import type { PreferenceRow } from '@syncular/ui';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
AlertDescription,
|
|
5
|
+
Badge,
|
|
6
|
+
Button,
|
|
7
|
+
Checkbox,
|
|
8
|
+
ConnectionForm,
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
EmptyState,
|
|
15
|
+
Field,
|
|
16
|
+
FieldDescription,
|
|
17
|
+
FieldLabel,
|
|
18
|
+
Input,
|
|
19
|
+
PreferencesPanel,
|
|
20
|
+
SectionCard,
|
|
21
|
+
Spinner,
|
|
22
|
+
Switch,
|
|
23
|
+
Table,
|
|
24
|
+
TableBody,
|
|
25
|
+
TableCell,
|
|
26
|
+
TableHead,
|
|
27
|
+
TableHeader,
|
|
28
|
+
TableRow,
|
|
29
|
+
ToggleGroup,
|
|
30
|
+
ToggleGroupItem,
|
|
31
|
+
} from '@syncular/ui';
|
|
32
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
33
|
+
import {
|
|
34
|
+
PAGE_SIZE_OPTIONS,
|
|
35
|
+
REFRESH_INTERVAL_OPTIONS,
|
|
36
|
+
useApiKeys,
|
|
37
|
+
useBulkRevokeApiKeysMutation,
|
|
38
|
+
useCreateApiKeyMutation,
|
|
39
|
+
useLocalStorage,
|
|
40
|
+
usePreferences,
|
|
41
|
+
useRevokeApiKeyMutation,
|
|
42
|
+
useRotateApiKeyMutation,
|
|
43
|
+
useStageRotateApiKeyMutation,
|
|
44
|
+
} from '../hooks';
|
|
45
|
+
import { useConnection } from '../hooks/ConnectionContext';
|
|
46
|
+
import type {
|
|
47
|
+
ConsoleApiKey,
|
|
48
|
+
ConsoleApiKeyBulkRevokeResponse,
|
|
49
|
+
} from '../lib/types';
|
|
50
|
+
|
|
51
|
+
export function Config() {
|
|
52
|
+
return (
|
|
53
|
+
<div className="space-y-4 px-5 py-5">
|
|
54
|
+
<ConnectionTab />
|
|
55
|
+
<ApiKeysTab />
|
|
56
|
+
<PreferencesTab />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ConnectionTab() {
|
|
62
|
+
const {
|
|
63
|
+
clearError,
|
|
64
|
+
config,
|
|
65
|
+
connect,
|
|
66
|
+
disconnect,
|
|
67
|
+
error,
|
|
68
|
+
isConnected,
|
|
69
|
+
isConnecting,
|
|
70
|
+
} = useConnection();
|
|
71
|
+
|
|
72
|
+
const [serverUrl, setServerUrl] = useState(config?.serverUrl ?? '/api');
|
|
73
|
+
const [token, setToken] = useState(config?.token ?? '');
|
|
74
|
+
const [testLatency, setTestLatency] = useState<number | null>(null);
|
|
75
|
+
const [isTesting, setIsTesting] = useState(false);
|
|
76
|
+
const [connectMessage, setConnectMessage] = useState<string | null>(null);
|
|
77
|
+
const [clearSavedConfigOnDisconnect, setClearSavedConfigOnDisconnect] =
|
|
78
|
+
useLocalStorage<boolean>('console:disconnect-clear-saved-config', false);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const params = new URLSearchParams(window.location.search);
|
|
82
|
+
const urlToken = params.get('token');
|
|
83
|
+
const urlServer = params.get('server');
|
|
84
|
+
|
|
85
|
+
if (urlToken) {
|
|
86
|
+
setToken(urlToken);
|
|
87
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
88
|
+
}
|
|
89
|
+
if (urlServer) {
|
|
90
|
+
setServerUrl(urlServer);
|
|
91
|
+
}
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (config?.serverUrl) {
|
|
96
|
+
setServerUrl(config.serverUrl);
|
|
97
|
+
}
|
|
98
|
+
if (config?.token) {
|
|
99
|
+
setToken(config.token);
|
|
100
|
+
}
|
|
101
|
+
}, [config?.serverUrl, config?.token]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
setConnectMessage(null);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const handleSaveAndConnect = async () => {
|
|
108
|
+
clearError();
|
|
109
|
+
setTestLatency(null);
|
|
110
|
+
|
|
111
|
+
const ok = await connect(
|
|
112
|
+
{
|
|
113
|
+
serverUrl: serverUrl.trim(),
|
|
114
|
+
token: token.trim(),
|
|
115
|
+
},
|
|
116
|
+
{ persistOverride: true }
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
setConnectMessage(
|
|
120
|
+
ok
|
|
121
|
+
? 'Connected successfully and configuration saved.'
|
|
122
|
+
: 'Failed to connect with the provided settings.'
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleDisconnect = () => {
|
|
127
|
+
disconnect({ clearSavedConfig: clearSavedConfigOnDisconnect });
|
|
128
|
+
if (clearSavedConfigOnDisconnect) {
|
|
129
|
+
setServerUrl('/api');
|
|
130
|
+
setToken('');
|
|
131
|
+
}
|
|
132
|
+
setConnectMessage(
|
|
133
|
+
clearSavedConfigOnDisconnect
|
|
134
|
+
? 'Disconnected and saved credentials cleared.'
|
|
135
|
+
: 'Disconnected.'
|
|
136
|
+
);
|
|
137
|
+
setTestLatency(null);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleTestConnection = async () => {
|
|
141
|
+
setIsTesting(true);
|
|
142
|
+
setTestLatency(null);
|
|
143
|
+
setConnectMessage(null);
|
|
144
|
+
const start = performance.now();
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const targetServerUrl = serverUrl.trim();
|
|
148
|
+
const targetToken = token.trim();
|
|
149
|
+
|
|
150
|
+
if (!targetServerUrl || !targetToken) {
|
|
151
|
+
throw new Error('Missing server URL or token');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const response = await fetch(`${targetServerUrl}/console/stats`, {
|
|
155
|
+
headers: { Authorization: `Bearer ${targetToken}` },
|
|
156
|
+
});
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
throw new Error('Failed to connect');
|
|
159
|
+
}
|
|
160
|
+
setTestLatency(Math.round(performance.now() - start));
|
|
161
|
+
} catch {
|
|
162
|
+
setTestLatency(-1);
|
|
163
|
+
} finally {
|
|
164
|
+
setIsTesting(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const statusMessage =
|
|
169
|
+
error ??
|
|
170
|
+
connectMessage ??
|
|
171
|
+
(testLatency !== null
|
|
172
|
+
? testLatency < 0
|
|
173
|
+
? 'Connection failed'
|
|
174
|
+
: `Connection successful (${testLatency}ms latency)`
|
|
175
|
+
: undefined);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<ConnectionForm
|
|
179
|
+
isConnected={isConnected}
|
|
180
|
+
isConnecting={isConnecting}
|
|
181
|
+
isTestingConnection={isTesting}
|
|
182
|
+
serverUrl={serverUrl}
|
|
183
|
+
onServerUrlChange={setServerUrl}
|
|
184
|
+
consoleToken={token}
|
|
185
|
+
onConsoleTokenChange={setToken}
|
|
186
|
+
onSaveAndConnect={handleSaveAndConnect}
|
|
187
|
+
onDisconnect={handleDisconnect}
|
|
188
|
+
onTestConnection={handleTestConnection}
|
|
189
|
+
statusMessage={statusMessage}
|
|
190
|
+
>
|
|
191
|
+
<div className="px-5 pb-5 -mt-2">
|
|
192
|
+
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
|
193
|
+
<div>
|
|
194
|
+
<p className="font-mono text-[10px] text-neutral-400">
|
|
195
|
+
Clear saved credentials on disconnect
|
|
196
|
+
</p>
|
|
197
|
+
<p className="font-mono text-[9px] text-neutral-500">
|
|
198
|
+
Removes stored server URL and token from this browser.
|
|
199
|
+
</p>
|
|
200
|
+
</div>
|
|
201
|
+
<Switch
|
|
202
|
+
checked={clearSavedConfigOnDisconnect}
|
|
203
|
+
onCheckedChange={setClearSavedConfigOnDisconnect}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</ConnectionForm>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type ApiKeyTypeFilter = 'all' | 'relay' | 'proxy' | 'admin';
|
|
212
|
+
type ApiKeyStatusFilter = 'all' | 'active' | 'revoked' | 'expiring';
|
|
213
|
+
type ApiKeyLifecycleStatus = 'active' | 'revoked' | 'expiring' | 'expired';
|
|
214
|
+
|
|
215
|
+
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
216
|
+
|
|
217
|
+
function parsePositiveInteger(value: string): number | null {
|
|
218
|
+
const normalized = value.trim();
|
|
219
|
+
if (!normalized) return null;
|
|
220
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
221
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return null;
|
|
222
|
+
return parsed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatOptionalDateTime(iso: string | null): string {
|
|
226
|
+
if (!iso) return 'Never';
|
|
227
|
+
const timestamp = Date.parse(iso);
|
|
228
|
+
if (!Number.isFinite(timestamp)) return 'Invalid date';
|
|
229
|
+
return new Date(timestamp).toLocaleString();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function summarizeScopeKeys(scopeKeys: string[]): string {
|
|
233
|
+
if (scopeKeys.length === 0) return 'all scopes';
|
|
234
|
+
if (scopeKeys.length <= 2) return scopeKeys.join(', ');
|
|
235
|
+
return `${scopeKeys.slice(0, 2).join(', ')} +${scopeKeys.length - 2}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getApiKeyLifecycleStatus(
|
|
239
|
+
apiKey: ConsoleApiKey,
|
|
240
|
+
expiringWindowDays: number
|
|
241
|
+
): ApiKeyLifecycleStatus {
|
|
242
|
+
if (apiKey.revokedAt) return 'revoked';
|
|
243
|
+
if (!apiKey.expiresAt) return 'active';
|
|
244
|
+
|
|
245
|
+
const expiresAtMs = Date.parse(apiKey.expiresAt);
|
|
246
|
+
if (!Number.isFinite(expiresAtMs)) return 'active';
|
|
247
|
+
|
|
248
|
+
const nowMs = Date.now();
|
|
249
|
+
if (expiresAtMs <= nowMs) return 'expired';
|
|
250
|
+
if (expiresAtMs <= nowMs + expiringWindowDays * MILLISECONDS_PER_DAY) {
|
|
251
|
+
return 'expiring';
|
|
252
|
+
}
|
|
253
|
+
return 'active';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getApiKeyStatusBadgeVariant(status: ApiKeyLifecycleStatus) {
|
|
257
|
+
if (status === 'revoked') return 'destructive';
|
|
258
|
+
if (status === 'expired') return 'offline';
|
|
259
|
+
if (status === 'expiring') return 'syncing';
|
|
260
|
+
return 'healthy';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ApiKeysTab() {
|
|
264
|
+
const [apiKeyTypeFilter, setApiKeyTypeFilter] =
|
|
265
|
+
useState<ApiKeyTypeFilter>('all');
|
|
266
|
+
const [apiKeyStatusFilter, setApiKeyStatusFilter] =
|
|
267
|
+
useState<ApiKeyStatusFilter>('all');
|
|
268
|
+
const [expiringWithinDaysInput, setExpiringWithinDaysInput] = useState('14');
|
|
269
|
+
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
270
|
+
const [showBulkRevokeDialog, setShowBulkRevokeDialog] = useState(false);
|
|
271
|
+
const [newKeyName, setNewKeyName] = useState('');
|
|
272
|
+
const [newKeyType, setNewKeyType] = useState<'relay' | 'proxy' | 'admin'>(
|
|
273
|
+
'relay'
|
|
274
|
+
);
|
|
275
|
+
const [newKeyActorId, setNewKeyActorId] = useState('');
|
|
276
|
+
const [newKeyScopeKeys, setNewKeyScopeKeys] = useState('');
|
|
277
|
+
const [newKeyExpiresInDays, setNewKeyExpiresInDays] = useState('');
|
|
278
|
+
const [createdSecretKey, setCreatedSecretKey] = useState<string | null>(null);
|
|
279
|
+
const [selectedKeyIds, setSelectedKeyIds] = useState<string[]>([]);
|
|
280
|
+
const [bulkRevokeResult, setBulkRevokeResult] =
|
|
281
|
+
useState<ConsoleApiKeyBulkRevokeResponse | null>(null);
|
|
282
|
+
const [copiedKeyId, setCopiedKeyId] = useState<string | null>(null);
|
|
283
|
+
const [revokingKeyId, setRevokingKeyId] = useState<string | null>(null);
|
|
284
|
+
const [stagingRotateKey, setStagingRotateKey] =
|
|
285
|
+
useState<ConsoleApiKey | null>(null);
|
|
286
|
+
const [stagedRotateResult, setStagedRotateResult] = useState<{
|
|
287
|
+
oldKeyId: string;
|
|
288
|
+
oldKeyName: string;
|
|
289
|
+
secretKey: string;
|
|
290
|
+
} | null>(null);
|
|
291
|
+
const [rotatingKeyId, setRotatingKeyId] = useState<string | null>(null);
|
|
292
|
+
const [rotatedSecretKey, setRotatedSecretKey] = useState<string | null>(null);
|
|
293
|
+
|
|
294
|
+
const parsedExpiringWithinDays = useMemo(
|
|
295
|
+
() => parsePositiveInteger(expiringWithinDaysInput),
|
|
296
|
+
[expiringWithinDaysInput]
|
|
297
|
+
);
|
|
298
|
+
const effectiveExpiringWithinDays = parsedExpiringWithinDays ?? 14;
|
|
299
|
+
const parsedNewKeyExpiresInDays = useMemo(
|
|
300
|
+
() => parsePositiveInteger(newKeyExpiresInDays),
|
|
301
|
+
[newKeyExpiresInDays]
|
|
302
|
+
);
|
|
303
|
+
const hasNewKeyExpiry = newKeyExpiresInDays.trim().length > 0;
|
|
304
|
+
const isNewKeyExpiryValid =
|
|
305
|
+
!hasNewKeyExpiry || parsedNewKeyExpiresInDays !== null;
|
|
306
|
+
|
|
307
|
+
const apiKeyQuery = useMemo(
|
|
308
|
+
() => ({
|
|
309
|
+
type: apiKeyTypeFilter === 'all' ? undefined : apiKeyTypeFilter,
|
|
310
|
+
status: apiKeyStatusFilter === 'all' ? undefined : apiKeyStatusFilter,
|
|
311
|
+
expiresWithinDays:
|
|
312
|
+
apiKeyStatusFilter === 'expiring'
|
|
313
|
+
? effectiveExpiringWithinDays
|
|
314
|
+
: undefined,
|
|
315
|
+
}),
|
|
316
|
+
[apiKeyTypeFilter, apiKeyStatusFilter, effectiveExpiringWithinDays]
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const { data, isLoading, error } = useApiKeys(apiKeyQuery);
|
|
320
|
+
const createMutation = useCreateApiKeyMutation();
|
|
321
|
+
const bulkRevokeMutation = useBulkRevokeApiKeysMutation();
|
|
322
|
+
const revokeMutation = useRevokeApiKeyMutation();
|
|
323
|
+
const rotateMutation = useRotateApiKeyMutation();
|
|
324
|
+
const stageRotateMutation = useStageRotateApiKeyMutation();
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
const visibleKeyIds = new Set(
|
|
328
|
+
(data?.items ?? []).map((apiKey) => apiKey.keyId)
|
|
329
|
+
);
|
|
330
|
+
setSelectedKeyIds((current) =>
|
|
331
|
+
current.filter((keyId) => visibleKeyIds.has(keyId))
|
|
332
|
+
);
|
|
333
|
+
}, [data?.items]);
|
|
334
|
+
|
|
335
|
+
const handleCreate = async () => {
|
|
336
|
+
try {
|
|
337
|
+
const parsedScopeKeys = newKeyScopeKeys
|
|
338
|
+
.split(',')
|
|
339
|
+
.map((scope) => scope.trim())
|
|
340
|
+
.filter((scope) => scope.length > 0);
|
|
341
|
+
|
|
342
|
+
const result = await createMutation.mutateAsync({
|
|
343
|
+
name: newKeyName,
|
|
344
|
+
keyType: newKeyType,
|
|
345
|
+
actorId: newKeyActorId || undefined,
|
|
346
|
+
scopeKeys: parsedScopeKeys.length > 0 ? parsedScopeKeys : undefined,
|
|
347
|
+
expiresInDays: hasNewKeyExpiry
|
|
348
|
+
? (parsedNewKeyExpiresInDays ?? undefined)
|
|
349
|
+
: undefined,
|
|
350
|
+
});
|
|
351
|
+
setCreatedSecretKey(result.secretKey);
|
|
352
|
+
setNewKeyName('');
|
|
353
|
+
setNewKeyActorId('');
|
|
354
|
+
setNewKeyScopeKeys('');
|
|
355
|
+
setNewKeyExpiresInDays('');
|
|
356
|
+
} catch {
|
|
357
|
+
// handled by mutation state
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const handleCopyKey = (key: string) => {
|
|
362
|
+
navigator.clipboard.writeText(key);
|
|
363
|
+
setCopiedKeyId(key);
|
|
364
|
+
setTimeout(() => setCopiedKeyId(null), 2000);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const handleRevoke = async () => {
|
|
368
|
+
if (!revokingKeyId) return;
|
|
369
|
+
try {
|
|
370
|
+
await revokeMutation.mutateAsync(revokingKeyId);
|
|
371
|
+
} finally {
|
|
372
|
+
setRevokingKeyId(null);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const handleBulkRevoke = async () => {
|
|
377
|
+
if (selectedKeyIds.length === 0) return;
|
|
378
|
+
try {
|
|
379
|
+
const result = await bulkRevokeMutation.mutateAsync({
|
|
380
|
+
keyIds: selectedKeyIds,
|
|
381
|
+
});
|
|
382
|
+
setBulkRevokeResult(result);
|
|
383
|
+
setSelectedKeyIds([]);
|
|
384
|
+
} catch {
|
|
385
|
+
// handled by mutation state
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const handleStageRotate = async () => {
|
|
390
|
+
if (!stagingRotateKey) return;
|
|
391
|
+
try {
|
|
392
|
+
const result = await stageRotateMutation.mutateAsync(
|
|
393
|
+
stagingRotateKey.keyId
|
|
394
|
+
);
|
|
395
|
+
setStagedRotateResult({
|
|
396
|
+
oldKeyId: stagingRotateKey.keyId,
|
|
397
|
+
oldKeyName: stagingRotateKey.name,
|
|
398
|
+
secretKey: result.secretKey,
|
|
399
|
+
});
|
|
400
|
+
} catch {
|
|
401
|
+
// handled by mutation state
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const handleFinalizeStagedRotate = async () => {
|
|
406
|
+
if (!stagedRotateResult) return;
|
|
407
|
+
try {
|
|
408
|
+
await revokeMutation.mutateAsync(stagedRotateResult.oldKeyId);
|
|
409
|
+
} finally {
|
|
410
|
+
setStagedRotateResult(null);
|
|
411
|
+
setStagingRotateKey(null);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const handleRotate = async () => {
|
|
416
|
+
if (!rotatingKeyId) return;
|
|
417
|
+
try {
|
|
418
|
+
const result = await rotateMutation.mutateAsync(rotatingKeyId);
|
|
419
|
+
setRotatedSecretKey(result.secretKey);
|
|
420
|
+
} finally {
|
|
421
|
+
setRotatingKeyId(null);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
if (isLoading) {
|
|
426
|
+
return (
|
|
427
|
+
<div className="flex h-[200px] items-center justify-center">
|
|
428
|
+
<Spinner size="lg" />
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (error) {
|
|
434
|
+
return (
|
|
435
|
+
<div className="flex h-[200px] items-center justify-center">
|
|
436
|
+
<p className="text-danger">Failed to load API keys: {error.message}</p>
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const selectableKeyIds = (data?.items ?? [])
|
|
442
|
+
.filter((apiKey) => apiKey.revokedAt === null)
|
|
443
|
+
.map((apiKey) => apiKey.keyId);
|
|
444
|
+
const allSelectableChecked =
|
|
445
|
+
selectableKeyIds.length > 0 &&
|
|
446
|
+
selectableKeyIds.every((keyId) => selectedKeyIds.includes(keyId));
|
|
447
|
+
const someSelectableChecked = selectedKeyIds.length > 0;
|
|
448
|
+
const selectedKeyNames = (data?.items ?? [])
|
|
449
|
+
.filter((apiKey) => selectedKeyIds.includes(apiKey.keyId))
|
|
450
|
+
.map((apiKey) => apiKey.name);
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<>
|
|
454
|
+
<SectionCard
|
|
455
|
+
title="API Keys"
|
|
456
|
+
description="Issue, rotate, revoke, and audit key lifecycle state."
|
|
457
|
+
actions={
|
|
458
|
+
<div className="flex items-center gap-2">
|
|
459
|
+
<Button
|
|
460
|
+
size="sm"
|
|
461
|
+
variant="destructive"
|
|
462
|
+
onClick={() => setShowBulkRevokeDialog(true)}
|
|
463
|
+
disabled={selectedKeyIds.length === 0}
|
|
464
|
+
>
|
|
465
|
+
Revoke Selected ({selectedKeyIds.length})
|
|
466
|
+
</Button>
|
|
467
|
+
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
|
|
468
|
+
Create Key
|
|
469
|
+
</Button>
|
|
470
|
+
</div>
|
|
471
|
+
}
|
|
472
|
+
>
|
|
473
|
+
<div className="mb-4 grid gap-3 lg:grid-cols-3">
|
|
474
|
+
<Field>
|
|
475
|
+
<FieldLabel>Type filter</FieldLabel>
|
|
476
|
+
<ToggleGroup
|
|
477
|
+
value={[apiKeyTypeFilter]}
|
|
478
|
+
multiple={false}
|
|
479
|
+
onValueChange={(nextValues) => {
|
|
480
|
+
const nextValue = nextValues.find(
|
|
481
|
+
(value) => typeof value === 'string'
|
|
482
|
+
);
|
|
483
|
+
if (
|
|
484
|
+
nextValue === 'all' ||
|
|
485
|
+
nextValue === 'relay' ||
|
|
486
|
+
nextValue === 'proxy' ||
|
|
487
|
+
nextValue === 'admin'
|
|
488
|
+
) {
|
|
489
|
+
setApiKeyTypeFilter(nextValue);
|
|
490
|
+
}
|
|
491
|
+
}}
|
|
492
|
+
>
|
|
493
|
+
<ToggleGroupItem value="all">all</ToggleGroupItem>
|
|
494
|
+
<ToggleGroupItem value="relay">relay</ToggleGroupItem>
|
|
495
|
+
<ToggleGroupItem value="proxy">proxy</ToggleGroupItem>
|
|
496
|
+
<ToggleGroupItem value="admin">admin</ToggleGroupItem>
|
|
497
|
+
</ToggleGroup>
|
|
498
|
+
</Field>
|
|
499
|
+
|
|
500
|
+
<Field>
|
|
501
|
+
<FieldLabel>Status filter</FieldLabel>
|
|
502
|
+
<ToggleGroup
|
|
503
|
+
value={[apiKeyStatusFilter]}
|
|
504
|
+
multiple={false}
|
|
505
|
+
onValueChange={(nextValues) => {
|
|
506
|
+
const nextValue = nextValues.find(
|
|
507
|
+
(value) => typeof value === 'string'
|
|
508
|
+
);
|
|
509
|
+
if (
|
|
510
|
+
nextValue === 'all' ||
|
|
511
|
+
nextValue === 'active' ||
|
|
512
|
+
nextValue === 'revoked' ||
|
|
513
|
+
nextValue === 'expiring'
|
|
514
|
+
) {
|
|
515
|
+
setApiKeyStatusFilter(nextValue);
|
|
516
|
+
}
|
|
517
|
+
}}
|
|
518
|
+
>
|
|
519
|
+
<ToggleGroupItem value="all">all</ToggleGroupItem>
|
|
520
|
+
<ToggleGroupItem value="active">active</ToggleGroupItem>
|
|
521
|
+
<ToggleGroupItem value="revoked">revoked</ToggleGroupItem>
|
|
522
|
+
<ToggleGroupItem value="expiring">expiring</ToggleGroupItem>
|
|
523
|
+
</ToggleGroup>
|
|
524
|
+
</Field>
|
|
525
|
+
|
|
526
|
+
<Field>
|
|
527
|
+
<FieldLabel htmlFor="api-key-expiring-window">
|
|
528
|
+
Expiring window (days)
|
|
529
|
+
</FieldLabel>
|
|
530
|
+
<FieldDescription>
|
|
531
|
+
Used when status filter is set to expiring.
|
|
532
|
+
</FieldDescription>
|
|
533
|
+
<Input
|
|
534
|
+
id="api-key-expiring-window"
|
|
535
|
+
placeholder="14"
|
|
536
|
+
value={expiringWithinDaysInput}
|
|
537
|
+
inputMode="numeric"
|
|
538
|
+
onChange={(event) =>
|
|
539
|
+
setExpiringWithinDaysInput(event.target.value)
|
|
540
|
+
}
|
|
541
|
+
/>
|
|
542
|
+
{parsedExpiringWithinDays === null &&
|
|
543
|
+
expiringWithinDaysInput.trim().length > 0 ? (
|
|
544
|
+
<p className="font-mono text-[10px] text-offline">
|
|
545
|
+
Enter a positive whole number.
|
|
546
|
+
</p>
|
|
547
|
+
) : null}
|
|
548
|
+
</Field>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
{data?.items.length === 0 ? (
|
|
552
|
+
<EmptyState message="No API keys match the current filters." />
|
|
553
|
+
) : (
|
|
554
|
+
<Table>
|
|
555
|
+
<TableHeader>
|
|
556
|
+
<TableRow>
|
|
557
|
+
<TableHead>
|
|
558
|
+
<Checkbox
|
|
559
|
+
checked={allSelectableChecked}
|
|
560
|
+
indeterminate={
|
|
561
|
+
!allSelectableChecked &&
|
|
562
|
+
someSelectableChecked &&
|
|
563
|
+
selectableKeyIds.length > 0
|
|
564
|
+
}
|
|
565
|
+
onCheckedChange={(checked) => {
|
|
566
|
+
if (checked) {
|
|
567
|
+
setSelectedKeyIds(selectableKeyIds);
|
|
568
|
+
} else {
|
|
569
|
+
setSelectedKeyIds([]);
|
|
570
|
+
}
|
|
571
|
+
}}
|
|
572
|
+
aria-label="Select all active keys"
|
|
573
|
+
/>
|
|
574
|
+
</TableHead>
|
|
575
|
+
<TableHead>NAME</TableHead>
|
|
576
|
+
<TableHead>TYPE</TableHead>
|
|
577
|
+
<TableHead>KEY PREFIX</TableHead>
|
|
578
|
+
<TableHead>ACTOR</TableHead>
|
|
579
|
+
<TableHead>SCOPES</TableHead>
|
|
580
|
+
<TableHead>CREATED</TableHead>
|
|
581
|
+
<TableHead>LAST USED</TableHead>
|
|
582
|
+
<TableHead>EXPIRES</TableHead>
|
|
583
|
+
<TableHead>STATUS</TableHead>
|
|
584
|
+
<TableHead>ACTIONS</TableHead>
|
|
585
|
+
</TableRow>
|
|
586
|
+
</TableHeader>
|
|
587
|
+
<TableBody>
|
|
588
|
+
{(data?.items ?? []).map((apiKey) => {
|
|
589
|
+
const lifecycleStatus = getApiKeyLifecycleStatus(
|
|
590
|
+
apiKey,
|
|
591
|
+
effectiveExpiringWithinDays
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<TableRow key={apiKey.keyId}>
|
|
596
|
+
<TableCell>
|
|
597
|
+
<Checkbox
|
|
598
|
+
checked={selectedKeyIds.includes(apiKey.keyId)}
|
|
599
|
+
onCheckedChange={(checked) => {
|
|
600
|
+
setSelectedKeyIds((current) =>
|
|
601
|
+
checked
|
|
602
|
+
? [...new Set([...current, apiKey.keyId])]
|
|
603
|
+
: current.filter(
|
|
604
|
+
(keyId) => keyId !== apiKey.keyId
|
|
605
|
+
)
|
|
606
|
+
);
|
|
607
|
+
}}
|
|
608
|
+
aria-label={`Select ${apiKey.name}`}
|
|
609
|
+
disabled={apiKey.revokedAt !== null}
|
|
610
|
+
/>
|
|
611
|
+
</TableCell>
|
|
612
|
+
<TableCell className="font-medium">{apiKey.name}</TableCell>
|
|
613
|
+
<TableCell>
|
|
614
|
+
<Badge
|
|
615
|
+
variant={
|
|
616
|
+
apiKey.keyType === 'admin'
|
|
617
|
+
? 'flow'
|
|
618
|
+
: apiKey.keyType === 'proxy'
|
|
619
|
+
? 'ghost'
|
|
620
|
+
: 'relay'
|
|
621
|
+
}
|
|
622
|
+
>
|
|
623
|
+
{apiKey.keyType}
|
|
624
|
+
</Badge>
|
|
625
|
+
</TableCell>
|
|
626
|
+
<TableCell>
|
|
627
|
+
<code className="font-mono text-[11px]">
|
|
628
|
+
{apiKey.keyPrefix}...
|
|
629
|
+
</code>
|
|
630
|
+
</TableCell>
|
|
631
|
+
<TableCell className="text-neutral-500">
|
|
632
|
+
{apiKey.actorId ?? '-'}
|
|
633
|
+
</TableCell>
|
|
634
|
+
<TableCell className="max-w-[220px] text-neutral-500">
|
|
635
|
+
<code className="font-mono text-[10px]">
|
|
636
|
+
{summarizeScopeKeys(apiKey.scopeKeys)}
|
|
637
|
+
</code>
|
|
638
|
+
</TableCell>
|
|
639
|
+
<TableCell className="text-neutral-500">
|
|
640
|
+
{formatOptionalDateTime(apiKey.createdAt)}
|
|
641
|
+
</TableCell>
|
|
642
|
+
<TableCell className="text-neutral-500">
|
|
643
|
+
{formatOptionalDateTime(apiKey.lastUsedAt)}
|
|
644
|
+
</TableCell>
|
|
645
|
+
<TableCell className="text-neutral-500">
|
|
646
|
+
{formatOptionalDateTime(apiKey.expiresAt)}
|
|
647
|
+
</TableCell>
|
|
648
|
+
<TableCell>
|
|
649
|
+
<Badge
|
|
650
|
+
variant={getApiKeyStatusBadgeVariant(lifecycleStatus)}
|
|
651
|
+
>
|
|
652
|
+
{lifecycleStatus}
|
|
653
|
+
</Badge>
|
|
654
|
+
</TableCell>
|
|
655
|
+
<TableCell>
|
|
656
|
+
<div className="flex items-center gap-1">
|
|
657
|
+
<Button
|
|
658
|
+
variant="default"
|
|
659
|
+
size="sm"
|
|
660
|
+
onClick={() => setStagingRotateKey(apiKey)}
|
|
661
|
+
disabled={apiKey.revokedAt !== null}
|
|
662
|
+
>
|
|
663
|
+
Stage
|
|
664
|
+
</Button>
|
|
665
|
+
<Button
|
|
666
|
+
variant="default"
|
|
667
|
+
size="sm"
|
|
668
|
+
onClick={() => setRotatingKeyId(apiKey.keyId)}
|
|
669
|
+
disabled={apiKey.revokedAt !== null}
|
|
670
|
+
>
|
|
671
|
+
Rotate
|
|
672
|
+
</Button>
|
|
673
|
+
<Button
|
|
674
|
+
variant="destructive"
|
|
675
|
+
size="sm"
|
|
676
|
+
onClick={() => setRevokingKeyId(apiKey.keyId)}
|
|
677
|
+
disabled={apiKey.revokedAt !== null}
|
|
678
|
+
>
|
|
679
|
+
Revoke
|
|
680
|
+
</Button>
|
|
681
|
+
</div>
|
|
682
|
+
</TableCell>
|
|
683
|
+
</TableRow>
|
|
684
|
+
);
|
|
685
|
+
})}
|
|
686
|
+
</TableBody>
|
|
687
|
+
</Table>
|
|
688
|
+
)}
|
|
689
|
+
</SectionCard>
|
|
690
|
+
|
|
691
|
+
{/* Create API Key Dialog */}
|
|
692
|
+
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
|
693
|
+
<DialogContent>
|
|
694
|
+
<DialogHeader>
|
|
695
|
+
<DialogTitle>Create API Key</DialogTitle>
|
|
696
|
+
</DialogHeader>
|
|
697
|
+
|
|
698
|
+
{createdSecretKey ? (
|
|
699
|
+
<SecretKeyReveal
|
|
700
|
+
copiedKeyId={copiedKeyId}
|
|
701
|
+
onClose={() => {
|
|
702
|
+
setCreatedSecretKey(null);
|
|
703
|
+
setShowCreateDialog(false);
|
|
704
|
+
}}
|
|
705
|
+
onCopy={handleCopyKey}
|
|
706
|
+
secretKey={createdSecretKey}
|
|
707
|
+
warning="Copy this key now. You will not be able to view it again."
|
|
708
|
+
/>
|
|
709
|
+
) : (
|
|
710
|
+
<div className="px-5 py-4 flex flex-col gap-4">
|
|
711
|
+
<Field>
|
|
712
|
+
<FieldLabel htmlFor="api-key-name">Name</FieldLabel>
|
|
713
|
+
<Input
|
|
714
|
+
id="api-key-name"
|
|
715
|
+
placeholder="Backend Relay Key"
|
|
716
|
+
value={newKeyName}
|
|
717
|
+
onChange={(event) => setNewKeyName(event.target.value)}
|
|
718
|
+
/>
|
|
719
|
+
</Field>
|
|
720
|
+
|
|
721
|
+
<Field>
|
|
722
|
+
<FieldLabel>Key type</FieldLabel>
|
|
723
|
+
<ToggleGroup
|
|
724
|
+
value={[newKeyType]}
|
|
725
|
+
multiple={false}
|
|
726
|
+
onValueChange={(nextValues) => {
|
|
727
|
+
const nextValue = nextValues.find(
|
|
728
|
+
(value) => typeof value === 'string'
|
|
729
|
+
);
|
|
730
|
+
if (
|
|
731
|
+
nextValue === 'relay' ||
|
|
732
|
+
nextValue === 'proxy' ||
|
|
733
|
+
nextValue === 'admin'
|
|
734
|
+
) {
|
|
735
|
+
setNewKeyType(nextValue);
|
|
736
|
+
}
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
<ToggleGroupItem value="relay">relay</ToggleGroupItem>
|
|
740
|
+
<ToggleGroupItem value="proxy">proxy</ToggleGroupItem>
|
|
741
|
+
<ToggleGroupItem value="admin">admin</ToggleGroupItem>
|
|
742
|
+
</ToggleGroup>
|
|
743
|
+
</Field>
|
|
744
|
+
|
|
745
|
+
<Field>
|
|
746
|
+
<FieldLabel htmlFor="api-key-actor-id">
|
|
747
|
+
Actor ID (optional)
|
|
748
|
+
</FieldLabel>
|
|
749
|
+
<FieldDescription>
|
|
750
|
+
Pin this key to a fixed actor ID
|
|
751
|
+
</FieldDescription>
|
|
752
|
+
<Input
|
|
753
|
+
id="api-key-actor-id"
|
|
754
|
+
placeholder="actor-123"
|
|
755
|
+
value={newKeyActorId}
|
|
756
|
+
onChange={(event) => setNewKeyActorId(event.target.value)}
|
|
757
|
+
/>
|
|
758
|
+
</Field>
|
|
759
|
+
|
|
760
|
+
<Field>
|
|
761
|
+
<FieldLabel htmlFor="api-key-scope-keys">
|
|
762
|
+
Scope keys (optional)
|
|
763
|
+
</FieldLabel>
|
|
764
|
+
<FieldDescription>
|
|
765
|
+
Comma-separated list of allowed scope keys
|
|
766
|
+
</FieldDescription>
|
|
767
|
+
<Input
|
|
768
|
+
id="api-key-scope-keys"
|
|
769
|
+
placeholder="scope-a, scope-b"
|
|
770
|
+
value={newKeyScopeKeys}
|
|
771
|
+
onChange={(event) => setNewKeyScopeKeys(event.target.value)}
|
|
772
|
+
/>
|
|
773
|
+
</Field>
|
|
774
|
+
|
|
775
|
+
<Field>
|
|
776
|
+
<FieldLabel htmlFor="api-key-expires-days">
|
|
777
|
+
Expires in days (optional)
|
|
778
|
+
</FieldLabel>
|
|
779
|
+
<FieldDescription>
|
|
780
|
+
Leave empty to keep the key non-expiring.
|
|
781
|
+
</FieldDescription>
|
|
782
|
+
<Input
|
|
783
|
+
id="api-key-expires-days"
|
|
784
|
+
placeholder="30"
|
|
785
|
+
value={newKeyExpiresInDays}
|
|
786
|
+
inputMode="numeric"
|
|
787
|
+
onChange={(event) =>
|
|
788
|
+
setNewKeyExpiresInDays(event.target.value)
|
|
789
|
+
}
|
|
790
|
+
/>
|
|
791
|
+
{!isNewKeyExpiryValid ? (
|
|
792
|
+
<p className="font-mono text-[10px] text-offline">
|
|
793
|
+
Enter a positive whole number.
|
|
794
|
+
</p>
|
|
795
|
+
) : null}
|
|
796
|
+
</Field>
|
|
797
|
+
|
|
798
|
+
<DialogFooter>
|
|
799
|
+
<Button
|
|
800
|
+
variant="default"
|
|
801
|
+
onClick={() => setShowCreateDialog(false)}
|
|
802
|
+
>
|
|
803
|
+
Cancel
|
|
804
|
+
</Button>
|
|
805
|
+
<Button
|
|
806
|
+
variant="primary"
|
|
807
|
+
onClick={handleCreate}
|
|
808
|
+
disabled={
|
|
809
|
+
createMutation.isPending ||
|
|
810
|
+
!newKeyName ||
|
|
811
|
+
!isNewKeyExpiryValid
|
|
812
|
+
}
|
|
813
|
+
>
|
|
814
|
+
{createMutation.isPending ? (
|
|
815
|
+
<>
|
|
816
|
+
<Spinner size="sm" />
|
|
817
|
+
Creating...
|
|
818
|
+
</>
|
|
819
|
+
) : (
|
|
820
|
+
'Create'
|
|
821
|
+
)}
|
|
822
|
+
</Button>
|
|
823
|
+
</DialogFooter>
|
|
824
|
+
</div>
|
|
825
|
+
)}
|
|
826
|
+
</DialogContent>
|
|
827
|
+
</Dialog>
|
|
828
|
+
|
|
829
|
+
{/* Bulk Revoke Dialog */}
|
|
830
|
+
<Dialog
|
|
831
|
+
open={showBulkRevokeDialog}
|
|
832
|
+
onOpenChange={(open) => {
|
|
833
|
+
setShowBulkRevokeDialog(open);
|
|
834
|
+
if (!open) {
|
|
835
|
+
setBulkRevokeResult(null);
|
|
836
|
+
}
|
|
837
|
+
}}
|
|
838
|
+
>
|
|
839
|
+
<DialogContent>
|
|
840
|
+
<DialogHeader>
|
|
841
|
+
<DialogTitle>Bulk Revoke API Keys</DialogTitle>
|
|
842
|
+
</DialogHeader>
|
|
843
|
+
|
|
844
|
+
{bulkRevokeResult ? (
|
|
845
|
+
<div className="px-5 py-4 flex flex-col gap-3">
|
|
846
|
+
<Alert variant="destructive">
|
|
847
|
+
<AlertDescription>
|
|
848
|
+
Requested {bulkRevokeResult.requestedCount} keys. Revoked{' '}
|
|
849
|
+
{bulkRevokeResult.revokedCount}, already revoked{' '}
|
|
850
|
+
{bulkRevokeResult.alreadyRevokedCount}, not found{' '}
|
|
851
|
+
{bulkRevokeResult.notFoundCount}.
|
|
852
|
+
</AlertDescription>
|
|
853
|
+
</Alert>
|
|
854
|
+
<DialogFooter>
|
|
855
|
+
<Button
|
|
856
|
+
variant="primary"
|
|
857
|
+
onClick={() => {
|
|
858
|
+
setShowBulkRevokeDialog(false);
|
|
859
|
+
setBulkRevokeResult(null);
|
|
860
|
+
}}
|
|
861
|
+
>
|
|
862
|
+
Done
|
|
863
|
+
</Button>
|
|
864
|
+
</DialogFooter>
|
|
865
|
+
</div>
|
|
866
|
+
) : (
|
|
867
|
+
<>
|
|
868
|
+
<div className="px-5 py-4 flex flex-col gap-3">
|
|
869
|
+
<Alert variant="destructive">
|
|
870
|
+
<AlertDescription>
|
|
871
|
+
This revokes selected keys immediately and cannot be undone.
|
|
872
|
+
</AlertDescription>
|
|
873
|
+
</Alert>
|
|
874
|
+
<p className="font-mono text-[10px] text-neutral-500">
|
|
875
|
+
Selected keys: {selectedKeyNames.slice(0, 5).join(', ')}
|
|
876
|
+
{selectedKeyNames.length > 5
|
|
877
|
+
? ` +${selectedKeyNames.length - 5} more`
|
|
878
|
+
: ''}
|
|
879
|
+
</p>
|
|
880
|
+
</div>
|
|
881
|
+
<DialogFooter>
|
|
882
|
+
<Button
|
|
883
|
+
variant="default"
|
|
884
|
+
onClick={() => setShowBulkRevokeDialog(false)}
|
|
885
|
+
>
|
|
886
|
+
Cancel
|
|
887
|
+
</Button>
|
|
888
|
+
<Button
|
|
889
|
+
variant="destructive"
|
|
890
|
+
onClick={handleBulkRevoke}
|
|
891
|
+
disabled={
|
|
892
|
+
bulkRevokeMutation.isPending || selectedKeyIds.length === 0
|
|
893
|
+
}
|
|
894
|
+
>
|
|
895
|
+
{bulkRevokeMutation.isPending ? (
|
|
896
|
+
<>
|
|
897
|
+
<Spinner size="sm" />
|
|
898
|
+
Revoking...
|
|
899
|
+
</>
|
|
900
|
+
) : (
|
|
901
|
+
'Revoke Selected'
|
|
902
|
+
)}
|
|
903
|
+
</Button>
|
|
904
|
+
</DialogFooter>
|
|
905
|
+
</>
|
|
906
|
+
)}
|
|
907
|
+
</DialogContent>
|
|
908
|
+
</Dialog>
|
|
909
|
+
|
|
910
|
+
{/* Stage Rotate Dialog */}
|
|
911
|
+
<Dialog
|
|
912
|
+
open={stagingRotateKey !== null || stagedRotateResult !== null}
|
|
913
|
+
onOpenChange={() => {
|
|
914
|
+
setStagingRotateKey(null);
|
|
915
|
+
setStagedRotateResult(null);
|
|
916
|
+
}}
|
|
917
|
+
>
|
|
918
|
+
<DialogContent>
|
|
919
|
+
<DialogHeader>
|
|
920
|
+
<DialogTitle>Stage Rotate API Key</DialogTitle>
|
|
921
|
+
</DialogHeader>
|
|
922
|
+
|
|
923
|
+
{stagedRotateResult ? (
|
|
924
|
+
<div className="px-5 py-4 flex flex-col gap-4">
|
|
925
|
+
<Alert variant="default">
|
|
926
|
+
<AlertDescription>
|
|
927
|
+
Replacement key created for {stagedRotateResult.oldKeyName}.
|
|
928
|
+
The old key is still active until you revoke it.
|
|
929
|
+
</AlertDescription>
|
|
930
|
+
</Alert>
|
|
931
|
+
|
|
932
|
+
<div className="flex items-center gap-2">
|
|
933
|
+
<code className="flex-1 rounded-md border border-border bg-surface p-3 font-mono text-[11px] text-white break-all">
|
|
934
|
+
{stagedRotateResult.secretKey}
|
|
935
|
+
</code>
|
|
936
|
+
<Button
|
|
937
|
+
variant="default"
|
|
938
|
+
size="sm"
|
|
939
|
+
onClick={() => handleCopyKey(stagedRotateResult.secretKey)}
|
|
940
|
+
>
|
|
941
|
+
{copiedKeyId === stagedRotateResult.secretKey
|
|
942
|
+
? 'Copied'
|
|
943
|
+
: 'Copy'}
|
|
944
|
+
</Button>
|
|
945
|
+
</div>
|
|
946
|
+
|
|
947
|
+
<DialogFooter>
|
|
948
|
+
<Button
|
|
949
|
+
variant="default"
|
|
950
|
+
onClick={() => {
|
|
951
|
+
setStagedRotateResult(null);
|
|
952
|
+
setStagingRotateKey(null);
|
|
953
|
+
}}
|
|
954
|
+
>
|
|
955
|
+
Keep Old Key Active
|
|
956
|
+
</Button>
|
|
957
|
+
<Button
|
|
958
|
+
variant="destructive"
|
|
959
|
+
onClick={handleFinalizeStagedRotate}
|
|
960
|
+
disabled={revokeMutation.isPending}
|
|
961
|
+
>
|
|
962
|
+
{revokeMutation.isPending ? (
|
|
963
|
+
<>
|
|
964
|
+
<Spinner size="sm" />
|
|
965
|
+
Revoking Old Key...
|
|
966
|
+
</>
|
|
967
|
+
) : (
|
|
968
|
+
'Finalize and Revoke Old Key'
|
|
969
|
+
)}
|
|
970
|
+
</Button>
|
|
971
|
+
</DialogFooter>
|
|
972
|
+
</div>
|
|
973
|
+
) : (
|
|
974
|
+
<>
|
|
975
|
+
<div className="px-5 py-4">
|
|
976
|
+
<p className="font-mono text-[10px] text-neutral-500">
|
|
977
|
+
Staged rotation creates a replacement key now and keeps the
|
|
978
|
+
current key active until you explicitly revoke it.
|
|
979
|
+
</p>
|
|
980
|
+
</div>
|
|
981
|
+
<DialogFooter>
|
|
982
|
+
<Button
|
|
983
|
+
variant="default"
|
|
984
|
+
onClick={() => setStagingRotateKey(null)}
|
|
985
|
+
>
|
|
986
|
+
Cancel
|
|
987
|
+
</Button>
|
|
988
|
+
<Button
|
|
989
|
+
variant="primary"
|
|
990
|
+
onClick={handleStageRotate}
|
|
991
|
+
disabled={stageRotateMutation.isPending || !stagingRotateKey}
|
|
992
|
+
>
|
|
993
|
+
{stageRotateMutation.isPending ? (
|
|
994
|
+
<>
|
|
995
|
+
<Spinner size="sm" />
|
|
996
|
+
Staging...
|
|
997
|
+
</>
|
|
998
|
+
) : (
|
|
999
|
+
'Create Replacement Key'
|
|
1000
|
+
)}
|
|
1001
|
+
</Button>
|
|
1002
|
+
</DialogFooter>
|
|
1003
|
+
</>
|
|
1004
|
+
)}
|
|
1005
|
+
</DialogContent>
|
|
1006
|
+
</Dialog>
|
|
1007
|
+
|
|
1008
|
+
{/* Revoke Confirmation Dialog */}
|
|
1009
|
+
<Dialog
|
|
1010
|
+
open={revokingKeyId !== null}
|
|
1011
|
+
onOpenChange={() => setRevokingKeyId(null)}
|
|
1012
|
+
>
|
|
1013
|
+
<DialogContent>
|
|
1014
|
+
<DialogHeader>
|
|
1015
|
+
<DialogTitle>Revoke API Key</DialogTitle>
|
|
1016
|
+
</DialogHeader>
|
|
1017
|
+
<div className="px-5 py-4">
|
|
1018
|
+
<Alert variant="destructive">
|
|
1019
|
+
<AlertDescription>
|
|
1020
|
+
Revoking a key immediately invalidates it for all requests.
|
|
1021
|
+
</AlertDescription>
|
|
1022
|
+
</Alert>
|
|
1023
|
+
</div>
|
|
1024
|
+
<DialogFooter>
|
|
1025
|
+
<Button variant="default" onClick={() => setRevokingKeyId(null)}>
|
|
1026
|
+
Cancel
|
|
1027
|
+
</Button>
|
|
1028
|
+
<Button
|
|
1029
|
+
variant="destructive"
|
|
1030
|
+
onClick={handleRevoke}
|
|
1031
|
+
disabled={revokeMutation.isPending}
|
|
1032
|
+
>
|
|
1033
|
+
{revokeMutation.isPending ? (
|
|
1034
|
+
<>
|
|
1035
|
+
<Spinner size="sm" />
|
|
1036
|
+
Revoking...
|
|
1037
|
+
</>
|
|
1038
|
+
) : (
|
|
1039
|
+
'Revoke'
|
|
1040
|
+
)}
|
|
1041
|
+
</Button>
|
|
1042
|
+
</DialogFooter>
|
|
1043
|
+
</DialogContent>
|
|
1044
|
+
</Dialog>
|
|
1045
|
+
|
|
1046
|
+
{/* Rotate Confirmation / Result Dialog */}
|
|
1047
|
+
<Dialog
|
|
1048
|
+
open={rotatingKeyId !== null || rotatedSecretKey !== null}
|
|
1049
|
+
onOpenChange={() => {
|
|
1050
|
+
setRotatingKeyId(null);
|
|
1051
|
+
setRotatedSecretKey(null);
|
|
1052
|
+
}}
|
|
1053
|
+
>
|
|
1054
|
+
<DialogContent>
|
|
1055
|
+
<DialogHeader>
|
|
1056
|
+
<DialogTitle>Rotate API Key</DialogTitle>
|
|
1057
|
+
</DialogHeader>
|
|
1058
|
+
|
|
1059
|
+
{rotatedSecretKey ? (
|
|
1060
|
+
<SecretKeyReveal
|
|
1061
|
+
copiedKeyId={copiedKeyId}
|
|
1062
|
+
onClose={() => setRotatedSecretKey(null)}
|
|
1063
|
+
onCopy={handleCopyKey}
|
|
1064
|
+
secretKey={rotatedSecretKey}
|
|
1065
|
+
warning="The previous key has been invalidated. Store this replacement securely."
|
|
1066
|
+
/>
|
|
1067
|
+
) : (
|
|
1068
|
+
<>
|
|
1069
|
+
<div className="px-5 py-4">
|
|
1070
|
+
<p className="font-mono text-[10px] text-neutral-500">
|
|
1071
|
+
Rotating a key invalidates the previous secret immediately.
|
|
1072
|
+
</p>
|
|
1073
|
+
</div>
|
|
1074
|
+
<DialogFooter>
|
|
1075
|
+
<Button
|
|
1076
|
+
variant="default"
|
|
1077
|
+
onClick={() => setRotatingKeyId(null)}
|
|
1078
|
+
>
|
|
1079
|
+
Cancel
|
|
1080
|
+
</Button>
|
|
1081
|
+
<Button
|
|
1082
|
+
variant="primary"
|
|
1083
|
+
onClick={handleRotate}
|
|
1084
|
+
disabled={rotateMutation.isPending}
|
|
1085
|
+
>
|
|
1086
|
+
{rotateMutation.isPending ? (
|
|
1087
|
+
<>
|
|
1088
|
+
<Spinner size="sm" />
|
|
1089
|
+
Rotating...
|
|
1090
|
+
</>
|
|
1091
|
+
) : (
|
|
1092
|
+
'Rotate'
|
|
1093
|
+
)}
|
|
1094
|
+
</Button>
|
|
1095
|
+
</DialogFooter>
|
|
1096
|
+
</>
|
|
1097
|
+
)}
|
|
1098
|
+
</DialogContent>
|
|
1099
|
+
</Dialog>
|
|
1100
|
+
</>
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function PreferencesTab() {
|
|
1105
|
+
const { preferences, updatePreference, resetPreferences } = usePreferences();
|
|
1106
|
+
|
|
1107
|
+
const rows: PreferenceRow[] = [
|
|
1108
|
+
{
|
|
1109
|
+
type: 'filter',
|
|
1110
|
+
label: 'Auto-refresh interval',
|
|
1111
|
+
options: REFRESH_INTERVAL_OPTIONS.map((o) => ({
|
|
1112
|
+
id: `${o.value}`,
|
|
1113
|
+
label: o.label,
|
|
1114
|
+
})),
|
|
1115
|
+
activeId: `${preferences.refreshInterval}`,
|
|
1116
|
+
onActiveChange: (id) =>
|
|
1117
|
+
updatePreference('refreshInterval', Number.parseInt(id, 10)),
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
type: 'filter',
|
|
1121
|
+
label: 'Items per page',
|
|
1122
|
+
options: PAGE_SIZE_OPTIONS.map((o) => ({
|
|
1123
|
+
id: `${o.value}`,
|
|
1124
|
+
label: o.label,
|
|
1125
|
+
})),
|
|
1126
|
+
activeId: `${preferences.pageSize}`,
|
|
1127
|
+
onActiveChange: (id) =>
|
|
1128
|
+
updatePreference('pageSize', Number.parseInt(id, 10)),
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
type: 'filter',
|
|
1132
|
+
label: 'Time format',
|
|
1133
|
+
options: [
|
|
1134
|
+
{ id: 'relative', label: 'Relative' },
|
|
1135
|
+
{ id: 'absolute', label: 'Absolute' },
|
|
1136
|
+
],
|
|
1137
|
+
activeId: preferences.timeFormat,
|
|
1138
|
+
onActiveChange: (id) =>
|
|
1139
|
+
updatePreference('timeFormat', id as 'relative' | 'absolute'),
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
type: 'toggle',
|
|
1143
|
+
label: 'Show sparklines',
|
|
1144
|
+
description: 'Display mini trend charts in dashboard metric cards',
|
|
1145
|
+
checked: preferences.showSparklines,
|
|
1146
|
+
onCheckedChange: (checked) => updatePreference('showSparklines', checked),
|
|
1147
|
+
},
|
|
1148
|
+
];
|
|
1149
|
+
|
|
1150
|
+
return <PreferencesPanel rows={rows} onResetDefaults={resetPreferences} />;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
interface SecretKeyRevealProps {
|
|
1154
|
+
copiedKeyId: string | null;
|
|
1155
|
+
onClose: () => void;
|
|
1156
|
+
onCopy: (key: string) => void;
|
|
1157
|
+
secretKey: string;
|
|
1158
|
+
warning: string;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function SecretKeyReveal({
|
|
1162
|
+
copiedKeyId,
|
|
1163
|
+
onClose,
|
|
1164
|
+
onCopy,
|
|
1165
|
+
secretKey,
|
|
1166
|
+
warning,
|
|
1167
|
+
}: SecretKeyRevealProps) {
|
|
1168
|
+
return (
|
|
1169
|
+
<div className="px-5 py-4 flex flex-col gap-4">
|
|
1170
|
+
<Alert variant="destructive">
|
|
1171
|
+
<AlertDescription>{warning}</AlertDescription>
|
|
1172
|
+
</Alert>
|
|
1173
|
+
|
|
1174
|
+
<div className="flex items-center gap-2">
|
|
1175
|
+
<code className="flex-1 rounded-md border border-border bg-surface p-3 font-mono text-[11px] text-white break-all">
|
|
1176
|
+
{secretKey}
|
|
1177
|
+
</code>
|
|
1178
|
+
<Button variant="default" size="sm" onClick={() => onCopy(secretKey)}>
|
|
1179
|
+
{copiedKeyId === secretKey ? 'Copied' : 'Copy'}
|
|
1180
|
+
</Button>
|
|
1181
|
+
</div>
|
|
1182
|
+
|
|
1183
|
+
<DialogFooter>
|
|
1184
|
+
<Button variant="primary" onClick={onClose}>
|
|
1185
|
+
Done
|
|
1186
|
+
</Button>
|
|
1187
|
+
</DialogFooter>
|
|
1188
|
+
</div>
|
|
1189
|
+
);
|
|
1190
|
+
}
|