@rebasepro/studio 0.8.0 → 0.9.1-canary.09aaf62
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/README.md +4 -4
- package/dist/{ApiExplorer-CdIwR9Ga.js → ApiExplorer-B9M9-uy_.js} +3 -3
- package/dist/ApiExplorer-B9M9-uy_.js.map +1 -0
- package/dist/{ApiKeysView-D-_FSlNL.js → ApiKeysView-vQKqt2wl.js} +45 -3
- package/dist/ApiKeysView-vQKqt2wl.js.map +1 -0
- package/dist/{AuthSimulationSelector-iEZ-Or_1.js → AuthSimulationSelector-Bougd521.js} +2 -2
- package/dist/AuthSimulationSelector-Bougd521.js.map +1 -0
- package/dist/BackupsView-DNS6LdVg.js +227 -0
- package/dist/BackupsView-DNS6LdVg.js.map +1 -0
- package/dist/{BranchesView-Dlg78EQ8.js → BranchesView-DH0qnU9J.js} +2 -2
- package/dist/BranchesView-DH0qnU9J.js.map +1 -0
- package/dist/{CronJobsView-4gdtJvoe.js → CronJobsView-B59i3Mis.js} +2 -2
- package/dist/CronJobsView-B59i3Mis.js.map +1 -0
- package/dist/{JSEditor-Ca4XYGRp.js → JSEditor-BeHHuxhA.js} +28 -32
- package/dist/JSEditor-BeHHuxhA.js.map +1 -0
- package/dist/LogsExplorer-BCXoGREr.js +298 -0
- package/dist/LogsExplorer-BCXoGREr.js.map +1 -0
- package/dist/{MonacoEditor-COZqrIJ1.js → MonacoEditor-D0_QljcX.js} +2 -2
- package/dist/MonacoEditor-D0_QljcX.js.map +1 -0
- package/dist/{RLSEditor-BM64laoW.js → RLSEditor-D6adwB6e.js} +38 -34
- package/dist/RLSEditor-D6adwB6e.js.map +1 -0
- package/dist/{SQLEditor-CuAhR-zr.js → SQLEditor-_dCqVAN_.js} +8 -8
- package/dist/SQLEditor-_dCqVAN_.js.map +1 -0
- package/dist/{SchemaVisualizer-OibKoD3g.js → SchemaVisualizer-C_ErehfH.js} +11 -11
- package/dist/SchemaVisualizer-C_ErehfH.js.map +1 -0
- package/dist/{StorageView-BMhD29YO.js → StorageView-UvyM2xdW.js} +2 -2
- package/dist/StorageView-UvyM2xdW.js.map +1 -0
- package/dist/components/AuthSimulationSelector.d.ts +1 -1
- package/dist/components/Backups/BackupsView.d.ts +2 -0
- package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.es.js +74 -111
- package/dist/index.es.js.map +1 -1
- package/dist/utils/pgColumnToProperty.d.ts +3 -3
- package/dist/utils/sql_utils.d.ts +3 -3
- package/package.json +11 -12
- package/src/components/ApiExplorer/ApiExplorer.tsx +1 -1
- package/src/components/ApiExplorer/TryItPanel.tsx +1 -1
- package/src/components/ApiKeys/ApiKeysView.tsx +43 -3
- package/src/components/AuthSimulationSelector.tsx +1 -1
- package/src/components/Backups/BackupsView.tsx +185 -0
- package/src/components/Branches/BranchesView.tsx +1 -1
- package/src/components/CronJobs/CronJobsView.tsx +1 -1
- package/src/components/JSEditor/JSEditor.tsx +12 -12
- package/src/components/JSEditor/JSMonacoEditor.tsx +21 -25
- package/src/components/LogsExplorer/LogsExplorer.tsx +168 -46
- package/src/components/RLSEditor/PolicyEditor.tsx +5 -3
- package/src/components/RLSEditor/RLSEditor.tsx +51 -34
- package/src/components/RebaseStudio.tsx +12 -3
- package/src/components/SQLEditor/MonacoEditor.tsx +1 -1
- package/src/components/SQLEditor/SQLEditor.tsx +6 -6
- package/src/components/SQLEditor/SQLEditorSidebar.tsx +1 -1
- package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
- package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +10 -9
- package/src/components/SchemaVisualizer/TableNode.tsx +1 -1
- package/src/components/SchemaVisualizer/useSchemaGraph.ts +11 -11
- package/src/components/StorageView/StorageView.tsx +1 -1
- package/src/components/StudioHomePage.tsx +62 -87
- package/src/index.ts +4 -4
- package/src/utils/pgColumnToProperty.ts +3 -3
- package/src/utils/sql_utils.test.ts +6 -6
- package/src/utils/sql_utils.ts +3 -3
- package/dist/ApiExplorer-CdIwR9Ga.js.map +0 -1
- package/dist/ApiKeysView-D-_FSlNL.js.map +0 -1
- package/dist/AuthSimulationSelector-iEZ-Or_1.js.map +0 -1
- package/dist/BranchesView-Dlg78EQ8.js.map +0 -1
- package/dist/CronJobsView-4gdtJvoe.js.map +0 -1
- package/dist/JSEditor-Ca4XYGRp.js.map +0 -1
- package/dist/LogsExplorer-J4xfsuv3.js +0 -206
- package/dist/LogsExplorer-J4xfsuv3.js.map +0 -1
- package/dist/MonacoEditor-COZqrIJ1.js.map +0 -1
- package/dist/RLSEditor-BM64laoW.js.map +0 -1
- package/dist/SQLEditor-CuAhR-zr.js.map +0 -1
- package/dist/SchemaVisualizer-OibKoD3g.js.map +0 -1
- package/dist/StorageView-BMhD29YO.js.map +0 -1
- package/dist/index.umd.js +0 -11491
- package/dist/index.umd.js.map +0 -1
- package/src/vite-env.d.ts +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import {
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { ArrowDownToLineIcon, Checkbox, cls, defaultBorderMixin, Label, Select, SelectItem, TextField, Typography } from "@rebasepro/ui";
|
|
3
|
+
import { useApiConfig } from "@rebasepro/app";
|
|
3
4
|
|
|
4
5
|
interface LogEntry {
|
|
5
6
|
id: string;
|
|
@@ -25,14 +26,48 @@ const SOURCE_COLORS: Record<string, string> = {
|
|
|
25
26
|
system: "text-surface-600 dark:text-surface-400"
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
// Ids are `log_<n>` with a monotonic counter, so numeric comparison tells
|
|
30
|
+
// old entries from new ones across polls.
|
|
31
|
+
const idNum = (entry: LogEntry): number => Number(entry.id.slice("log_".length));
|
|
32
|
+
|
|
33
|
+
// The window is contiguous and ordered, so identical ends mean identical content.
|
|
34
|
+
const sameLogs = (a: LogEntry[], b: LogEntry[]): boolean =>
|
|
35
|
+
a.length === b.length &&
|
|
36
|
+
a[0]?.id === b[0]?.id &&
|
|
37
|
+
a[a.length - 1]?.id === b[b.length - 1]?.id;
|
|
38
|
+
|
|
39
|
+
// How close to the bottom edge still counts as "at the bottom".
|
|
40
|
+
const STICK_THRESHOLD = 40;
|
|
41
|
+
|
|
28
42
|
export function LogsExplorer() {
|
|
29
43
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
30
44
|
const [level, setLevel] = useState<string>("all");
|
|
31
45
|
const [source, setSource] = useState<string>("all");
|
|
46
|
+
const [searchInput, setSearchInput] = useState("");
|
|
32
47
|
const [search, setSearch] = useState("");
|
|
33
48
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
// Entries that arrived while the user was scrolled away from the bottom.
|
|
51
|
+
const [newCount, setNewCount] = useState(0);
|
|
52
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
34
53
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
// Whether the view is stuck to the bottom right now. A ref, not state:
|
|
55
|
+
// the scroll handler and the fetch loop both read it synchronously.
|
|
56
|
+
const stickRef = useRef(true);
|
|
57
|
+
const lastMaxIdRef = useRef<number | null>(null);
|
|
58
|
+
const apiConfig = useApiConfig();
|
|
59
|
+
|
|
60
|
+
// Debounce the search box so typing doesn't refetch per keystroke.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const t = setTimeout(() => setSearch(searchInput), 300);
|
|
63
|
+
return () => clearTimeout(t);
|
|
64
|
+
}, [searchInput]);
|
|
65
|
+
|
|
35
66
|
const fetchLogs = useCallback(async () => {
|
|
67
|
+
if (!apiConfig?.apiUrl) {
|
|
68
|
+
setError("No API URL configured — cannot load logs.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
36
71
|
try {
|
|
37
72
|
const params = new URLSearchParams();
|
|
38
73
|
if (level && level !== "all") params.set("level", level);
|
|
@@ -40,14 +75,46 @@ export function LogsExplorer() {
|
|
|
40
75
|
if (search) params.set("search", search);
|
|
41
76
|
params.set("limit", "200");
|
|
42
77
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
// Logs are admin-only, so the request must carry the auth token. The
|
|
79
|
+
// URL is absolute: a relative one would resolve against the frontend
|
|
80
|
+
// origin, which serves index.html rather than the API.
|
|
81
|
+
const headers: Record<string, string> = {};
|
|
82
|
+
const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
|
|
83
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
84
|
+
|
|
85
|
+
const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });
|
|
86
|
+
if (!resp.ok) {
|
|
87
|
+
setError(resp.status === 401 || resp.status === 403
|
|
88
|
+
? "Not authorised to read logs — an admin role is required."
|
|
89
|
+
: `Could not load logs (HTTP ${resp.status}).`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const data: { entries?: LogEntry[] } = await resp.json();
|
|
93
|
+
// The API returns newest-first; the view tails like a terminal, so
|
|
94
|
+
// flip to chronological order (newest at the bottom).
|
|
95
|
+
const entries = (data.entries || []).slice().reverse();
|
|
96
|
+
|
|
97
|
+
const prevMax = lastMaxIdRef.current;
|
|
98
|
+
if (prevMax != null && !stickRef.current) {
|
|
99
|
+
const fresh = entries.filter(e => idNum(e) > prevMax).length;
|
|
100
|
+
if (fresh > 0) setNewCount(c => c + fresh);
|
|
47
101
|
}
|
|
48
|
-
|
|
49
|
-
|
|
102
|
+
if (entries.length > 0) lastMaxIdRef.current = idNum(entries[entries.length - 1]);
|
|
103
|
+
|
|
104
|
+
// Unchanged window → keep the previous array so React leaves the
|
|
105
|
+
// DOM alone (hover states and text selection survive the poll).
|
|
106
|
+
setLogs(prev => sameLogs(prev, entries) ? prev : entries);
|
|
107
|
+
setError(null);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
setError(e instanceof Error ? e.message : "Could not load logs.");
|
|
50
110
|
}
|
|
111
|
+
}, [level, source, search, apiConfig]);
|
|
112
|
+
|
|
113
|
+
// A filter change replaces the window wholesale — "new entries since last
|
|
114
|
+
// poll" stops meaning anything, so the counter starts over.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
lastMaxIdRef.current = null;
|
|
117
|
+
setNewCount(0);
|
|
51
118
|
}, [level, source, search]);
|
|
52
119
|
|
|
53
120
|
useEffect(() => {
|
|
@@ -82,9 +149,31 @@ export function LogsExplorer() {
|
|
|
82
149
|
};
|
|
83
150
|
}, [fetchLogs]);
|
|
84
151
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
152
|
+
const scrollToBottom = useCallback(() => {
|
|
153
|
+
const el = containerRef.current;
|
|
154
|
+
if (!el) return;
|
|
155
|
+
el.scrollTop = el.scrollHeight;
|
|
156
|
+
stickRef.current = true;
|
|
157
|
+
setAtBottom(true);
|
|
158
|
+
setNewCount(0);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
// Stick to the bottom only while the user is already there. Scrolling up
|
|
162
|
+
// disengages; scrolling back down re-engages. Our own scrollTop writes
|
|
163
|
+
// always land at the bottom, so they can never disengage it.
|
|
164
|
+
const handleScroll = useCallback(() => {
|
|
165
|
+
const el = containerRef.current;
|
|
166
|
+
if (!el) return;
|
|
167
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
168
|
+
stickRef.current = nearBottom;
|
|
169
|
+
setAtBottom(nearBottom);
|
|
170
|
+
if (nearBottom) setNewCount(0);
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
useLayoutEffect(() => {
|
|
174
|
+
if (autoScroll && stickRef.current) {
|
|
175
|
+
const el = containerRef.current;
|
|
176
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
88
177
|
}
|
|
89
178
|
}, [logs, autoScroll]);
|
|
90
179
|
|
|
@@ -123,15 +212,18 @@ export function LogsExplorer() {
|
|
|
123
212
|
<TextField
|
|
124
213
|
size="small"
|
|
125
214
|
placeholder="Search logs..."
|
|
126
|
-
value={
|
|
127
|
-
onChange={e =>
|
|
215
|
+
value={searchInput}
|
|
216
|
+
onChange={e => setSearchInput(e.target.value)}
|
|
128
217
|
className="flex-1 min-w-[200px]"
|
|
129
218
|
/>
|
|
130
219
|
<div className="flex items-center gap-1.5 cursor-pointer ml-2">
|
|
131
220
|
<Checkbox
|
|
132
221
|
id="auto-scroll"
|
|
133
222
|
checked={autoScroll}
|
|
134
|
-
onCheckedChange={
|
|
223
|
+
onCheckedChange={(checked: boolean) => {
|
|
224
|
+
setAutoScroll(checked);
|
|
225
|
+
if (checked) scrollToBottom();
|
|
226
|
+
}}
|
|
135
227
|
size="small"
|
|
136
228
|
padding={false}
|
|
137
229
|
/>
|
|
@@ -148,40 +240,70 @@ export function LogsExplorer() {
|
|
|
148
240
|
</Typography>
|
|
149
241
|
</div>
|
|
150
242
|
</div>
|
|
151
|
-
|
|
243
|
+
|
|
244
|
+
{/* A failing poll must stay visible even while stale logs are on
|
|
245
|
+
screen — otherwise the view quietly freezes. */}
|
|
246
|
+
{error && logs.length > 0 && (
|
|
247
|
+
<div className={cls(
|
|
248
|
+
"px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0",
|
|
249
|
+
defaultBorderMixin
|
|
250
|
+
)}>
|
|
251
|
+
<Typography variant="caption" className="text-amber-700 dark:text-amber-400">
|
|
252
|
+
{error}
|
|
253
|
+
</Typography>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
152
257
|
{/* Log entries */}
|
|
153
|
-
<div
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
258
|
+
<div className="relative flex-1 min-h-0">
|
|
259
|
+
<div
|
|
260
|
+
ref={containerRef}
|
|
261
|
+
onScroll={handleScroll}
|
|
262
|
+
className="h-full overflow-auto py-2"
|
|
263
|
+
>
|
|
264
|
+
{logs.map(log => (
|
|
265
|
+
<div
|
|
266
|
+
key={log.id}
|
|
267
|
+
className={cls(
|
|
268
|
+
"flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors",
|
|
269
|
+
defaultBorderMixin
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
<Typography variant="body2" color="secondary" className="w-[72px] shrink-0 font-mono">
|
|
273
|
+
{new Date(log.timestamp).toLocaleTimeString()}
|
|
274
|
+
</Typography>
|
|
275
|
+
<Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
|
|
276
|
+
{log.level}
|
|
277
|
+
</Typography>
|
|
278
|
+
<Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
|
|
279
|
+
[{log.source}]
|
|
280
|
+
</Typography>
|
|
281
|
+
<Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
|
|
282
|
+
{log.message}
|
|
283
|
+
</Typography>
|
|
284
|
+
</div>
|
|
285
|
+
))}
|
|
286
|
+
{logs.length === 0 && (
|
|
287
|
+
<div className="p-8 text-center">
|
|
288
|
+
<Typography
|
|
289
|
+
variant="body2"
|
|
290
|
+
className={error ? "text-red-600 dark:text-red-500" : undefined}
|
|
291
|
+
color={error ? undefined : "secondary"}
|
|
292
|
+
>
|
|
293
|
+
{error ?? "No log entries yet. Logs will appear here as requests come in."}
|
|
294
|
+
</Typography>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{autoScroll && !atBottom && newCount > 0 && (
|
|
300
|
+
<button
|
|
301
|
+
onClick={scrollToBottom}
|
|
302
|
+
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-full bg-primary text-white text-xs font-medium pl-2.5 pr-3 py-1.5 shadow-md hover:bg-primary-dark transition-colors"
|
|
164
303
|
>
|
|
165
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
<Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
|
|
169
|
-
{log.level}
|
|
170
|
-
</Typography>
|
|
171
|
-
<Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
|
|
172
|
-
[{log.source}]
|
|
173
|
-
</Typography>
|
|
174
|
-
<Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
|
|
175
|
-
{log.message}
|
|
176
|
-
</Typography>
|
|
177
|
-
</div>
|
|
178
|
-
))}
|
|
179
|
-
{logs.length === 0 && (
|
|
180
|
-
<div className="p-8 text-center">
|
|
181
|
-
<Typography variant="body2" color="secondary">
|
|
182
|
-
No log entries yet. Logs will appear here as requests come in.
|
|
183
|
-
</Typography>
|
|
184
|
-
</div>
|
|
304
|
+
<ArrowDownToLineIcon size={14}/>
|
|
305
|
+
{newCount} new {newCount === 1 ? "entry" : "entries"}
|
|
306
|
+
</button>
|
|
185
307
|
)}
|
|
186
308
|
</div>
|
|
187
309
|
</div>
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
TextField,
|
|
20
20
|
Typography
|
|
21
21
|
} from "@rebasepro/ui";
|
|
22
|
-
import { useTranslation } from "@rebasepro/
|
|
22
|
+
import { useTranslation } from "@rebasepro/app";
|
|
23
23
|
import { MonacoEditor } from "../SQLEditor/MonacoEditor";
|
|
24
24
|
import { PostgresPolicy } from "./RLSEditor";
|
|
25
25
|
|
|
@@ -187,7 +187,9 @@ export const PolicyEditor = ({
|
|
|
187
187
|
|
|
188
188
|
return (
|
|
189
189
|
<>
|
|
190
|
-
|
|
190
|
+
{/* Inline header: this editor renders as a full pane, not inside a Dialog,
|
|
191
|
+
so DialogTitle (Radix-bound) cannot be used here. */}
|
|
192
|
+
<Typography variant="h6" gutterBottom className="mt-8 mx-8 flex justify-between items-center w-full">
|
|
191
193
|
<div>
|
|
192
194
|
<div>{policy ? t("studio_policy_edit") : t("studio_policy_create")}</div>
|
|
193
195
|
<div className="text-sm font-normal text-text-secondary dark:text-text-secondary-dark tracking-wide mt-1">
|
|
@@ -197,7 +199,7 @@ export const PolicyEditor = ({
|
|
|
197
199
|
<IconButton size="small" onClick={() => setHelpOpen(true)}>
|
|
198
200
|
<HelpCircleIcon size={iconSize.smallest}/>
|
|
199
201
|
</IconButton>
|
|
200
|
-
</
|
|
202
|
+
</Typography>
|
|
201
203
|
|
|
202
204
|
<DialogContent className="p-4 md:p-6 border-t dark:border-surface-950 bg-surface-50 dark:bg-surface-950" includeMargin={false}>
|
|
203
205
|
<div className="max-w-4xl mx-auto">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { useStudioCollectionRegistry } from "@rebasepro/
|
|
2
|
+
import { useStudioCollectionRegistry } from "@rebasepro/app";
|
|
3
3
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
4
4
|
import {
|
|
5
5
|
Alert,
|
|
@@ -25,9 +25,11 @@ import {
|
|
|
25
25
|
Trash2Icon,
|
|
26
26
|
Typography
|
|
27
27
|
} from "@rebasepro/ui";
|
|
28
|
-
import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/
|
|
29
|
-
import {
|
|
28
|
+
import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/app";
|
|
29
|
+
import { isPostgresCollectionConfig } from "@rebasepro/types";
|
|
30
30
|
import { REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_PREFIXES, JUNCTION_TABLES_SQL } from "@rebasepro/common";
|
|
31
|
+
import { getPolicyNamesForRule, getPolicyNamesForRules, getPolicyOperations } from "@rebasepro/utils";
|
|
32
|
+
import { resolveJunctionSpecs, getJunctionSecurityRules } from "@rebasepro/common";
|
|
31
33
|
import { PolicyEditor } from "./PolicyEditor";
|
|
32
34
|
|
|
33
35
|
type TableCategory = "collection" | "junction" | "internal" | "other";
|
|
@@ -387,41 +389,54 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
|
387
389
|
status: "live" };
|
|
388
390
|
});
|
|
389
391
|
|
|
390
|
-
// Merge code-based policies
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
qual: rule.using || null,
|
|
405
|
-
with_check: rule.withCheck || null,
|
|
406
|
-
status: "both"
|
|
407
|
-
};
|
|
408
|
-
} else {
|
|
409
|
-
policiesMap[ruleName] = {
|
|
410
|
-
policyname: ruleName,
|
|
392
|
+
// Merge code-based policies.
|
|
393
|
+
//
|
|
394
|
+
// A rule without an explicit `name` still produces policies — Postgres gets
|
|
395
|
+
// `<table>_<op>_<hash>`, one per operation. Skipping those rules left their
|
|
396
|
+
// live policies looking like hand-written SQL ("DB Only"), so derive the
|
|
397
|
+
// names the generator would emit and match on those.
|
|
398
|
+
if (activeCollection && isPostgresCollectionConfig(activeCollection) && activeCollection.securityRules) {
|
|
399
|
+
activeCollection.securityRules.forEach((rule) => {
|
|
400
|
+
const ops = getPolicyOperations(rule);
|
|
401
|
+
const policyNames = getPolicyNamesForRule(rule, activeTableData.tableName);
|
|
402
|
+
|
|
403
|
+
policyNames.forEach((policyName, opIdx) => {
|
|
404
|
+
policiesMap[policyName] = {
|
|
405
|
+
policyname: policyName,
|
|
411
406
|
tablename: activeTableData.tableName,
|
|
412
407
|
permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
|
|
413
|
-
cmd: (rule.operation
|
|
414
|
-
roles: rule.roles
|
|
408
|
+
cmd: (ops[opIdx] ?? rule.operation ?? "ALL").toUpperCase() as PostgresPolicy["cmd"],
|
|
409
|
+
roles: [...(rule.roles ?? ["public"])],
|
|
415
410
|
qual: rule.using || null,
|
|
416
411
|
with_check: rule.withCheck || null,
|
|
417
|
-
|
|
412
|
+
// "both" = defined in code and live in Postgres (potentially edited)
|
|
413
|
+
status: policiesMap[policyName] ? "both" : "code_only"
|
|
418
414
|
};
|
|
419
|
-
}
|
|
415
|
+
});
|
|
420
416
|
});
|
|
421
417
|
}
|
|
422
418
|
|
|
419
|
+
// Junction tables have no collection, but their policies are generated
|
|
420
|
+
// too — derived from the endpoints' relations. Recognise them so they
|
|
421
|
+
// don't show as hand-written SQL ("DB Only"). If the registry's
|
|
422
|
+
// collections don't carry resolvable relations, they simply stay "live".
|
|
423
|
+
if (!activeCollection) {
|
|
424
|
+
try {
|
|
425
|
+
const registryCollections = (collectionRegistry.collections ?? []) as unknown as Parameters<typeof resolveJunctionSpecs>[0];
|
|
426
|
+
const spec = resolveJunctionSpecs(registryCollections).get(activeTableData.tableName);
|
|
427
|
+
if (spec) {
|
|
428
|
+
const generatedNames = getPolicyNamesForRules(getJunctionSecurityRules(spec), spec.table);
|
|
429
|
+
for (const p of Object.values(policiesMap)) {
|
|
430
|
+
if (generatedNames.has(p.policyname)) p.status = "both";
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
/* serialized configs without relation closures — leave as live */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
423
438
|
return Object.values(policiesMap).sort((a, b) => a.policyname.localeCompare(b.policyname));
|
|
424
|
-
}, [activeTableData, activeCollection]);
|
|
439
|
+
}, [activeTableData, activeCollection, collectionRegistry.collections]);
|
|
425
440
|
|
|
426
441
|
// Stats for the info tab
|
|
427
442
|
const rlsStats = useMemo(() => {
|
|
@@ -734,7 +749,7 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
734
749
|
roles: newPolicy.roles
|
|
735
750
|
};
|
|
736
751
|
|
|
737
|
-
const existingRules = (
|
|
752
|
+
const existingRules = (isPostgresCollectionConfig(activeCollection) ? activeCollection.securityRules : undefined) || [];
|
|
738
753
|
let newRules;
|
|
739
754
|
if (editingPolicy === "new") {
|
|
740
755
|
newRules = [...existingRules, rule];
|
|
@@ -829,8 +844,10 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
829
844
|
</Typography>
|
|
830
845
|
<Typography variant="caption" className="opacity-80">
|
|
831
846
|
This is an auto-generated junction table for a many-to-many relation.
|
|
832
|
-
|
|
833
|
-
|
|
847
|
+
Rebase derives its policies: rows are readable when both related rows
|
|
848
|
+
are, and writable following the declaring collection's update rules
|
|
849
|
+
(plus the server/admin baseline). You can still add RLS policies
|
|
850
|
+
directly to broaden access.
|
|
834
851
|
</Typography>
|
|
835
852
|
</div>
|
|
836
853
|
</div>
|
|
@@ -845,7 +862,7 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
845
862
|
Unmapped Table
|
|
846
863
|
</Typography>
|
|
847
864
|
<Typography variant="caption" className="opacity-80">
|
|
848
|
-
This table exists in the database but isn
|
|
865
|
+
This table exists in the database but isn't mapped to a collection definition.
|
|
849
866
|
Import it into a Schema configuration file to manage security policies visually.
|
|
850
867
|
</Typography>
|
|
851
868
|
</div>
|
|
@@ -926,7 +943,7 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
926
943
|
roles: policy.roles
|
|
927
944
|
};
|
|
928
945
|
|
|
929
|
-
const existingRules = (
|
|
946
|
+
const existingRules = (isPostgresCollectionConfig(activeCollection) ? activeCollection.securityRules : undefined) || [];
|
|
930
947
|
const newRules = [...existingRules, rule];
|
|
931
948
|
|
|
932
949
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { lazy, Suspense, useLayoutEffect, useMemo } from "react";
|
|
2
|
-
import { useRebaseRegistryDispatch } from "@rebasepro/
|
|
2
|
+
import { useRebaseRegistryDispatch } from "@rebasepro/app";
|
|
3
3
|
import type { RebaseStudioConfig, AppView } from "@rebasepro/types";
|
|
4
4
|
import { CircularProgressCenter } from "@rebasepro/ui";
|
|
5
5
|
|
|
@@ -12,6 +12,7 @@ const StorageView = lazy(() => import("./StorageView/StorageView").then(m => ({
|
|
|
12
12
|
const CronJobsView = lazy(() => import("./CronJobs/CronJobsView").then(m => ({ default: m.CronJobsView })));
|
|
13
13
|
const SchemaVisualizer = lazy(() => import("./SchemaVisualizer/SchemaVisualizer").then(m => ({ default: m.SchemaVisualizer })));
|
|
14
14
|
const BranchesView = lazy(() => import("./Branches/BranchesView").then(m => ({ default: m.BranchesView })));
|
|
15
|
+
const BackupsView = lazy(() => import("./Backups/BackupsView").then(m => ({ default: m.BackupsView })));
|
|
15
16
|
const ApiExplorer = lazy(() => import("./ApiExplorer/ApiExplorer").then(m => ({ default: m.ApiExplorer })));
|
|
16
17
|
const LogsExplorer = lazy(() => import("./LogsExplorer/LogsExplorer").then(m => ({ default: m.LogsExplorer })));
|
|
17
18
|
const ApiKeysView = lazy(() => import("./ApiKeys/ApiKeysView").then(m => ({ default: m.ApiKeysView })));
|
|
@@ -23,7 +24,7 @@ import { StudioHomePage } from "./StudioHomePage";
|
|
|
23
24
|
* Renders nothing — purely registers config into the RebaseRegistry.
|
|
24
25
|
*
|
|
25
26
|
* The "schema" tool (collection editor view) is now a built-in CMS feature.
|
|
26
|
-
* When `<
|
|
27
|
+
* When `<RebaseAdmin collectionEditor={...}>` is used, the schema view is
|
|
27
28
|
* automatically injected into Studio — no manual wiring needed.
|
|
28
29
|
*/
|
|
29
30
|
const DEFAULT_HOME_PAGE = <StudioHomePage/>;
|
|
@@ -35,7 +36,7 @@ export function RebaseStudio({ tools, homePage }: RebaseStudioConfig) {
|
|
|
35
36
|
|
|
36
37
|
const devViews: AppView[] = useMemo(() => {
|
|
37
38
|
const views: AppView[] = [];
|
|
38
|
-
const activeTools = tools ?? ["sql", "js", "rls", "storage", "cron", "schema-visualizer", "branches", "api", "logs", "api-keys"];
|
|
39
|
+
const activeTools = tools ?? ["sql", "js", "rls", "storage", "cron", "schema-visualizer", "branches", "backups", "api", "logs", "api-keys"];
|
|
39
40
|
const suspense = (el: React.ReactNode) => <Suspense fallback={<CircularProgressCenter/>}>{el}</Suspense>;
|
|
40
41
|
|
|
41
42
|
if (activeTools.includes("sql")) {
|
|
@@ -93,6 +94,14 @@ group: "Database",
|
|
|
93
94
|
icon: "GitBranch",
|
|
94
95
|
description: "Create and manage database branches",
|
|
95
96
|
view: suspense(<BranchesView/>) });
|
|
97
|
+
}
|
|
98
|
+
if (activeTools.includes("backups")) {
|
|
99
|
+
views.push({ slug: "backups",
|
|
100
|
+
name: "Backups",
|
|
101
|
+
group: "Database",
|
|
102
|
+
icon: "Database",
|
|
103
|
+
description: "Download database backups",
|
|
104
|
+
view: suspense(<BackupsView/>) });
|
|
96
105
|
}
|
|
97
106
|
if (activeTools.includes("api")) {
|
|
98
107
|
views.push({ slug: "api",
|
|
@@ -2,7 +2,7 @@ import React, { useRef } from "react";
|
|
|
2
2
|
import Editor, { Monaco, OnMount } from "@monaco-editor/react";
|
|
3
3
|
import type { editor, Position, IRange } from "monaco-editor";
|
|
4
4
|
import { cls, defaultBorderMixin } from "@rebasepro/ui";
|
|
5
|
-
import { useModeController } from "@rebasepro/
|
|
5
|
+
import { useModeController } from "@rebasepro/app";
|
|
6
6
|
|
|
7
7
|
export type MonacoEditorProps = {
|
|
8
8
|
value: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
|
-
import { IconForView } from "@rebasepro/
|
|
3
|
-
import { useStudioCollectionRegistry,
|
|
2
|
+
import { IconForView } from "@rebasepro/app";
|
|
3
|
+
import { useStudioCollectionRegistry, useStudioSidePanelController } from "@rebasepro/app";
|
|
4
4
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
import {
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
XIcon
|
|
43
43
|
} from "@rebasepro/ui";
|
|
44
44
|
|
|
45
|
-
import { useRebaseContext, useSnackbarController, ConfirmationDialog, ErrorView, useTranslation } from "@rebasepro/
|
|
45
|
+
import { useRebaseContext, useSnackbarController, ConfirmationDialog, ErrorView, useTranslation } from "@rebasepro/app";
|
|
46
46
|
import { MonacoEditor } from "./MonacoEditor";
|
|
47
47
|
import { SQLEditorSidebar, Snippet } from "./SQLEditorSidebar";
|
|
48
48
|
import { parseFirst } from "pgsql-ast-parser";
|
|
@@ -213,7 +213,7 @@ const getStoragePrefix = (baseUrl?: string) => {
|
|
|
213
213
|
|
|
214
214
|
export const SQLEditor = () => {
|
|
215
215
|
const { databaseAdmin, client } = useRebaseContext();
|
|
216
|
-
const
|
|
216
|
+
const sidePanelController = useStudioSidePanelController();
|
|
217
217
|
const snackbarController = useSnackbarController();
|
|
218
218
|
const collectionRegistry = useStudioCollectionRegistry();
|
|
219
219
|
|
|
@@ -1100,7 +1100,7 @@ id: String(ra.entityId) })}>
|
|
|
1100
1100
|
className="text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
|
|
1101
1101
|
onClick={(e) => {
|
|
1102
1102
|
e.stopPropagation();
|
|
1103
|
-
|
|
1103
|
+
sidePanelController?.open({
|
|
1104
1104
|
path: ra.collection.collection.slug,
|
|
1105
1105
|
entityId: ra.entityId,
|
|
1106
1106
|
collection: ra.collection.collection,
|
|
@@ -1133,7 +1133,7 @@ id: String(ra.entityId) })}>
|
|
|
1133
1133
|
key={ra.collection.tableName}
|
|
1134
1134
|
dense
|
|
1135
1135
|
onClick={() => {
|
|
1136
|
-
|
|
1136
|
+
sidePanelController?.open({
|
|
1137
1137
|
path: ra.collection.collection.slug,
|
|
1138
1138
|
entityId: ra.entityId,
|
|
1139
1139
|
collection: ra.collection.collection,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import React, { useState } from "react";
|
|
3
3
|
import { cls, defaultBorderMixin, IconButton, iconSize, Tab, Tabs, Trash2Icon, Typography } from "@rebasepro/ui";
|
|
4
|
-
import { useTranslation } from "@rebasepro/
|
|
4
|
+
import { useTranslation } from "@rebasepro/app";
|
|
5
5
|
import { SchemaBrowser } from "./SchemaBrowser";
|
|
6
6
|
import { TableInfo } from "./SQLEditor";
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
Typography
|
|
16
16
|
} from "@rebasepro/ui";
|
|
17
17
|
import { TableInfo } from "./SQLEditor";
|
|
18
|
-
import { ErrorView, useTranslation } from "@rebasepro/
|
|
18
|
+
import { ErrorView, useTranslation } from "@rebasepro/app";
|
|
19
19
|
|
|
20
20
|
export const SchemaBrowser = ({
|
|
21
21
|
onTableClick,
|
|
@@ -86,7 +86,7 @@ export const SchemaBrowser = ({
|
|
|
86
86
|
e.stopPropagation();
|
|
87
87
|
navigator.clipboard.writeText(table.tableName);
|
|
88
88
|
}}
|
|
89
|
-
title="
|
|
89
|
+
title="Copy table name"
|
|
90
90
|
>
|
|
91
91
|
<CopyIcon size={iconSize.small}/>
|
|
92
92
|
</IconButton>
|
|
@@ -148,7 +148,7 @@ export const SchemaBrowser = ({
|
|
|
148
148
|
e.stopPropagation();
|
|
149
149
|
navigator.clipboard.writeText(col.name);
|
|
150
150
|
}}
|
|
151
|
-
title="
|
|
151
|
+
title="Copy column name"
|
|
152
152
|
>
|
|
153
153
|
<CopyIcon size={iconSize.smallest}/>
|
|
154
154
|
</IconButton>
|