@rebasepro/studio 0.17.3 → 0.18.1
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/LICENSE +0 -1
- package/README.md +12 -4
- package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
- package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
- package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
- package/dist/BackupsView-DVkKzaPR.js.map +1 -0
- package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
- package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
- package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
- package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
- package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
- package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
- package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
- package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
- package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
- package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
- package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
- package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
- package/dist/components/RLSEditor/saveRules.d.ts +38 -0
- package/dist/components/RebaseStudio.d.ts +1 -1
- package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
- package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
- package/dist/components/load-failure-view.d.ts +17 -0
- package/dist/components/load-failure.d.ts +47 -0
- package/dist/index.es.js +56 -36
- package/dist/index.es.js.map +1 -1
- package/dist/load-failure-C6OMJSGC.js +31 -0
- package/dist/load-failure-C6OMJSGC.js.map +1 -0
- package/dist/load-failure-view-CBTB_h-h.js +48 -0
- package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
- package/package.json +36 -21
- package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
- package/dist/BackupsView-DNS6LdVg.js.map +0 -1
- package/dist/BranchesView-CM3B-ER3.js.map +0 -1
- package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
- package/dist/RLSEditor-CBX4uD66.js.map +0 -1
- package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
- package/dist/StorageView-BGDeC5GI.js.map +0 -1
- package/dist/components/StorageView/storage-failure.d.ts +0 -34
- package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
- package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
- package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
- package/src/components/ApiExplorer/parseSpec.ts +0 -104
- package/src/components/ApiExplorer/types.ts +0 -84
- package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
- package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
- package/src/components/ApiKeys/permissions.ts +0 -145
- package/src/components/AuthSimulationSelector.tsx +0 -72
- package/src/components/Backups/BackupsView.tsx +0 -185
- package/src/components/Branches/BranchesView.tsx +0 -386
- package/src/components/CronJobs/CronJobsView.tsx +0 -413
- package/src/components/JSEditor/JSEditor.tsx +0 -1100
- package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
- package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
- package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
- package/src/components/LogsExplorer/useLogTail.ts +0 -356
- package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
- package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
- package/src/components/RLSEditor/index.ts +0 -1
- package/src/components/RLSEditor/policy-presets.ts +0 -176
- package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
- package/src/components/RebaseStudio.tsx +0 -174
- package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
- package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
- package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
- package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
- package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
- package/src/components/SQLEditor/sql_editor_types.ts +0 -18
- package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
- package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
- package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
- package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
- package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
- package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
- package/src/components/StorageView/StorageView.tsx +0 -1588
- package/src/components/StorageView/storage-failure.ts +0 -63
- package/src/components/StudioHomePage.tsx +0 -430
- package/src/index.ts +0 -31
- package/src/utils/parseSpec.test.ts +0 -309
- package/src/utils/sql_utils.test.ts +0 -265
- 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
|
-
}
|