@rebasepro/studio 0.9.1-canary.fd3754b → 0.10.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.
- package/dist/{ApiKeysView-Dbe5cXkS.js → ApiKeysView-vQKqt2wl.js} +44 -2
- package/dist/ApiKeysView-vQKqt2wl.js.map +1 -0
- package/dist/{JSEditor-Etgw9-Cw.js → JSEditor-BeHHuxhA.js} +3 -3
- package/dist/{JSEditor-Etgw9-Cw.js.map → JSEditor-BeHHuxhA.js.map} +1 -1
- package/dist/LogsExplorer-BCXoGREr.js +298 -0
- package/dist/LogsExplorer-BCXoGREr.js.map +1 -0
- package/dist/{RLSEditor-Cgv_kUVW.js → RLSEditor-rLlMcZPG.js} +42 -28
- package/dist/RLSEditor-rLlMcZPG.js.map +1 -0
- package/dist/{SQLEditor-ZZsy1Mk6.js → SQLEditor-_dCqVAN_.js} +3 -3
- package/dist/SQLEditor-_dCqVAN_.js.map +1 -0
- package/dist/{SchemaVisualizer-D01DzU-f.js → SchemaVisualizer-C_ErehfH.js} +2 -2
- package/dist/{SchemaVisualizer-D01DzU-f.js.map → SchemaVisualizer-C_ErehfH.js.map} +1 -1
- package/dist/components/RLSEditor/PolicyEditor.d.ts +1 -1
- package/dist/components/SQLEditor/SQLEditor.d.ts +2 -10
- package/dist/components/SQLEditor/SQLEditorSidebar.d.ts +1 -1
- package/dist/components/SQLEditor/SchemaBrowser.d.ts +1 -1
- package/dist/components/SQLEditor/sql_editor_types.d.ts +16 -0
- package/dist/index.es.js +58 -105
- package/dist/index.es.js.map +1 -1
- package/dist/utils/sql_utils.d.ts +1 -1
- package/package.json +13 -12
- package/src/components/ApiKeys/ApiKeysView.tsx +41 -1
- package/src/components/JSEditor/JSEditor.tsx +3 -3
- package/src/components/LogsExplorer/LogsExplorer.tsx +168 -46
- package/src/components/RLSEditor/PolicyEditor.tsx +15 -13
- package/src/components/RLSEditor/RLSEditor.tsx +48 -18
- package/src/components/SQLEditor/SQLEditor.tsx +3 -11
- package/src/components/SQLEditor/SQLEditorSidebar.tsx +1 -1
- package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
- package/src/components/SQLEditor/sql_editor_types.ts +18 -0
- package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +2 -1
- package/src/components/StudioHomePage.tsx +61 -86
- package/src/utils/sql_utils.test.ts +1 -1
- package/src/utils/sql_utils.ts +1 -1
- package/dist/ApiKeysView-Dbe5cXkS.js.map +0 -1
- package/dist/LogsExplorer-J4xfsuv3.js +0 -206
- package/dist/LogsExplorer-J4xfsuv3.js.map +0 -1
- package/dist/RLSEditor-Cgv_kUVW.js.map +0 -1
- package/dist/SQLEditor-ZZsy1Mk6.js.map +0 -1
- package/dist/index.umd.js +0 -11709
- package/dist/index.umd.js.map +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>
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "@rebasepro/ui";
|
|
22
22
|
import { useTranslation } from "@rebasepro/app";
|
|
23
23
|
import { MonacoEditor } from "../SQLEditor/MonacoEditor";
|
|
24
|
-
import { PostgresPolicy } from "./RLSEditor";
|
|
24
|
+
import type { PostgresPolicy } from "./RLSEditor";
|
|
25
25
|
|
|
26
26
|
export interface PolicyEditorProps {
|
|
27
27
|
policy?: PostgresPolicy;
|
|
@@ -84,34 +84,34 @@ const POLICY_PRESETS: PolicyPreset[] = [
|
|
|
84
84
|
{
|
|
85
85
|
id: "user_select_own",
|
|
86
86
|
label: "Users can read their own rows",
|
|
87
|
-
description: "Users can only read rows where the
|
|
87
|
+
description: "Users can only read rows where the uid matches their auth.uid()",
|
|
88
88
|
policyname: "Users can select their own data",
|
|
89
89
|
cmd: "SELECT",
|
|
90
90
|
permissive: "PERMISSIVE",
|
|
91
91
|
roles: ["authenticated"],
|
|
92
|
-
qual: "auth.uid() =
|
|
92
|
+
qual: "auth.uid() = uid",
|
|
93
93
|
with_check: ""
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
96
|
id: "user_update_own",
|
|
97
97
|
label: "Users can update their own rows",
|
|
98
|
-
description: "Users can only update rows where the
|
|
98
|
+
description: "Users can only update rows where the uid matches their auth.uid()",
|
|
99
99
|
policyname: "Users can update their own data",
|
|
100
100
|
cmd: "UPDATE",
|
|
101
101
|
permissive: "PERMISSIVE",
|
|
102
102
|
roles: ["authenticated"],
|
|
103
|
-
qual: "auth.uid() =
|
|
104
|
-
with_check: "auth.uid() =
|
|
103
|
+
qual: "auth.uid() = uid",
|
|
104
|
+
with_check: "auth.uid() = uid"
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
id: "user_delete_own",
|
|
108
108
|
label: "Users can delete their own rows",
|
|
109
|
-
description: "Users can only delete rows where the
|
|
109
|
+
description: "Users can only delete rows where the uid matches their auth.uid()",
|
|
110
110
|
policyname: "Users can delete their own data",
|
|
111
111
|
cmd: "DELETE",
|
|
112
112
|
permissive: "PERMISSIVE",
|
|
113
113
|
roles: ["authenticated"],
|
|
114
|
-
qual: "auth.uid() =
|
|
114
|
+
qual: "auth.uid() = uid",
|
|
115
115
|
with_check: ""
|
|
116
116
|
}
|
|
117
117
|
];
|
|
@@ -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">
|
|
@@ -393,7 +395,7 @@ export const PolicyEditor = ({
|
|
|
393
395
|
{t("studio_policy_help_step3_desc")}
|
|
394
396
|
</Typography>
|
|
395
397
|
<div className={cls("bg-surface-100 dark:bg-surface-950 px-3 py-2 rounded-md font-mono text-sm my-2", defaultBorderMixin)}>
|
|
396
|
-
Example: auth.uid() =
|
|
398
|
+
Example: auth.uid() = uid
|
|
397
399
|
</div>
|
|
398
400
|
<Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
|
|
399
401
|
{t("studio_policy_help_step3_example")}
|
|
@@ -406,7 +408,7 @@ export const PolicyEditor = ({
|
|
|
406
408
|
{t("studio_policy_help_step4_desc")}
|
|
407
409
|
</Typography>
|
|
408
410
|
<div className={cls("bg-surface-100 dark:bg-surface-950 px-3 py-2 rounded-md font-mono text-sm my-2", defaultBorderMixin)}>
|
|
409
|
-
Example: auth.uid() =
|
|
411
|
+
Example: auth.uid() = uid
|
|
410
412
|
</div>
|
|
411
413
|
<Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
|
|
412
414
|
{t("studio_policy_help_step4_example")}
|
|
@@ -421,7 +423,7 @@ export const PolicyEditor = ({
|
|
|
421
423
|
<ul className="list-disc pl-5 space-y-2 text-sm text-text-secondary dark:text-text-secondary-dark font-normal">
|
|
422
424
|
<li>
|
|
423
425
|
<code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">auth.uid()</code>
|
|
424
|
-
<span className="block mt-0.5">Returns the current user's ID as text. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">auth.uid() =
|
|
426
|
+
<span className="block mt-0.5">Returns the current user's ID as text. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">auth.uid() = uid</code></span>
|
|
425
427
|
</li>
|
|
426
428
|
<li>
|
|
427
429
|
<code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">auth.jwt()</code>
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/app";
|
|
29
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,28 +389,54 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
|
387
389
|
status: "live" };
|
|
388
390
|
});
|
|
389
391
|
|
|
390
|
-
// Merge code-based policies
|
|
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.
|
|
391
398
|
if (activeCollection && isPostgresCollectionConfig(activeCollection) && activeCollection.securityRules) {
|
|
392
399
|
activeCollection.securityRules.forEach((rule) => {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
400
|
+
const ops = getPolicyOperations(rule);
|
|
401
|
+
const policyNames = getPolicyNamesForRule(rule, activeTableData.tableName);
|
|
402
|
+
|
|
403
|
+
policyNames.forEach((policyName, opIdx) => {
|
|
404
|
+
policiesMap[policyName] = {
|
|
405
|
+
policyname: policyName,
|
|
406
|
+
tablename: activeTableData.tableName,
|
|
407
|
+
permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
|
|
408
|
+
cmd: (ops[opIdx] ?? rule.operation ?? "ALL").toUpperCase() as PostgresPolicy["cmd"],
|
|
409
|
+
roles: [...(rule.roles ?? ["public"])],
|
|
410
|
+
qual: rule.using || null,
|
|
411
|
+
with_check: rule.withCheck || null,
|
|
412
|
+
// "both" = defined in code and live in Postgres (potentially edited)
|
|
413
|
+
status: policiesMap[policyName] ? "both" : "code_only"
|
|
414
|
+
};
|
|
415
|
+
});
|
|
407
416
|
});
|
|
408
417
|
}
|
|
409
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
|
+
|
|
410
438
|
return Object.values(policiesMap).sort((a, b) => a.policyname.localeCompare(b.policyname));
|
|
411
|
-
}, [activeTableData, activeCollection]);
|
|
439
|
+
}, [activeTableData, activeCollection, collectionRegistry.collections]);
|
|
412
440
|
|
|
413
441
|
// Stats for the info tab
|
|
414
442
|
const rlsStats = useMemo(() => {
|
|
@@ -816,8 +844,10 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
816
844
|
</Typography>
|
|
817
845
|
<Typography variant="caption" className="opacity-80">
|
|
818
846
|
This is an auto-generated junction table for a many-to-many relation.
|
|
819
|
-
|
|
820
|
-
|
|
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.
|
|
821
851
|
</Typography>
|
|
822
852
|
</div>
|
|
823
853
|
</div>
|
|
@@ -49,17 +49,9 @@ import { parseFirst } from "pgsql-ast-parser";
|
|
|
49
49
|
import { determineTableAndPK, resolveQueryCollections, ResolvedQueryCollection } from "../../utils/sql_utils";
|
|
50
50
|
import { ExplainVisualizer } from "./ExplainVisualizer";
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
isPrimaryKey: boolean;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface TableInfo {
|
|
59
|
-
schemaName: string;
|
|
60
|
-
tableName: string;
|
|
61
|
-
columns: SQLEditorColumnInfo[];
|
|
62
|
-
}
|
|
52
|
+
import type { SQLEditorColumnInfo, TableInfo } from "./sql_editor_types";
|
|
53
|
+
|
|
54
|
+
export type { SQLEditorColumnInfo, TableInfo };
|
|
63
55
|
|
|
64
56
|
const QueryLoadingView = () => {
|
|
65
57
|
const [elapsed, setElapsed] = useState(0);
|
|
@@ -3,7 +3,7 @@ import React, { useState } from "react";
|
|
|
3
3
|
import { cls, defaultBorderMixin, IconButton, iconSize, Tab, Tabs, Trash2Icon, Typography } from "@rebasepro/ui";
|
|
4
4
|
import { useTranslation } from "@rebasepro/app";
|
|
5
5
|
import { SchemaBrowser } from "./SchemaBrowser";
|
|
6
|
-
import { TableInfo } from "./
|
|
6
|
+
import type { TableInfo } from "./sql_editor_types";
|
|
7
7
|
|
|
8
8
|
export interface Snippet {
|
|
9
9
|
id: string;
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
RefreshCwIcon,
|
|
15
15
|
Typography
|
|
16
16
|
} from "@rebasepro/ui";
|
|
17
|
-
import { TableInfo } from "./
|
|
17
|
+
import type { TableInfo } from "./sql_editor_types";
|
|
18
18
|
import { ErrorView, useTranslation } from "@rebasepro/app";
|
|
19
19
|
|
|
20
20
|
export const SchemaBrowser = ({
|
|
@@ -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>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes describing the introspected schema the SQL editor works against.
|
|
3
|
+
* They live here rather than in SQLEditor.tsx because the sidebar, the schema
|
|
4
|
+
* browser and utils/sql_utils all need them, and importing them from the
|
|
5
|
+
* component would close a cycle back through it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SQLEditorColumnInfo {
|
|
9
|
+
name: string;
|
|
10
|
+
dataType: string;
|
|
11
|
+
isPrimaryKey: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TableInfo {
|
|
15
|
+
schemaName: string;
|
|
16
|
+
tableName: string;
|
|
17
|
+
columns: SQLEditorColumnInfo[];
|
|
18
|
+
}
|
|
@@ -543,7 +543,8 @@ duration: 400 }
|
|
|
543
543
|
/>
|
|
544
544
|
<Controls
|
|
545
545
|
showInteractive={false}
|
|
546
|
-
|
|
546
|
+
// mb clears the legend overlay pinned at bottom-left
|
|
547
|
+
className="!mb-16 !bg-white dark:!bg-surface-900 !border !border-surface-200/40 dark:!border-surface-700/40 !shadow-sm !rounded-lg dark:[--xy-controls-button-background-color:var(--color-surface-900)] dark:[--xy-controls-button-background-color-hover:var(--color-surface-800)] dark:[--xy-controls-button-color:var(--color-surface-300)] dark:[--xy-controls-button-color-hover:var(--color-surface-50)] dark:[--xy-controls-button-border-color:var(--color-surface-700)]"
|
|
547
548
|
/>
|
|
548
549
|
<MiniMap
|
|
549
550
|
nodeStrokeColor={(n) => {
|