@rebasepro/studio 0.6.1 → 0.7.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 (30) hide show
  1. package/dist/ApiKeysView-D-_FSlNL.js +684 -0
  2. package/dist/ApiKeysView-D-_FSlNL.js.map +1 -0
  3. package/dist/{BranchesView-DncIRcZt.js → BranchesView-Dlg78EQ8.js} +4 -5
  4. package/dist/{BranchesView-DncIRcZt.js.map → BranchesView-Dlg78EQ8.js.map} +1 -1
  5. package/dist/{JSEditor-BhAbEjCP.js → JSEditor-DSucz6wV.js} +4 -4
  6. package/dist/{JSEditor-BhAbEjCP.js.map → JSEditor-DSucz6wV.js.map} +1 -1
  7. package/dist/{LogsExplorer-CqtKILj8.js → LogsExplorer-J4xfsuv3.js} +49 -83
  8. package/dist/LogsExplorer-J4xfsuv3.js.map +1 -0
  9. package/dist/{RLSEditor-DpF1u9EC.js → RLSEditor-BM64laoW.js} +294 -107
  10. package/dist/RLSEditor-BM64laoW.js.map +1 -0
  11. package/dist/{SQLEditor-BLuq_zDM.js → SQLEditor-CuAhR-zr.js} +4 -4
  12. package/dist/{SQLEditor-BLuq_zDM.js.map → SQLEditor-CuAhR-zr.js.map} +1 -1
  13. package/dist/{SchemaVisualizer-BJK2u3C0.js → SchemaVisualizer-OibKoD3g.js} +2 -2
  14. package/dist/{SchemaVisualizer-BJK2u3C0.js.map → SchemaVisualizer-OibKoD3g.js.map} +1 -1
  15. package/dist/components/ApiKeys/ApiKeysView.d.ts +2 -0
  16. package/dist/index.es.js +37 -18
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/index.umd.js +1154 -305
  19. package/dist/index.umd.js.map +1 -1
  20. package/package.json +17 -17
  21. package/src/components/ApiKeys/ApiKeysView.tsx +580 -0
  22. package/src/components/Branches/BranchesView.tsx +3 -4
  23. package/src/components/JSEditor/JSEditorSidebar.tsx +3 -3
  24. package/src/components/LogsExplorer/LogsExplorer.tsx +46 -84
  25. package/src/components/RLSEditor/RLSEditor.tsx +354 -117
  26. package/src/components/RebaseStudio.tsx +10 -1
  27. package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
  28. package/src/components/StudioHomePage.tsx +5 -1
  29. package/dist/LogsExplorer-CqtKILj8.js.map +0 -1
  30. package/dist/RLSEditor-DpF1u9EC.js.map +0 -1
package/package.json CHANGED
@@ -1,33 +1,33 @@
1
1
  {
2
2
  "name": "@rebasepro/studio",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.7.0",
5
5
  "main": "./dist/index.umd.js",
6
6
  "module": "./dist/index.es.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "source": "src/index.ts",
9
9
  "dependencies": {
10
10
  "@monaco-editor/react": "^4.7.0",
11
+ "@rebasepro/client": "workspace:*",
12
+ "@rebasepro/common": "workspace:*",
13
+ "@rebasepro/core": "workspace:*",
14
+ "@rebasepro/types": "workspace:*",
15
+ "@rebasepro/ui": "workspace:*",
16
+ "@rebasepro/utils": "workspace:*",
11
17
  "@types/dagre": "0.7.54",
12
18
  "@xyflow/react": "12.11.0",
13
19
  "dagre": "0.8.5",
14
20
  "monaco-editor": "^0.55.1",
15
21
  "pgsql-ast-parser": "12.0.2",
16
22
  "prism-react-renderer": "^2.4.1",
17
- "react-dropzone": "^15.0.0",
18
- "@rebasepro/common": "0.6.1",
19
- "@rebasepro/core": "0.6.1",
20
- "@rebasepro/types": "0.6.1",
21
- "@rebasepro/ui": "0.6.1",
22
- "@rebasepro/utils": "0.6.1",
23
- "@rebasepro/client": "0.6.1"
23
+ "react-dropzone": "^15.0.0"
24
24
  },
25
25
  "peerDependencies": {
26
+ "@rebasepro/admin": "workspace:*",
26
27
  "react": ">=19.0.0",
27
28
  "react-dom": ">=19.0.0",
28
29
  "react-router": "^7.0.0",
29
- "react-router-dom": "^7.0.0",
30
- "@rebasepro/admin": "0.6.1"
30
+ "react-router-dom": "^7.0.0"
31
31
  },
32
32
  "peerDependenciesMeta": {
33
33
  "@rebasepro/admin": {
@@ -43,6 +43,12 @@
43
43
  },
44
44
  "./package.json": "./package.json"
45
45
  },
46
+ "scripts": {
47
+ "dev": "vite",
48
+ "test": "jest --passWithNoTests",
49
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
50
+ "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
51
+ },
46
52
  "browserslist": {
47
53
  "production": [
48
54
  ">0.2%",
@@ -82,11 +88,5 @@
82
88
  "type": "git",
83
89
  "url": "https://github.com/rebasepro/rebase.git",
84
90
  "directory": "packages/studio"
85
- },
86
- "scripts": {
87
- "dev": "vite",
88
- "test": "jest --passWithNoTests",
89
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
90
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
91
91
  }
92
- }
92
+ }
@@ -0,0 +1,580 @@
1
+
2
+ import React, { useState, useEffect, useRef, useCallback } from "react";
3
+ import {
4
+ Button,
5
+ Card,
6
+ Chip,
7
+ CircularProgress,
8
+ cls,
9
+ defaultBorderMixin,
10
+ Dialog,
11
+ DialogActions,
12
+ DialogContent,
13
+ DialogTitle,
14
+ IconButton,
15
+ iconSize,
16
+ KeyRoundIcon,
17
+ RefreshCwIcon,
18
+ Tooltip,
19
+ Typography,
20
+ TextField,
21
+ Select,
22
+ SelectItem,
23
+ Checkbox,
24
+ CopyIcon,
25
+ PlusIcon as AddIcon,
26
+ Trash2Icon as DeleteIcon,
27
+ AlertCircleIcon,
28
+ CheckCircleIcon
29
+ } from "@rebasepro/ui";
30
+ import { useRebaseClient, useSnackbarController } from "@rebasepro/core";
31
+ import type { RebaseClient } from "@rebasepro/types";
32
+
33
+ /* ═══════════════════════════════════════════════════════════════
34
+ Types — mirrors server-core 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
+ }
59
+
60
+ /* ═══════════════════════════════════════════════════════════════
61
+ Helpers
62
+ ═══════════════════════════════════════════════════════════════ */
63
+
64
+ function formatRelative(iso: string | null | undefined): string {
65
+ if (!iso) return "—";
66
+ const d = new Date(iso);
67
+ const now = Date.now();
68
+ const diff = d.getTime() - now;
69
+ const abs = Math.abs(diff);
70
+ if (abs < 60000) return diff > 0 ? "in <1m" : "<1m ago";
71
+ if (abs < 3600000) { const m = Math.round(abs / 60000); return diff > 0 ? `in ${m}m` : `${m}m ago`; }
72
+ if (abs < 86400000) { const h = Math.round(abs / 3600000); return diff > 0 ? `in ${h}h` : `${h}h ago`; }
73
+ return d.toLocaleDateString();
74
+ }
75
+
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
+ function isExpired(key: ApiKeyMasked): boolean {
89
+ return !!(key.expires_at && new Date(key.expires_at) < new Date());
90
+ }
91
+
92
+ function keyStatus(key: ApiKeyMasked): { label: string; color: string } {
93
+ if (key.revoked_at) return { label: "Revoked", color: "text-red-500" };
94
+ if (isExpired(key)) return { label: "Expired", color: "text-amber-500" };
95
+ return { label: "Active", color: "text-emerald-500" };
96
+ }
97
+
98
+ /* ═══════════════════════════════════════════════════════════════
99
+ Main Component
100
+ ═══════════════════════════════════════════════════════════════ */
101
+
102
+ export function ApiKeysView() {
103
+ const client = useRebaseClient<RebaseClient>();
104
+ const snackbar = useSnackbarController();
105
+ const [keys, setKeys] = useState<ApiKeyMasked[]>([]);
106
+ const [loading, setLoading] = useState(true);
107
+ const [selectedId, setSelectedId] = useState<string | null>(null);
108
+ const [showCreate, setShowCreate] = useState(false);
109
+ const [showSecret, setShowSecret] = useState<ApiKeyWithSecret | null>(null);
110
+ const [revoking, setRevoking] = useState<string | null>(null);
111
+
112
+ const clientRef = useRef(client);
113
+ clientRef.current = client;
114
+ const snackbarRef = useRef(snackbar);
115
+ snackbarRef.current = snackbar;
116
+
117
+ const loadKeys = useCallback(async () => {
118
+ const c = clientRef.current;
119
+ if (!c?.apiKeys) { setLoading(false); return; }
120
+ try {
121
+ const res = await c.apiKeys.listKeys();
122
+ setKeys(res.keys);
123
+ } catch (e: unknown) {
124
+ snackbarRef.current.open({
125
+ type: "error",
126
+ message: e instanceof Error ? e.message : String(e)
127
+ });
128
+ } finally {
129
+ setLoading(false);
130
+ }
131
+ }, []);
132
+
133
+ useEffect(() => { loadKeys(); }, [loadKeys]);
134
+
135
+ const handleRevoke = async (id: string) => {
136
+ const c = clientRef.current;
137
+ if (!c?.apiKeys) return;
138
+ setRevoking(id);
139
+ try {
140
+ await c.apiKeys.revokeKey(id);
141
+ snackbarRef.current.open({ type: "success", message: "API key revoked" });
142
+ await loadKeys();
143
+ if (selectedId === id) setSelectedId(null);
144
+ } catch (e: unknown) {
145
+ snackbarRef.current.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
146
+ } finally { setRevoking(null); }
147
+ };
148
+
149
+ const handleCreated = (keyWithSecret: ApiKeyWithSecret) => {
150
+ setShowCreate(false);
151
+ setShowSecret(keyWithSecret);
152
+ loadKeys();
153
+ };
154
+
155
+ const selectedKey = keys.find(k => k.id === selectedId);
156
+ const activeKeys = keys.filter(k => !k.revoked_at && !isExpired(k));
157
+ const inactiveKeys = keys.filter(k => k.revoked_at || isExpired(k));
158
+
159
+ if (loading) return <div className="flex items-center justify-center h-full"><CircularProgress/></div>;
160
+
161
+ return (
162
+ <>
163
+ <div className="flex h-full w-full overflow-hidden bg-white dark:bg-surface-950">
164
+ {/* ── Key List ── */}
165
+ <div className={cls("flex flex-col w-[340px] min-w-[280px] border-r h-full", defaultBorderMixin)}>
166
+ <div className={cls("flex items-center justify-between px-4 py-2.5 border-b bg-surface-50 dark:bg-surface-900 min-h-[48px]", defaultBorderMixin)}>
167
+ <div className="flex items-center gap-2">
168
+ <KeyRoundIcon size={iconSize.smallest} className="text-primary"/>
169
+ <Typography variant="subtitle2" className="font-semibold">API Keys</Typography>
170
+ <Chip size="smallest" className="bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300">{activeKeys.length}</Chip>
171
+ </div>
172
+ <div className="flex items-center gap-1">
173
+ <IconButton size="small" onClick={loadKeys} title="Refresh"><RefreshCwIcon size={iconSize.smallest}/></IconButton>
174
+ <Button size="small" color="primary" onClick={() => setShowCreate(true)} startIcon={<AddIcon size={iconSize.smallest}/>}>
175
+ New
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ <div className="flex-1 overflow-y-auto p-2 space-y-1">
180
+ {activeKeys.length === 0 && inactiveKeys.length === 0 && (
181
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-center p-6">
182
+ <KeyRoundIcon size={iconSize.medium} className="text-surface-300 dark:text-surface-600"/>
183
+ <Typography variant="body2" color="secondary">No API keys yet</Typography>
184
+ <Typography variant="caption" color="disabled">Create a key to enable machine-to-machine authentication</Typography>
185
+ </div>
186
+ )}
187
+ {activeKeys.map(key => (
188
+ <KeyListItem key={key.id} apiKey={key} selected={selectedId === key.id} onClick={() => setSelectedId(key.id)}/>
189
+ ))}
190
+ {inactiveKeys.length > 0 && (
191
+ <>
192
+ <div className="px-2 pt-3 pb-1">
193
+ <Typography variant="caption" color="disabled" className="text-[10px] uppercase tracking-wider font-medium">Revoked / Expired</Typography>
194
+ </div>
195
+ {inactiveKeys.map(key => (
196
+ <KeyListItem key={key.id} apiKey={key} selected={selectedId === key.id} onClick={() => setSelectedId(key.id)}/>
197
+ ))}
198
+ </>
199
+ )}
200
+ </div>
201
+ </div>
202
+
203
+ {/* ── Detail Panel ── */}
204
+ <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
205
+ {!selectedKey ? (
206
+ <div className="flex items-center justify-center h-full">
207
+ <Typography variant="body2" color="disabled">Select an API key to view details</Typography>
208
+ </div>
209
+ ) : (
210
+ <>
211
+ {/* Header */}
212
+ <div className={cls("flex items-center justify-between px-5 py-3 border-b bg-white dark:bg-surface-950 min-h-[56px]", defaultBorderMixin)}>
213
+ <div className="flex items-center gap-3 min-w-0">
214
+ <KeyRoundIcon size={iconSize.small} className="text-primary shrink-0"/>
215
+ <div className="min-w-0">
216
+ <Typography variant="subtitle1" className="font-semibold truncate">{selectedKey.name}</Typography>
217
+ <Typography variant="caption" color="secondary" className="font-mono text-[11px]">{selectedKey.key_prefix}•••</Typography>
218
+ </div>
219
+ </div>
220
+ <div className="flex items-center gap-2 shrink-0">
221
+ {!selectedKey.revoked_at && (
222
+ <Button
223
+ size="small"
224
+ color="error"
225
+ variant="outlined"
226
+ onClick={() => handleRevoke(selectedKey.id)}
227
+ disabled={revoking === selectedKey.id}
228
+ startIcon={revoking === selectedKey.id ? <CircularProgress size="smallest"/> : <DeleteIcon size={iconSize.smallest}/>}
229
+ >
230
+ Revoke
231
+ </Button>
232
+ )}
233
+ </div>
234
+ </div>
235
+
236
+ {/* Stats */}
237
+ <div className="px-5 py-4 bg-surface-50 dark:bg-surface-900/50">
238
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
239
+ <StatCard label="Status" value={keyStatus(selectedKey).label} className={keyStatus(selectedKey).color}/>
240
+ <StatCard label="Created" value={formatRelative(selectedKey.created_at)}/>
241
+ <StatCard label="Last Used" value={formatRelative(selectedKey.last_used_at)}/>
242
+ <StatCard label="Expires" value={selectedKey.expires_at ? formatRelative(selectedKey.expires_at) : "Never"}/>
243
+ </div>
244
+ <div className="grid grid-cols-2 gap-3 mt-3">
245
+ <StatCard label="Rate Limit" value={selectedKey.rate_limit ? `${selectedKey.rate_limit}/15min` : "Default (1000/15min)"}/>
246
+ <StatCard label="Created By" value={selectedKey.created_by} mono/>
247
+ </div>
248
+ </div>
249
+
250
+ {/* Permissions */}
251
+ <div className={cls("flex items-center gap-2 px-5 py-2 border-y bg-white dark:bg-surface-950", defaultBorderMixin)}>
252
+ <Typography variant="subtitle2" className="font-semibold text-[13px]">Permissions</Typography>
253
+ <Chip size="smallest" className="bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300">
254
+ {selectedKey.permissions.length}
255
+ </Chip>
256
+ </div>
257
+ <div className="flex-1 overflow-y-auto px-5 py-3">
258
+ {selectedKey.permissions.length === 0 ? (
259
+ <Typography variant="body2" color="disabled">No permissions configured</Typography>
260
+ ) : (
261
+ <div className="space-y-2">
262
+ {selectedKey.permissions.map((perm, idx) => (
263
+ <div key={idx} className={cls("flex items-center gap-3 px-3 py-2 rounded-lg border", defaultBorderMixin)}>
264
+ <Typography variant="body2" className="font-mono text-[13px] font-medium flex-1">
265
+ {perm.collection === "*" ? "* (all collections)" : perm.collection}
266
+ </Typography>
267
+ <div className="flex items-center gap-1">
268
+ {perm.operations.map(op => (
269
+ <Chip key={op} size="smallest" className={cls(
270
+ op === "read" && "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300",
271
+ op === "write" && "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300",
272
+ op === "delete" && "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
273
+ )}>{op}</Chip>
274
+ ))}
275
+ </div>
276
+ </div>
277
+ ))}
278
+ </div>
279
+ )}
280
+ </div>
281
+ </>
282
+ )}
283
+ </div>
284
+ </div>
285
+
286
+ {/* Create Dialog */}
287
+ {showCreate && (
288
+ <CreateApiKeyDialog
289
+ onClose={() => setShowCreate(false)}
290
+ onCreated={handleCreated}
291
+ />
292
+ )}
293
+
294
+ {/* Secret Display Dialog */}
295
+ {showSecret && (
296
+ <SecretDisplayDialog
297
+ keyWithSecret={showSecret}
298
+ onClose={() => setShowSecret(null)}
299
+ />
300
+ )}
301
+ </>
302
+ );
303
+ }
304
+
305
+ /* ═══════════════════════════════════════════════════════════════
306
+ List item
307
+ ═══════════════════════════════════════════════════════════════ */
308
+
309
+ function KeyListItem({ apiKey, selected, onClick }: { apiKey: ApiKeyMasked; selected: boolean; onClick: () => void }) {
310
+ const status = keyStatus(apiKey);
311
+ return (
312
+ <div
313
+ onClick={onClick}
314
+ className={cls(
315
+ "flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all",
316
+ selected
317
+ ? "bg-primary/10 dark:bg-primary/15 ring-1 ring-primary/30"
318
+ : "hover:bg-surface-100 dark:hover:bg-surface-950"
319
+ )}
320
+ >
321
+ <div className={cls("w-2 h-2 rounded-full shrink-0",
322
+ status.label === "Active" ? "bg-emerald-400" :
323
+ status.label === "Expired" ? "bg-amber-400" : "bg-red-400"
324
+ )}/>
325
+ <div className="flex-1 min-w-0">
326
+ <Typography variant="body2" className="truncate font-medium text-[13px]">{apiKey.name}</Typography>
327
+ <Typography variant="caption" color="secondary" className="truncate text-[11px] font-mono">{apiKey.key_prefix}•••</Typography>
328
+ </div>
329
+ <div className="shrink-0">
330
+ <Typography variant="caption" color="disabled" className="text-[10px]">{permissionSummary(apiKey.permissions)}</Typography>
331
+ </div>
332
+ </div>
333
+ );
334
+ }
335
+
336
+ /* ═══════════════════════════════════════════════════════════════
337
+ Stat card
338
+ ═══════════════════════════════════════════════════════════════ */
339
+
340
+ function StatCard({ label, value, mono, className }: { label: string; value: string; mono?: boolean; className?: string }) {
341
+ return (
342
+ <div className={cls("px-3 py-2 rounded-lg border bg-white dark:bg-surface-900", defaultBorderMixin)}>
343
+ <Typography variant="caption" color="secondary" className="text-[10px] uppercase tracking-wider font-medium">{label}</Typography>
344
+ <Typography variant="body2" className={cls(
345
+ "mt-0.5 font-semibold text-[13px]",
346
+ mono && "font-mono",
347
+ className
348
+ )}>{value}</Typography>
349
+ </div>
350
+ );
351
+ }
352
+
353
+ /* ═══════════════════════════════════════════════════════════════
354
+ Create API Key Dialog
355
+ ═══════════════════════════════════════════════════════════════ */
356
+
357
+ interface PermissionRow {
358
+ collection: string;
359
+ read: boolean;
360
+ write: boolean;
361
+ delete: boolean;
362
+ }
363
+
364
+ function CreateApiKeyDialog({ onClose, onCreated }: { onClose: () => void; onCreated: (key: ApiKeyWithSecret) => void }) {
365
+ const client = useRebaseClient<RebaseClient>();
366
+ const snackbar = useSnackbarController();
367
+ const [name, setName] = useState("");
368
+ const [permissions, setPermissions] = useState<PermissionRow[]>([{ collection: "*", read: true, write: false, delete: false }]);
369
+ const [rateLimit, setRateLimit] = useState("");
370
+ const [expiresIn, setExpiresIn] = useState("never");
371
+ const [creating, setCreating] = useState(false);
372
+
373
+ const addRow = () => setPermissions([...permissions, { collection: "", read: true, write: false, delete: false }]);
374
+ const removeRow = (idx: number) => setPermissions(permissions.filter((_, i) => i !== idx));
375
+
376
+ const updateRow = (idx: number, field: keyof PermissionRow, value: string | boolean) => {
377
+ setPermissions(permissions.map((row, i) => i === idx ? { ...row, [field]: value } : row));
378
+ };
379
+
380
+ const handleCreate = async () => {
381
+ if (!client?.apiKeys || !name.trim()) return;
382
+ const apiPerms: ApiKeyPermission[] = permissions
383
+ .filter(r => r.collection.trim())
384
+ .map(r => ({
385
+ collection: r.collection.trim(),
386
+ operations: [
387
+ ...(r.read ? ["read" as const] : []),
388
+ ...(r.write ? ["write" as const] : []),
389
+ ...(r.delete ? ["delete" as const] : [])
390
+ ]
391
+ }))
392
+ .filter(p => p.operations.length > 0);
393
+
394
+ if (apiPerms.length === 0) {
395
+ snackbar.open({ type: "error", message: "At least one permission is required" });
396
+ return;
397
+ }
398
+
399
+ let expires_at: string | null = null;
400
+ if (expiresIn === "7d") expires_at = new Date(Date.now() + 7 * 86400000).toISOString();
401
+ else if (expiresIn === "30d") expires_at = new Date(Date.now() + 30 * 86400000).toISOString();
402
+ else if (expiresIn === "90d") expires_at = new Date(Date.now() + 90 * 86400000).toISOString();
403
+ else if (expiresIn === "1y") expires_at = new Date(Date.now() + 365 * 86400000).toISOString();
404
+
405
+ setCreating(true);
406
+ try {
407
+ const res = await client.apiKeys.createKey({
408
+ name: name.trim(),
409
+ permissions: apiPerms,
410
+ rate_limit: rateLimit ? parseInt(rateLimit, 10) : null,
411
+ expires_at
412
+ });
413
+ onCreated(res.key);
414
+ } catch (e: unknown) {
415
+ snackbar.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
416
+ } finally { setCreating(false); }
417
+ };
418
+
419
+ return (
420
+ <Dialog open onOpenChange={(open) => { if (!open) onClose(); }} maxWidth="lg">
421
+ <DialogTitle>Create API Key</DialogTitle>
422
+ <DialogContent className="space-y-4">
423
+ <TextField
424
+ label="Name"
425
+ value={name}
426
+ onChange={(e) => setName(e.target.value)}
427
+ placeholder="e.g. Analytics Pipeline"
428
+ size="small"
429
+ autoFocus
430
+ />
431
+
432
+ <div>
433
+ <Typography variant="caption" color="secondary" className="text-[11px] uppercase tracking-wider font-medium mb-2 block">
434
+ Permissions
435
+ </Typography>
436
+ <div className="space-y-2">
437
+ {permissions.map((row, idx) => (
438
+ <div key={idx} className={cls("flex items-center gap-2 p-2 rounded-lg border", defaultBorderMixin)}>
439
+ <TextField
440
+ size="small"
441
+ value={row.collection}
442
+ onChange={(e) => updateRow(idx, "collection", e.target.value)}
443
+ placeholder="Collection slug or *"
444
+ className="flex-1"
445
+ />
446
+ <label className="flex items-center gap-1 text-xs cursor-pointer">
447
+ <Checkbox size="small" checked={row.read} onCheckedChange={(v) => updateRow(idx, "read", !!v)}/>
448
+ <span className="text-emerald-600 dark:text-emerald-400">read</span>
449
+ </label>
450
+ <label className="flex items-center gap-1 text-xs cursor-pointer">
451
+ <Checkbox size="small" checked={row.write} onCheckedChange={(v) => updateRow(idx, "write", !!v)}/>
452
+ <span className="text-blue-600 dark:text-blue-400">write</span>
453
+ </label>
454
+ <label className="flex items-center gap-1 text-xs cursor-pointer">
455
+ <Checkbox size="small" checked={row.delete} onCheckedChange={(v) => updateRow(idx, "delete", !!v)}/>
456
+ <span className="text-red-600 dark:text-red-400">delete</span>
457
+ </label>
458
+ {permissions.length > 1 && (
459
+ <IconButton size="small" onClick={() => removeRow(idx)}>
460
+ <DeleteIcon size={iconSize.smallest}/>
461
+ </IconButton>
462
+ )}
463
+ </div>
464
+ ))}
465
+ </div>
466
+ <Button size="small" variant="text" onClick={addRow} className="mt-1" startIcon={<AddIcon size={iconSize.smallest}/>}>
467
+ Add collection
468
+ </Button>
469
+ </div>
470
+
471
+ <div className="flex gap-4">
472
+ <div className="flex-1">
473
+ <Select label="Expires" value={expiresIn} onValueChange={setExpiresIn} size="small" renderValue={(v) =>
474
+ v === "never" ? "Never" :
475
+ v === "7d" ? "7 days" :
476
+ v === "30d" ? "30 days" :
477
+ v === "90d" ? "90 days" : "1 year"
478
+ }>
479
+ <SelectItem value="never">Never</SelectItem>
480
+ <SelectItem value="7d">7 days</SelectItem>
481
+ <SelectItem value="30d">30 days</SelectItem>
482
+ <SelectItem value="90d">90 days</SelectItem>
483
+ <SelectItem value="1y">1 year</SelectItem>
484
+ </Select>
485
+ </div>
486
+ <div className="flex-1">
487
+ <TextField
488
+ label="Rate Limit (per 15 min)"
489
+ value={rateLimit}
490
+ onChange={(e) => setRateLimit(e.target.value.replace(/\D/g, ""))}
491
+ placeholder="Default: 1000"
492
+ size="small"
493
+ />
494
+ </div>
495
+ </div>
496
+ </DialogContent>
497
+ <DialogActions>
498
+ <Button variant="text" onClick={onClose}>Cancel</Button>
499
+ <Button
500
+ color="primary"
501
+ onClick={handleCreate}
502
+ disabled={creating || !name.trim()}
503
+ startIcon={creating ? <CircularProgress size="smallest"/> : undefined}
504
+ >
505
+ Create Key
506
+ </Button>
507
+ </DialogActions>
508
+ </Dialog>
509
+ );
510
+ }
511
+
512
+ /* ═══════════════════════════════════════════════════════════════
513
+ Secret Display Dialog — shown exactly once after creation
514
+ ═══════════════════════════════════════════════════════════════ */
515
+
516
+ function SecretDisplayDialog({ keyWithSecret, onClose }: { keyWithSecret: ApiKeyWithSecret; onClose: () => void }) {
517
+ const snackbar = useSnackbarController();
518
+ const [copied, setCopied] = useState(false);
519
+
520
+ const handleCopy = async () => {
521
+ try {
522
+ await navigator.clipboard.writeText(keyWithSecret.key);
523
+ setCopied(true);
524
+ snackbar.open({ type: "success", message: "API key copied to clipboard" });
525
+ setTimeout(() => setCopied(false), 2000);
526
+ } catch {
527
+ snackbar.open({ type: "error", message: "Failed to copy" });
528
+ }
529
+ };
530
+
531
+ return (
532
+ <Dialog open onOpenChange={(open) => { if (!open) onClose(); }} maxWidth="md">
533
+ <DialogTitle>
534
+ <div className="flex items-center gap-2">
535
+ <CheckCircleIcon size={iconSize.small} className="text-emerald-500"/>
536
+ API Key Created
537
+ </div>
538
+ </DialogTitle>
539
+ <DialogContent>
540
+ <div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 mb-4">
541
+ <div className="flex items-center gap-2 mb-1">
542
+ <AlertCircleIcon size={iconSize.smallest} className="text-amber-600 dark:text-amber-400"/>
543
+ <Typography variant="caption" className="font-semibold text-amber-700 dark:text-amber-400">
544
+ Copy your key now — it won&apos;t be shown again
545
+ </Typography>
546
+ </div>
547
+ <Typography variant="caption" className="text-amber-600 dark:text-amber-300">
548
+ This is the only time the full API key will be displayed. Store it securely.
549
+ </Typography>
550
+ </div>
551
+
552
+ <div className={cls("flex items-center gap-2 p-3 rounded-lg border bg-surface-50 dark:bg-surface-900", defaultBorderMixin)}>
553
+ <code className="flex-1 text-[12px] font-mono break-all text-surface-700 dark:text-surface-300 select-all">
554
+ {keyWithSecret.key}
555
+ </code>
556
+ <Tooltip title={copied ? "Copied!" : "Copy"}>
557
+ <IconButton size="small" onClick={handleCopy}>
558
+ {copied
559
+ ? <CheckCircleIcon size={iconSize.smallest} className="text-emerald-500"/>
560
+ : <CopyIcon size={iconSize.smallest}/>
561
+ }
562
+ </IconButton>
563
+ </Tooltip>
564
+ </div>
565
+
566
+ <div className="mt-4 space-y-1">
567
+ <Typography variant="caption" color="secondary">
568
+ <strong>Name:</strong> {keyWithSecret.name}
569
+ </Typography>
570
+ <Typography variant="caption" color="secondary">
571
+ <strong>Permissions:</strong> {permissionSummary(keyWithSecret.permissions)}
572
+ </Typography>
573
+ </div>
574
+ </DialogContent>
575
+ <DialogActions>
576
+ <Button color="primary" onClick={onClose}>Done</Button>
577
+ </DialogActions>
578
+ </Dialog>
579
+ );
580
+ }
@@ -330,13 +330,12 @@ export function BranchesView() {
330
330
  <div>
331
331
  <Select
332
332
  label="Source Database"
333
- value={sourceBranch ?? ""}
334
- onValueChange={(v) => setSourceBranch(v || undefined)}
333
+ value={sourceBranch ?? "__main__"}
334
+ onValueChange={(v) => setSourceBranch(v === "__main__" ? undefined : v)}
335
335
  placeholder="Default (main database)"
336
336
  size="small"
337
- renderValue={(v) => v || "Default (main database)"}
338
337
  >
339
- <SelectItem value="">Default (main database)</SelectItem>
338
+ <SelectItem value="__main__">Default (main database)</SelectItem>
340
339
  {branches.map(b => (
341
340
  <SelectItem key={b.name} value={b.name}>{b.name}</SelectItem>
342
341
  ))}