@rebasepro/studio 0.17.2 → 0.17.3-canary.gdd23447
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/dist/ApiKeysView-DiCurTEU.js +1224 -0
- package/dist/ApiKeysView-DiCurTEU.js.map +1 -0
- package/dist/components/ApiKeys/CreateApiKeyDialog.d.ts +6 -0
- package/dist/components/ApiKeys/permissions.d.ts +66 -0
- package/dist/index.es.js +1 -1
- package/package.json +9 -9
- package/src/components/ApiKeys/ApiKeysView.tsx +69 -211
- package/src/components/ApiKeys/CreateApiKeyDialog.tsx +711 -0
- package/src/components/ApiKeys/permissions.ts +145 -0
- package/dist/ApiKeysView-C_UoUPuR.js +0 -728
- package/dist/ApiKeysView-C_UoUPuR.js.map +0 -1
|
@@ -15,12 +15,9 @@ import {
|
|
|
15
15
|
iconSize,
|
|
16
16
|
KeyRoundIcon,
|
|
17
17
|
RefreshCwIcon,
|
|
18
|
+
ShieldIcon,
|
|
18
19
|
Tooltip,
|
|
19
20
|
Typography,
|
|
20
|
-
TextField,
|
|
21
|
-
Select,
|
|
22
|
-
SelectItem,
|
|
23
|
-
Checkbox,
|
|
24
21
|
CopyIcon,
|
|
25
22
|
PlusIcon as AddIcon,
|
|
26
23
|
Trash2Icon as DeleteIcon,
|
|
@@ -28,37 +25,17 @@ import {
|
|
|
28
25
|
CheckCircleIcon
|
|
29
26
|
} from "@rebasepro/ui";
|
|
30
27
|
import { useRebaseClient, useSnackbarController } from "@rebasepro/app";
|
|
31
|
-
import type { RebaseClient } from "@rebasepro/types";
|
|
28
|
+
import type { ApiKeyMasked, ApiKeyWithSecret, RebaseClient } from "@rebasepro/types";
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
═══════════════════════════════════════════════════════════════ */
|
|
36
|
-
|
|
37
|
-
interface ApiKeyPermission {
|
|
38
|
-
collection: string;
|
|
39
|
-
operations: ("read" | "write" | "delete")[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface ApiKeyMasked {
|
|
43
|
-
id: string;
|
|
44
|
-
name: string;
|
|
45
|
-
key_prefix: string;
|
|
46
|
-
permissions: ApiKeyPermission[];
|
|
47
|
-
rate_limit: number | null;
|
|
48
|
-
created_by: string;
|
|
49
|
-
created_at: string;
|
|
50
|
-
updated_at: string;
|
|
51
|
-
last_used_at: string | null;
|
|
52
|
-
expires_at: string | null;
|
|
53
|
-
revoked_at: string | null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface ApiKeyWithSecret extends ApiKeyMasked {
|
|
57
|
-
key: string;
|
|
58
|
-
}
|
|
30
|
+
import { CreateApiKeyDialog } from "./CreateApiKeyDialog";
|
|
31
|
+
import { permissionSummary, resourceLabel, resourcePhrase } from "./permissions";
|
|
59
32
|
|
|
60
33
|
/* ═══════════════════════════════════════════════════════════════
|
|
61
34
|
Helpers
|
|
35
|
+
|
|
36
|
+
The row types come from `@rebasepro/types`: this view used to declare its
|
|
37
|
+
own copies, and they had already drifted — neither carried `admin`, so the
|
|
38
|
+
panel could not tell an admin key from a scoped one.
|
|
62
39
|
═══════════════════════════════════════════════════════════════ */
|
|
63
40
|
|
|
64
41
|
function formatRelative(iso: string | null | undefined): string {
|
|
@@ -73,18 +50,6 @@ function formatRelative(iso: string | null | undefined): string {
|
|
|
73
50
|
return d.toLocaleDateString();
|
|
74
51
|
}
|
|
75
52
|
|
|
76
|
-
function permissionSummary(perms: ApiKeyPermission[]): string {
|
|
77
|
-
if (perms.length === 0) return "No permissions";
|
|
78
|
-
const wildcard = perms.find(p => p.collection === "*");
|
|
79
|
-
if (wildcard) {
|
|
80
|
-
return `All collections (${wildcard.operations.join(", ")})`;
|
|
81
|
-
}
|
|
82
|
-
if (perms.length === 1) {
|
|
83
|
-
return `${perms[0].collection} (${perms[0].operations.join(", ")})`;
|
|
84
|
-
}
|
|
85
|
-
return `${perms.length} collections`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
53
|
function isExpired(key: ApiKeyMasked): boolean {
|
|
89
54
|
return !!(key.expires_at && new Date(key.expires_at) < new Date());
|
|
90
55
|
}
|
|
@@ -214,7 +179,10 @@ export function ApiKeysView() {
|
|
|
214
179
|
<div className="flex items-center gap-3 min-w-0">
|
|
215
180
|
<KeyRoundIcon size={iconSize.small} className="text-primary shrink-0"/>
|
|
216
181
|
<div className="min-w-0">
|
|
217
|
-
<
|
|
182
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
183
|
+
<Typography variant="subtitle1" className="font-semibold truncate">{selectedKey.name}</Typography>
|
|
184
|
+
{selectedKey.admin && <AdminChip/>}
|
|
185
|
+
</div>
|
|
218
186
|
<Typography variant="caption" color="secondary" className="font-mono text-[11px]">{selectedKey.key_prefix}•••</Typography>
|
|
219
187
|
</div>
|
|
220
188
|
</div>
|
|
@@ -242,7 +210,12 @@ export function ApiKeysView() {
|
|
|
242
210
|
<StatCard label="Last Used" value={formatRelative(selectedKey.last_used_at)}/>
|
|
243
211
|
<StatCard label="Expires" value={selectedKey.expires_at ? formatRelative(selectedKey.expires_at) : "Never"}/>
|
|
244
212
|
</div>
|
|
245
|
-
<div className="grid grid-cols-2 gap-3 mt-3">
|
|
213
|
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-3">
|
|
214
|
+
<StatCard
|
|
215
|
+
label="Role"
|
|
216
|
+
value={selectedKey.admin ? "Admin" : "Service"}
|
|
217
|
+
className={selectedKey.admin ? "text-amber-600 dark:text-amber-400" : undefined}
|
|
218
|
+
/>
|
|
246
219
|
<StatCard label="Rate Limit" value={selectedKey.rate_limit ? `${selectedKey.rate_limit}/15min` : "Default (1000/15min)"}/>
|
|
247
220
|
<StatCard label="Created By" value={selectedKey.created_by} mono/>
|
|
248
221
|
</div>
|
|
@@ -256,16 +229,31 @@ export function ApiKeysView() {
|
|
|
256
229
|
</Chip>
|
|
257
230
|
</div>
|
|
258
231
|
<div className="flex-1 overflow-y-auto px-5 py-3">
|
|
232
|
+
{selectedKey.admin && (
|
|
233
|
+
<div className="flex items-start gap-2 mb-3 px-3 py-2 rounded-lg border border-amber-500/40 bg-amber-500/[0.06]">
|
|
234
|
+
<ShieldIcon size={iconSize.smallest} className="mt-[3px] shrink-0 text-amber-600 dark:text-amber-400"/>
|
|
235
|
+
<Typography variant="caption" className="text-[12px] leading-snug text-amber-700 dark:text-amber-300">
|
|
236
|
+
This key holds the <span className="font-semibold">admin role</span>: it also passes the
|
|
237
|
+
admin-gated routes — users, roles, cron, backups, logs — and reads through the
|
|
238
|
+
<span className="font-mono"> default_admin</span> RLS policies, beyond the resources listed here.
|
|
239
|
+
</Typography>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
259
242
|
{selectedKey.permissions.length === 0 ? (
|
|
260
243
|
<Typography variant="body2" color="disabled">No permissions configured</Typography>
|
|
261
244
|
) : (
|
|
262
245
|
<div className="space-y-2">
|
|
263
246
|
{selectedKey.permissions.map((perm, idx) => (
|
|
264
247
|
<div key={idx} className={cls("flex items-center gap-3 px-3 py-2 rounded-lg border", defaultBorderMixin)}>
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
248
|
+
<div className="flex-1 min-w-0">
|
|
249
|
+
<Typography variant="body2" className="text-[13px] font-medium truncate">
|
|
250
|
+
{resourceLabel(perm.collection)}
|
|
251
|
+
</Typography>
|
|
252
|
+
<Typography variant="caption" color="secondary" className="text-[11px]">
|
|
253
|
+
{resourcePhrase(perm.collection)}
|
|
254
|
+
</Typography>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
269
257
|
{perm.operations.map(op => (
|
|
270
258
|
<Chip key={op} size="smallest" className={cls(
|
|
271
259
|
op === "read" && "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300",
|
|
@@ -346,6 +334,27 @@ export function ApiKeysView() {
|
|
|
346
334
|
List item
|
|
347
335
|
═══════════════════════════════════════════════════════════════ */
|
|
348
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Marks a key that carries the admin role.
|
|
339
|
+
*
|
|
340
|
+
* Not cosmetic: an admin key reaches the admin routes and the `default_admin`
|
|
341
|
+
* RLS policies, and without this it is indistinguishable in the list from a
|
|
342
|
+
* read-only one.
|
|
343
|
+
*/
|
|
344
|
+
function AdminChip() {
|
|
345
|
+
return (
|
|
346
|
+
<Tooltip title="Carries the admin role: the admin-gated routes and the default_admin RLS policies">
|
|
347
|
+
<Chip
|
|
348
|
+
size="smallest"
|
|
349
|
+
className="shrink-0 bg-amber-500/12 dark:bg-amber-500/12 text-amber-700 dark:text-amber-300 border-amber-500/30 dark:border-amber-500/30"
|
|
350
|
+
>
|
|
351
|
+
<ShieldIcon size={10}/>
|
|
352
|
+
admin
|
|
353
|
+
</Chip>
|
|
354
|
+
</Tooltip>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
349
358
|
function KeyListItem({ apiKey, selected, onClick }: { apiKey: ApiKeyMasked; selected: boolean; onClick: () => void }) {
|
|
350
359
|
const status = keyStatus(apiKey);
|
|
351
360
|
return (
|
|
@@ -363,7 +372,10 @@ function KeyListItem({ apiKey, selected, onClick }: { apiKey: ApiKeyMasked; sele
|
|
|
363
372
|
status.label === "Expired" ? "bg-amber-400" : "bg-red-400"
|
|
364
373
|
)}/>
|
|
365
374
|
<div className="flex-1 min-w-0">
|
|
366
|
-
<
|
|
375
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
376
|
+
<Typography variant="body2" className="truncate font-medium text-[13px]">{apiKey.name}</Typography>
|
|
377
|
+
{apiKey.admin && <AdminChip/>}
|
|
378
|
+
</div>
|
|
367
379
|
<Typography variant="caption" color="secondary" className="truncate text-[11px] font-mono">{apiKey.key_prefix}•••</Typography>
|
|
368
380
|
</div>
|
|
369
381
|
<div className="shrink-0">
|
|
@@ -390,166 +402,6 @@ function StatCard({ label, value, mono, className }: { label: string; value: str
|
|
|
390
402
|
);
|
|
391
403
|
}
|
|
392
404
|
|
|
393
|
-
/* ═══════════════════════════════════════════════════════════════
|
|
394
|
-
Create API Key Dialog
|
|
395
|
-
═══════════════════════════════════════════════════════════════ */
|
|
396
|
-
|
|
397
|
-
interface PermissionRow {
|
|
398
|
-
collection: string;
|
|
399
|
-
read: boolean;
|
|
400
|
-
write: boolean;
|
|
401
|
-
delete: boolean;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function CreateApiKeyDialog({ onClose, onCreated }: { onClose: () => void; onCreated: (key: ApiKeyWithSecret) => void }) {
|
|
405
|
-
const client = useRebaseClient<RebaseClient>();
|
|
406
|
-
const snackbar = useSnackbarController();
|
|
407
|
-
const [name, setName] = useState("");
|
|
408
|
-
const [permissions, setPermissions] = useState<PermissionRow[]>([{ collection: "*", read: true, write: false, delete: false }]);
|
|
409
|
-
const [rateLimit, setRateLimit] = useState("");
|
|
410
|
-
const [expiresIn, setExpiresIn] = useState("never");
|
|
411
|
-
const [creating, setCreating] = useState(false);
|
|
412
|
-
|
|
413
|
-
const addRow = () => setPermissions([...permissions, { collection: "", read: true, write: false, delete: false }]);
|
|
414
|
-
const removeRow = (idx: number) => setPermissions(permissions.filter((_, i) => i !== idx));
|
|
415
|
-
|
|
416
|
-
const updateRow = (idx: number, field: keyof PermissionRow, value: string | boolean) => {
|
|
417
|
-
setPermissions(permissions.map((row, i) => i === idx ? { ...row, [field]: value } : row));
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
const handleCreate = async () => {
|
|
421
|
-
if (!client?.apiKeys || !name.trim()) return;
|
|
422
|
-
const apiPerms: ApiKeyPermission[] = permissions
|
|
423
|
-
.filter(r => r.collection.trim())
|
|
424
|
-
.map(r => ({
|
|
425
|
-
collection: r.collection.trim(),
|
|
426
|
-
operations: [
|
|
427
|
-
...(r.read ? ["read" as const] : []),
|
|
428
|
-
...(r.write ? ["write" as const] : []),
|
|
429
|
-
...(r.delete ? ["delete" as const] : [])
|
|
430
|
-
]
|
|
431
|
-
}))
|
|
432
|
-
.filter(p => p.operations.length > 0);
|
|
433
|
-
|
|
434
|
-
if (apiPerms.length === 0) {
|
|
435
|
-
snackbar.open({ type: "error", message: "At least one permission is required" });
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
let expires_at: string | null = null;
|
|
440
|
-
if (expiresIn === "7d") expires_at = new Date(Date.now() + 7 * 86400000).toISOString();
|
|
441
|
-
else if (expiresIn === "30d") expires_at = new Date(Date.now() + 30 * 86400000).toISOString();
|
|
442
|
-
else if (expiresIn === "90d") expires_at = new Date(Date.now() + 90 * 86400000).toISOString();
|
|
443
|
-
else if (expiresIn === "1y") expires_at = new Date(Date.now() + 365 * 86400000).toISOString();
|
|
444
|
-
|
|
445
|
-
setCreating(true);
|
|
446
|
-
try {
|
|
447
|
-
const res = await client.apiKeys.createKey({
|
|
448
|
-
name: name.trim(),
|
|
449
|
-
permissions: apiPerms,
|
|
450
|
-
rate_limit: rateLimit ? parseInt(rateLimit, 10) : null,
|
|
451
|
-
expires_at
|
|
452
|
-
});
|
|
453
|
-
onCreated(res.key);
|
|
454
|
-
} catch (e: unknown) {
|
|
455
|
-
snackbar.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
|
|
456
|
-
} finally { setCreating(false); }
|
|
457
|
-
};
|
|
458
|
-
|
|
459
|
-
return (
|
|
460
|
-
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }} maxWidth="lg">
|
|
461
|
-
<DialogTitle>Create API Key</DialogTitle>
|
|
462
|
-
<DialogContent className="space-y-4">
|
|
463
|
-
<TextField
|
|
464
|
-
label="Name"
|
|
465
|
-
value={name}
|
|
466
|
-
onChange={(e) => setName(e.target.value)}
|
|
467
|
-
placeholder="e.g. Analytics Pipeline"
|
|
468
|
-
size="small"
|
|
469
|
-
autoFocus
|
|
470
|
-
/>
|
|
471
|
-
|
|
472
|
-
<div>
|
|
473
|
-
<Typography variant="caption" color="secondary" className="text-[11px] uppercase tracking-wider font-medium mb-2 block">
|
|
474
|
-
Permissions
|
|
475
|
-
</Typography>
|
|
476
|
-
<div className="space-y-2">
|
|
477
|
-
{permissions.map((row, idx) => (
|
|
478
|
-
<div key={idx} className={cls("flex items-center gap-2 p-2 rounded-lg border", defaultBorderMixin)}>
|
|
479
|
-
<TextField
|
|
480
|
-
size="small"
|
|
481
|
-
aria-label={`Permission ${idx + 1} collection`}
|
|
482
|
-
value={row.collection}
|
|
483
|
-
onChange={(e) => updateRow(idx, "collection", e.target.value)}
|
|
484
|
-
placeholder="Collection slug or *"
|
|
485
|
-
className="flex-1"
|
|
486
|
-
/>
|
|
487
|
-
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
|
488
|
-
<Checkbox size="small" checked={row.read} onCheckedChange={(v) => updateRow(idx, "read", !!v)}/>
|
|
489
|
-
<span className="text-emerald-600 dark:text-emerald-400">read</span>
|
|
490
|
-
</label>
|
|
491
|
-
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
|
492
|
-
<Checkbox size="small" checked={row.write} onCheckedChange={(v) => updateRow(idx, "write", !!v)}/>
|
|
493
|
-
<span className="text-blue-600 dark:text-blue-400">write</span>
|
|
494
|
-
</label>
|
|
495
|
-
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
|
496
|
-
<Checkbox size="small" checked={row.delete} onCheckedChange={(v) => updateRow(idx, "delete", !!v)}/>
|
|
497
|
-
<span className="text-red-600 dark:text-red-400">delete</span>
|
|
498
|
-
</label>
|
|
499
|
-
{permissions.length > 1 && (
|
|
500
|
-
<IconButton size="small" onClick={() => removeRow(idx)}>
|
|
501
|
-
<DeleteIcon size={iconSize.smallest}/>
|
|
502
|
-
</IconButton>
|
|
503
|
-
)}
|
|
504
|
-
</div>
|
|
505
|
-
))}
|
|
506
|
-
</div>
|
|
507
|
-
<Button size="small" variant="text" onClick={addRow} className="mt-1" startIcon={<AddIcon size={iconSize.smallest}/>}>
|
|
508
|
-
Add collection
|
|
509
|
-
</Button>
|
|
510
|
-
</div>
|
|
511
|
-
|
|
512
|
-
<div className="flex gap-4">
|
|
513
|
-
<div className="flex-1">
|
|
514
|
-
<Select label="Expires" value={expiresIn} onValueChange={setExpiresIn} size="small" renderValue={(v) =>
|
|
515
|
-
v === "never" ? "Never" :
|
|
516
|
-
v === "7d" ? "7 days" :
|
|
517
|
-
v === "30d" ? "30 days" :
|
|
518
|
-
v === "90d" ? "90 days" : "1 year"
|
|
519
|
-
}>
|
|
520
|
-
<SelectItem value="never">Never</SelectItem>
|
|
521
|
-
<SelectItem value="7d">7 days</SelectItem>
|
|
522
|
-
<SelectItem value="30d">30 days</SelectItem>
|
|
523
|
-
<SelectItem value="90d">90 days</SelectItem>
|
|
524
|
-
<SelectItem value="1y">1 year</SelectItem>
|
|
525
|
-
</Select>
|
|
526
|
-
</div>
|
|
527
|
-
<div className="flex-1">
|
|
528
|
-
<TextField
|
|
529
|
-
label="Rate Limit (per 15 min)"
|
|
530
|
-
value={rateLimit}
|
|
531
|
-
onChange={(e) => setRateLimit(e.target.value.replace(/\D/g, ""))}
|
|
532
|
-
placeholder="Default: 1000"
|
|
533
|
-
size="small"
|
|
534
|
-
/>
|
|
535
|
-
</div>
|
|
536
|
-
</div>
|
|
537
|
-
</DialogContent>
|
|
538
|
-
<DialogActions>
|
|
539
|
-
<Button variant="text" onClick={onClose}>Cancel</Button>
|
|
540
|
-
<Button
|
|
541
|
-
color="primary"
|
|
542
|
-
onClick={handleCreate}
|
|
543
|
-
disabled={creating || !name.trim()}
|
|
544
|
-
startIcon={creating ? <CircularProgress size="smallest"/> : undefined}
|
|
545
|
-
>
|
|
546
|
-
Create Key
|
|
547
|
-
</Button>
|
|
548
|
-
</DialogActions>
|
|
549
|
-
</Dialog>
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
405
|
/* ═══════════════════════════════════════════════════════════════
|
|
554
406
|
Secret Display Dialog — shown exactly once after creation
|
|
555
407
|
═══════════════════════════════════════════════════════════════ */
|
|
@@ -609,8 +461,14 @@ function SecretDisplayDialog({ keyWithSecret, onClose }: { keyWithSecret: ApiKey
|
|
|
609
461
|
<strong>Name:</strong> {keyWithSecret.name}
|
|
610
462
|
</Typography>
|
|
611
463
|
<Typography variant="caption" color="secondary">
|
|
612
|
-
<strong>
|
|
464
|
+
<strong>Access:</strong> {permissionSummary(keyWithSecret.permissions)}
|
|
613
465
|
</Typography>
|
|
466
|
+
{keyWithSecret.admin && (
|
|
467
|
+
<Typography variant="caption" className="flex items-center gap-1.5 text-amber-700 dark:text-amber-300">
|
|
468
|
+
<ShieldIcon size={iconSize.smallest} className="shrink-0"/>
|
|
469
|
+
<span><strong>Admin role granted</strong> — the admin routes and the default_admin policies</span>
|
|
470
|
+
</Typography>
|
|
471
|
+
)}
|
|
614
472
|
</div>
|
|
615
473
|
</DialogContent>
|
|
616
474
|
<DialogActions>
|