@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.
@@ -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
- Types — mirrors server api-key-types.ts
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
- <Typography variant="subtitle1" className="font-semibold truncate">{selectedKey.name}</Typography>
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
- <Typography variant="body2" className="font-mono text-[13px] font-medium flex-1">
266
- {perm.collection === "*" ? "* (all collections)" : perm.collection}
267
- </Typography>
268
- <div className="flex items-center gap-1">
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
- <Typography variant="body2" className="truncate font-medium text-[13px]">{apiKey.name}</Typography>
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>Permissions:</strong> {permissionSummary(keyWithSecret.permissions)}
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>