@rebasepro/studio 0.17.3 → 0.18.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.
Files changed (81) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +12 -4
  3. package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
  4. package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
  5. package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
  6. package/dist/BackupsView-DVkKzaPR.js.map +1 -0
  7. package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
  8. package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
  9. package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
  10. package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
  11. package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
  12. package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
  13. package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
  14. package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
  15. package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
  16. package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
  17. package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
  18. package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
  19. package/dist/components/RLSEditor/saveRules.d.ts +38 -0
  20. package/dist/components/RebaseStudio.d.ts +1 -1
  21. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
  22. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
  23. package/dist/components/load-failure-view.d.ts +17 -0
  24. package/dist/components/load-failure.d.ts +47 -0
  25. package/dist/index.es.js +56 -36
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/load-failure-C6OMJSGC.js +31 -0
  28. package/dist/load-failure-C6OMJSGC.js.map +1 -0
  29. package/dist/load-failure-view-CBTB_h-h.js +48 -0
  30. package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
  31. package/package.json +36 -21
  32. package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
  33. package/dist/BackupsView-DNS6LdVg.js.map +0 -1
  34. package/dist/BranchesView-CM3B-ER3.js.map +0 -1
  35. package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
  36. package/dist/RLSEditor-CBX4uD66.js.map +0 -1
  37. package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
  38. package/dist/StorageView-BGDeC5GI.js.map +0 -1
  39. package/dist/components/StorageView/storage-failure.d.ts +0 -34
  40. package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
  41. package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
  42. package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
  43. package/src/components/ApiExplorer/parseSpec.ts +0 -104
  44. package/src/components/ApiExplorer/types.ts +0 -84
  45. package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
  46. package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
  47. package/src/components/ApiKeys/permissions.ts +0 -145
  48. package/src/components/AuthSimulationSelector.tsx +0 -72
  49. package/src/components/Backups/BackupsView.tsx +0 -185
  50. package/src/components/Branches/BranchesView.tsx +0 -386
  51. package/src/components/CronJobs/CronJobsView.tsx +0 -413
  52. package/src/components/JSEditor/JSEditor.tsx +0 -1100
  53. package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
  54. package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
  55. package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
  56. package/src/components/LogsExplorer/useLogTail.ts +0 -356
  57. package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
  58. package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
  59. package/src/components/RLSEditor/index.ts +0 -1
  60. package/src/components/RLSEditor/policy-presets.ts +0 -176
  61. package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
  62. package/src/components/RebaseStudio.tsx +0 -174
  63. package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
  64. package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
  65. package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
  66. package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
  67. package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
  68. package/src/components/SQLEditor/sql_editor_types.ts +0 -18
  69. package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
  70. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
  71. package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
  72. package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
  73. package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
  74. package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
  75. package/src/components/StorageView/StorageView.tsx +0 -1588
  76. package/src/components/StorageView/storage-failure.ts +0 -63
  77. package/src/components/StudioHomePage.tsx +0 -430
  78. package/src/index.ts +0 -31
  79. package/src/utils/parseSpec.test.ts +0 -309
  80. package/src/utils/sql_utils.test.ts +0 -265
  81. package/src/utils/sql_utils.ts +0 -291
@@ -1,711 +0,0 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from "react";
2
- import {
3
- AlertTriangleIcon,
4
- BooleanSwitchWithLabel,
5
- Button,
6
- ChevronsUpDownIcon,
7
- CircularProgress,
8
- cls,
9
- DatabaseIcon,
10
- defaultBorderMixin,
11
- Dialog,
12
- DialogActions,
13
- DialogContent,
14
- DialogTitle,
15
- FolderIcon,
16
- FunctionSquareIcon,
17
- GlobeIcon,
18
- iconSize,
19
- IconButton,
20
- PlusIcon,
21
- Select,
22
- SelectGroup,
23
- SelectItem,
24
- ShieldIcon,
25
- TextField,
26
- Tooltip,
27
- Trash2Icon,
28
- Typography
29
- } from "@rebasepro/ui";
30
- import {
31
- useApiBase,
32
- useApiConfig,
33
- useRebaseClient,
34
- useSnackbarController,
35
- useStudioCollectionRegistry
36
- } from "@rebasepro/app";
37
- import type { ApiKeyPermission, ApiKeyWithSecret, RebaseClient } from "@rebasepro/types";
38
-
39
- import {
40
- FUNCTION_PREFIX,
41
- grantSentence,
42
- parseResource,
43
- RESOURCE_ALL_FUNCTIONS,
44
- RESOURCE_EVERYTHING,
45
- RESOURCE_STORAGE,
46
- type ResourceKind
47
- } from "./permissions";
48
-
49
- /* ═══════════════════════════════════════════════════════════════
50
- Row model
51
- ═══════════════════════════════════════════════════════════════ */
52
-
53
- interface PermissionRow {
54
- /** The raw wire value for `ApiKeyPermission.collection`. */
55
- resource: string;
56
- read: boolean;
57
- write: boolean;
58
- delete: boolean;
59
- /**
60
- * The row edits its resource as free text instead of through the picker.
61
- * Tracked separately from the value: a slug the panel has not registered
62
- * is still a legitimate grant, and without this flag the row would snap
63
- * back to the picker on the next render as soon as it was typed.
64
- */
65
- freeText: boolean;
66
- }
67
-
68
- const OPERATIONS = ["read", "write", "delete"] as const;
69
- type Operation = (typeof OPERATIONS)[number];
70
-
71
- /** What each operation actually permits, per namespace, shown on the toggle. */
72
- const OPERATION_HINT: Record<ResourceKind, Record<Operation, string>> = {
73
- everything: {
74
- read: "GET on every resource",
75
- write: "POST, PUT and PATCH on every resource",
76
- delete: "DELETE on every resource"
77
- },
78
- collection: {
79
- read: "List and get snapshots",
80
- write: "Create and update snapshots",
81
- delete: "Delete snapshots"
82
- },
83
- storage: {
84
- read: "List and download files",
85
- write: "Upload files and create folders",
86
- delete: "Delete files"
87
- },
88
- "all-functions": {
89
- read: "Call any function over GET",
90
- write: "Call any function over POST, PUT or PATCH",
91
- delete: "Call any function over DELETE"
92
- },
93
- function: {
94
- read: "Call it over GET",
95
- write: "Call it over POST, PUT or PATCH",
96
- delete: "Call it over DELETE"
97
- }
98
- };
99
-
100
- /**
101
- * Picker value that switches a row to free text.
102
- *
103
- * Never reaches the wire — choosing it sets `freeText` and clears the resource
104
- * — so it only has to be a value no collection slug would take.
105
- */
106
- const SENTINEL_FREE_TEXT = "__custom__";
107
-
108
- const rowToPermission = (row: PermissionRow): ApiKeyPermission => ({
109
- collection: row.resource.trim(),
110
- operations: OPERATIONS.filter(op => row[op])
111
- });
112
-
113
- const rowGrantsNothing = (row: PermissionRow): boolean => {
114
- const perm = rowToPermission(row);
115
- return !perm.collection || perm.operations.length === 0;
116
- };
117
-
118
- function ResourceIcon({ kind, className }: { kind: ResourceKind; className?: string }) {
119
- const Component = kind === "everything" ? GlobeIcon
120
- : kind === "storage" ? FolderIcon
121
- : kind === "all-functions" || kind === "function" ? FunctionSquareIcon
122
- : DatabaseIcon;
123
- return <Component size={iconSize.smallest} className={className}/>;
124
- }
125
-
126
- /* ═══════════════════════════════════════════════════════════════
127
- Operation toggles
128
-
129
- Three states carried by one control each: off is neutral, on is tinted with
130
- the operation's own semantic colour. The previous version coloured the
131
- labels whether or not they were checked, so a read-only key still showed a
132
- red "delete" and the row read as more permissive than it was.
133
- ═══════════════════════════════════════════════════════════════ */
134
-
135
- const OPERATION_STYLES: Record<Operation, { on: string; dot: string }> = {
136
- read: {
137
- on: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300 ring-emerald-500/40",
138
- dot: "bg-emerald-500"
139
- },
140
- write: {
141
- on: "bg-blue-500/12 text-blue-700 dark:text-blue-300 ring-blue-500/40",
142
- dot: "bg-blue-500"
143
- },
144
- delete: {
145
- on: "bg-rose-500/12 text-rose-700 dark:text-rose-300 ring-rose-500/40",
146
- dot: "bg-rose-500"
147
- }
148
- };
149
-
150
- function OperationToggles({
151
- row,
152
- onToggle
153
- }: {
154
- row: PermissionRow;
155
- onToggle: (operation: Operation, value: boolean) => void;
156
- }) {
157
- const kind = parseResource(row.resource).kind;
158
- return (
159
- <div className="flex items-center gap-1" role="group" aria-label="Allowed operations">
160
- {OPERATIONS.map(op => {
161
- const active = row[op];
162
- const styles = OPERATION_STYLES[op];
163
- return (
164
- <Tooltip key={op} title={OPERATION_HINT[kind][op]} delayDuration={400}>
165
- <button
166
- type="button"
167
- aria-pressed={active}
168
- onClick={() => onToggle(op, !active)}
169
- className={cls(
170
- "flex items-center gap-1.5 h-7 pl-2 pr-2.5 rounded-md text-2xs font-medium",
171
- "ring-1 transition-colors duration-150 cursor-pointer",
172
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
173
- active
174
- ? styles.on
175
- : "bg-transparent ring-transparent text-surface-500 dark:text-surface-400 hover:bg-surface-accent-100 dark:hover:bg-surface-800"
176
- )}
177
- >
178
- <span className={cls(
179
- "w-1.5 h-1.5 rounded-full transition-colors duration-150",
180
- active ? styles.dot : "bg-surface-300 dark:bg-surface-600"
181
- )}/>
182
- {op}
183
- </button>
184
- </Tooltip>
185
- );
186
- })}
187
- </div>
188
- );
189
- }
190
-
191
- /* ═══════════════════════════════════════════════════════════════
192
- Section heading
193
- ═══════════════════════════════════════════════════════════════ */
194
-
195
- function SectionLabel({ children, hint }: { children: React.ReactNode; hint?: React.ReactNode }) {
196
- return (
197
- <div className="flex items-baseline gap-2 mb-2">
198
- <Typography
199
- variant="label"
200
- className="text-2xs uppercase tracking-wider font-semibold text-surface-600 dark:text-surface-300"
201
- gutterBottom={false}
202
- >
203
- {children}
204
- </Typography>
205
- {hint && (
206
- <Typography variant="caption" color="secondary" className="text-2xs" gutterBottom={false}>
207
- {hint}
208
- </Typography>
209
- )}
210
- </div>
211
- );
212
- }
213
-
214
- /* ═══════════════════════════════════════════════════════════════
215
- Create API Key Dialog
216
- ═══════════════════════════════════════════════════════════════ */
217
-
218
- export function CreateApiKeyDialog({
219
- onClose,
220
- onCreated
221
- }: {
222
- onClose: () => void;
223
- onCreated: (key: ApiKeyWithSecret) => void;
224
- }) {
225
- const client = useRebaseClient<RebaseClient>();
226
- const snackbar = useSnackbarController();
227
- const collectionRegistry = useStudioCollectionRegistry();
228
- const apiConfig = useApiConfig();
229
- const apiBase = useApiBase();
230
-
231
- const [name, setName] = useState("");
232
- const [rows, setRows] = useState<PermissionRow[]>([
233
- { resource: RESOURCE_EVERYTHING, read: true, write: false, delete: false, freeText: false }
234
- ]);
235
- const [admin, setAdmin] = useState(false);
236
- const [rateLimit, setRateLimit] = useState("");
237
- const [expiresIn, setExpiresIn] = useState("never");
238
- const [creating, setCreating] = useState(false);
239
-
240
- /* The collections the panel already knows — the picker's main list. */
241
- const collections = useMemo(
242
- () => (collectionRegistry?.collections ?? [])
243
- .map(col => ({ slug: col.slug, name: col.name }))
244
- .filter(col => !!col.slug)
245
- .sort((a, b) => a.slug.localeCompare(b.slug)),
246
- [collectionRegistry?.collections]
247
- );
248
-
249
- /**
250
- * Deployed functions, so a single-function grant can be picked instead of
251
- * spelled `functions/<name>` from memory. Best-effort: this is an ordinary
252
- * API route, and a backend that serves no functions — or refuses the
253
- * request — just leaves the free-text escape to cover it.
254
- */
255
- const [functionNames, setFunctionNames] = useState<string[]>([]);
256
- useEffect(() => {
257
- if (!apiBase) return;
258
- let cancelled = false;
259
- (async () => {
260
- try {
261
- const token = await apiConfig?.getAuthToken?.();
262
- const res = await fetch(`${apiBase}/functions`, {
263
- headers: token ? { Authorization: `Bearer ${token}` } : undefined
264
- });
265
- if (!res.ok) return;
266
- const body = await res.json() as { functions?: { name?: string }[] };
267
- if (cancelled) return;
268
- setFunctionNames((body.functions ?? [])
269
- .map(fn => fn.name)
270
- .filter((fnName): fnName is string => !!fnName));
271
- } catch {
272
- /* No listing available; the picker falls back to free text. */
273
- }
274
- })();
275
- return () => { cancelled = true; };
276
- }, [apiBase, apiConfig]);
277
-
278
- const updateRow = useCallback((idx: number, patch: Partial<PermissionRow>) => {
279
- setRows(current => current.map((row, i) => i === idx ? { ...row, ...patch } : row));
280
- }, []);
281
-
282
- const addRow = () => setRows(current => [
283
- ...current,
284
- {
285
- resource: collections[0]?.slug ?? "",
286
- read: true,
287
- write: false,
288
- delete: false,
289
- freeText: collections.length === 0
290
- }
291
- ]);
292
-
293
- const removeRow = (idx: number) => setRows(current => current.filter((_, i) => i !== idx));
294
-
295
- /* What would actually be sent. A row with no operations grants nothing;
296
- it used to be dropped at submit time with no indication it had been. */
297
- const effectivePermissions = useMemo(
298
- () => rows.map(rowToPermission).filter(perm => perm.collection && perm.operations.length > 0),
299
- [rows]
300
- );
301
-
302
- const droppedRows = rows.filter(rowGrantsNothing).length;
303
- const hasWildcard = rows.some(row => row.resource === RESOURCE_EVERYTHING && OPERATIONS.some(op => row[op]));
304
- const canSubmit = !!name.trim() && effectivePermissions.length > 0 && !creating;
305
-
306
- const handleCreate = async () => {
307
- if (!client?.apiKeys || !canSubmit) return;
308
-
309
- let expires_at: string | null = null;
310
- if (expiresIn === "7d") expires_at = new Date(Date.now() + 7 * 86400000).toISOString();
311
- else if (expiresIn === "30d") expires_at = new Date(Date.now() + 30 * 86400000).toISOString();
312
- else if (expiresIn === "90d") expires_at = new Date(Date.now() + 90 * 86400000).toISOString();
313
- else if (expiresIn === "1y") expires_at = new Date(Date.now() + 365 * 86400000).toISOString();
314
-
315
- setCreating(true);
316
- try {
317
- const res = await client.apiKeys.createKey({
318
- name: name.trim(),
319
- permissions: effectivePermissions,
320
- admin,
321
- rate_limit: rateLimit ? parseInt(rateLimit, 10) : null,
322
- expires_at
323
- });
324
- onCreated(res.key);
325
- } catch (e: unknown) {
326
- snackbar.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
327
- } finally {
328
- setCreating(false);
329
- }
330
- };
331
-
332
- const submitBlockedReason = !name.trim()
333
- ? "Name the key first"
334
- : effectivePermissions.length === 0
335
- ? "Grant at least one operation on one resource"
336
- : "";
337
-
338
- return (
339
- <Dialog open onOpenChange={(open) => { if (!open && !creating) onClose(); }} maxWidth="2xl">
340
-
341
- <DialogTitle variant="subtitle1" gutterBottom={false} className="font-semibold">
342
- Create API key
343
- </DialogTitle>
344
-
345
- <DialogContent includeMargin={false} className="px-8 pt-2 pb-4 flex flex-col gap-6">
346
-
347
- <Typography variant="body2" color="secondary" gutterBottom={false} className="text-[13px] max-w-[62ch]">
348
- A credential for scripts, cron jobs and agents. It authenticates as itself rather
349
- than as a user, and reaches only what you grant here.
350
- </Typography>
351
-
352
- <TextField
353
- label="Name"
354
- value={name}
355
- onChange={(e) => setName(e.target.value)}
356
- placeholder="e.g. Analytics pipeline"
357
- size="small"
358
- autoFocus
359
- />
360
-
361
- {/* ── Access ── */}
362
- <div>
363
- <SectionLabel hint="What the key may call">Access</SectionLabel>
364
-
365
- <div className={cls("rounded-lg border overflow-hidden", defaultBorderMixin)}>
366
- {rows.map((row, idx) => {
367
- const parsed = parseResource(row.resource);
368
- return (
369
- <div
370
- key={idx}
371
- role="group"
372
- aria-label={`Resource ${idx + 1}`}
373
- className={cls(
374
- "flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2.5",
375
- idx > 0 && cls("border-t", defaultBorderMixin)
376
- )}
377
- >
378
- <div className="flex items-center gap-2 flex-1 min-w-[15rem]">
379
- <ResourceIcon
380
- kind={parsed.kind}
381
- className={cls(
382
- "shrink-0",
383
- parsed.kind === "everything"
384
- ? "text-amber-600 dark:text-amber-400"
385
- : "text-surface-500 dark:text-surface-400"
386
- )}
387
- />
388
- {row.freeText
389
- ? (
390
- <TextField
391
- size="small"
392
- aria-label={`Resource ${idx + 1} name`}
393
- value={row.resource}
394
- onChange={(e) => updateRow(idx, { resource: e.target.value })}
395
- placeholder="collection slug, or functions/<name>"
396
- className="flex-1"
397
- endAdornment={
398
- <Tooltip title="Back to the list">
399
- <IconButton
400
- size="smallest"
401
- aria-label="Pick from the list instead"
402
- onClick={() => updateRow(idx, {
403
- resource: collections[0]?.slug ?? RESOURCE_EVERYTHING,
404
- freeText: false
405
- })}
406
- >
407
- <ChevronsUpDownIcon size={iconSize.smallest}/>
408
- </IconButton>
409
- </Tooltip>
410
- }
411
- />
412
- )
413
- : (
414
- <Select
415
- size="small"
416
- fullWidth
417
- className="flex-1"
418
- aria-label={`Resource ${idx + 1}`}
419
- value={row.resource}
420
- position="popper"
421
- onValueChange={(value) => {
422
- if (value === SENTINEL_FREE_TEXT) {
423
- updateRow(idx, { freeText: true, resource: "" });
424
- } else {
425
- updateRow(idx, { resource: value, freeText: false });
426
- }
427
- }}
428
- renderValue={(value) => (
429
- <span className="truncate">
430
- {value === RESOURCE_EVERYTHING ? "Everything"
431
- : value === RESOURCE_STORAGE ? "Storage"
432
- : value === RESOURCE_ALL_FUNCTIONS ? "All functions"
433
- : String(value).startsWith(FUNCTION_PREFIX)
434
- ? `${String(value).slice(FUNCTION_PREFIX.length)}()`
435
- : String(value)}
436
- </span>
437
- )}
438
- >
439
- <SelectItem value={RESOURCE_EVERYTHING}>
440
- <div className="flex flex-col text-left">
441
- <span>Everything</span>
442
- <span className="text-2xs text-text-secondary dark:text-text-secondary-dark">
443
- Every collection, every function and storage
444
- </span>
445
- </div>
446
- </SelectItem>
447
-
448
- {collections.length > 0 && (
449
- <SelectGroup label="Collections">
450
- {collections.map(col => (
451
- <SelectItem key={col.slug} value={col.slug}>
452
- <div className="flex flex-col text-left">
453
- <span>{col.slug}</span>
454
- {col.name && col.name !== col.slug && (
455
- <span className="text-2xs text-text-secondary dark:text-text-secondary-dark">
456
- {col.name}
457
- </span>
458
- )}
459
- </div>
460
- </SelectItem>
461
- ))}
462
- </SelectGroup>
463
- )}
464
-
465
- <SelectGroup label="Functions">
466
- <SelectItem value={RESOURCE_ALL_FUNCTIONS}>All functions</SelectItem>
467
- {functionNames.map(fnName => (
468
- <SelectItem key={fnName} value={`${FUNCTION_PREFIX}${fnName}`}>
469
- {fnName}()
470
- </SelectItem>
471
- ))}
472
- </SelectGroup>
473
-
474
- <SelectGroup label="Storage">
475
- <SelectItem value={RESOURCE_STORAGE}>Storage</SelectItem>
476
- </SelectGroup>
477
-
478
- <SelectGroup label="Other">
479
- <SelectItem value={SENTINEL_FREE_TEXT}>Type a name…</SelectItem>
480
- </SelectGroup>
481
- </Select>
482
- )}
483
- </div>
484
-
485
- <OperationToggles
486
- row={row}
487
- onToggle={(operation, value) => updateRow(idx, { [operation]: value })}
488
- />
489
-
490
- <Tooltip title={rows.length > 1 ? "Remove" : "At least one resource is required"}>
491
- <span>
492
- <IconButton
493
- size="small"
494
- disabled={rows.length === 1}
495
- onClick={() => removeRow(idx)}
496
- aria-label={`Remove resource ${idx + 1}`}
497
- >
498
- <Trash2Icon size={iconSize.smallest}/>
499
- </IconButton>
500
- </span>
501
- </Tooltip>
502
- </div>
503
- );
504
- })}
505
- </div>
506
-
507
- <Button
508
- size="small"
509
- variant="text"
510
- onClick={addRow}
511
- className="mt-1.5"
512
- startIcon={<PlusIcon size={iconSize.smallest}/>}
513
- >
514
- Add resource
515
- </Button>
516
-
517
- {/* Plain-language read-back of the grant being built. */}
518
- <div className={cls(
519
- "mt-3 rounded-lg border px-3 py-2.5 bg-surface-accent-50 dark:bg-surface-900",
520
- defaultBorderMixin
521
- )}>
522
- <Typography
523
- variant="label"
524
- gutterBottom={false}
525
- className="text-2xs uppercase tracking-wider font-semibold text-surface-600 dark:text-surface-300"
526
- >
527
- This key will be able to
528
- </Typography>
529
- {effectivePermissions.length === 0
530
- ? (
531
- <Typography
532
- variant="body2"
533
- gutterBottom={false}
534
- className="mt-1.5 text-[13px] text-surface-500 dark:text-surface-400"
535
- >
536
- Nothing yet — pick a resource and at least one operation.
537
- </Typography>
538
- )
539
- : (
540
- <ul className="mt-1.5 space-y-1">
541
- {effectivePermissions.map((perm, idx) => (
542
- <li key={idx} className="flex items-start gap-2">
543
- <ResourceIcon
544
- kind={parseResource(perm.collection).kind}
545
- className="mt-[3px] shrink-0 text-surface-500 dark:text-surface-400"
546
- />
547
- <Typography variant="body2" gutterBottom={false} className="text-[13px] leading-snug">
548
- {grantSentence(perm)}
549
- </Typography>
550
- </li>
551
- ))}
552
- {admin && (
553
- <li className="flex items-start gap-2">
554
- <ShieldIcon size={iconSize.smallest} className="mt-[3px] shrink-0 text-amber-600 dark:text-amber-400"/>
555
- <Typography variant="body2" gutterBottom={false} className="text-[13px] leading-snug">
556
- Reach every admin route — users, roles, cron, backups, logs — and read
557
- through the <span className="font-mono text-2xs">default_admin</span> policies
558
- </Typography>
559
- </li>
560
- )}
561
- </ul>
562
- )}
563
- {droppedRows > 0 && effectivePermissions.length > 0 && (
564
- <Typography
565
- variant="caption"
566
- gutterBottom={false}
567
- className="block mt-2 text-2xs text-surface-500 dark:text-surface-400"
568
- >
569
- {droppedRows === 1
570
- ? "1 row grants nothing and will not be saved."
571
- : `${droppedRows} rows grant nothing and will not be saved.`}
572
- </Typography>
573
- )}
574
- </div>
575
-
576
- {hasWildcard && (
577
- <div className="flex items-start gap-2 mt-2 px-1">
578
- <AlertTriangleIcon size={iconSize.smallest} className="mt-[3px] shrink-0 text-amber-600 dark:text-amber-400"/>
579
- <Typography
580
- variant="caption"
581
- gutterBottom={false}
582
- className="text-2xs text-amber-700 dark:text-amber-300 leading-snug max-w-[70ch]"
583
- >
584
- <span className="font-semibold">Everything</span> is the widest grant there is. It also
585
- covers collections and functions you add later, without this key being edited again.
586
- </Typography>
587
- </div>
588
- )}
589
- </div>
590
-
591
- {/* ── Admin role ── */}
592
- <div>
593
- <SectionLabel hint="Off for almost every key">Admin role</SectionLabel>
594
- <div className={cls(
595
- "rounded-lg border px-3 py-2.5 transition-colors duration-150",
596
- admin ? "border-amber-500/40 bg-amber-500/[0.06]" : defaultBorderMixin
597
- )}>
598
- <BooleanSwitchWithLabel
599
- size="small"
600
- position="start"
601
- invisible
602
- value={admin}
603
- onValueChange={setAdmin}
604
- label={
605
- <div className="flex items-center gap-2">
606
- <ShieldIcon
607
- size={iconSize.smallest}
608
- className={admin
609
- ? "text-amber-600 dark:text-amber-400"
610
- : "text-surface-500 dark:text-surface-400"}
611
- />
612
- <span className="text-[13px]">Grant the admin role</span>
613
- </div>
614
- }
615
- />
616
- <Typography
617
- variant="caption"
618
- color="secondary"
619
- gutterBottom={false}
620
- className="block mt-1.5 text-2xs leading-snug max-w-[70ch]"
621
- >
622
- {admin
623
- ? "The key passes the admin-gated routes and reads through the default_admin RLS policies — far wider than the resources above. It still cannot manage API keys."
624
- : "Without it the key carries only the service role, and RLS grants it nothing unless a collection policy names that role."}
625
- </Typography>
626
- </div>
627
- </div>
628
-
629
- {/* ── Limits ── */}
630
- <div>
631
- <SectionLabel>Limits</SectionLabel>
632
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
633
- {/* Each control gets its own cell: `Select.Root` renders no DOM
634
- node, so an unwrapped Select puts its label and its control
635
- into two separate grid items. */}
636
- <div>
637
- <Select
638
- label="Expires"
639
- value={expiresIn}
640
- onValueChange={setExpiresIn}
641
- size="small"
642
- fullWidth
643
- position="popper"
644
- renderValue={(v) =>
645
- v === "never" ? "Never"
646
- : v === "7d" ? "In 7 days"
647
- : v === "30d" ? "In 30 days"
648
- : v === "90d" ? "In 90 days" : "In 1 year"
649
- }
650
- >
651
- <SelectItem value="never">Never</SelectItem>
652
- <SelectItem value="7d">In 7 days</SelectItem>
653
- <SelectItem value="30d">In 30 days</SelectItem>
654
- <SelectItem value="90d">In 90 days</SelectItem>
655
- <SelectItem value="1y">In 1 year</SelectItem>
656
- </Select>
657
- </div>
658
- <div>
659
- {/* Labelled above rather than floating inside, so it lines
660
- up with the Select beside it — a Select can only label
661
- above, and the two idioms in one row do not align. */}
662
- <label
663
- htmlFor="api-key-rate-limit"
664
- className="block text-sm font-medium ml-3.5 mb-1 text-text-secondary dark:text-text-secondary-dark"
665
- >
666
- Rate limit
667
- </label>
668
- <TextField
669
- id="api-key-rate-limit"
670
- value={rateLimit}
671
- onChange={(e) => setRateLimit(e.target.value.replace(/\D/g, ""))}
672
- placeholder="1000"
673
- size="small"
674
- endAdornment={
675
- <span className="text-2xs text-text-secondary dark:text-text-secondary-dark whitespace-nowrap">
676
- / 15 min
677
- </span>
678
- }
679
- />
680
- </div>
681
- </div>
682
- <Typography
683
- variant="caption"
684
- color="secondary"
685
- gutterBottom={false}
686
- className="block mt-1.5 text-2xs"
687
- >
688
- Leave the rate limit empty for the server default of 1000 requests per 15-minute window.
689
- </Typography>
690
- </div>
691
-
692
- </DialogContent>
693
-
694
- <DialogActions>
695
- <Button variant="text" onClick={onClose} disabled={creating}>Cancel</Button>
696
- <Tooltip title={submitBlockedReason}>
697
- <span>
698
- <Button
699
- color="primary"
700
- onClick={handleCreate}
701
- disabled={!canSubmit}
702
- startIcon={creating ? <CircularProgress size="smallest"/> : undefined}
703
- >
704
- {creating ? "Creating…" : "Create key"}
705
- </Button>
706
- </span>
707
- </Tooltip>
708
- </DialogActions>
709
- </Dialog>
710
- );
711
- }