@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.
@@ -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
+ }