@rebasepro/studio 0.6.0 → 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.
- package/dist/ApiKeysView-D-_FSlNL.js +684 -0
- package/dist/ApiKeysView-D-_FSlNL.js.map +1 -0
- package/dist/{BranchesView-DncIRcZt.js → BranchesView-Dlg78EQ8.js} +4 -5
- package/dist/{BranchesView-DncIRcZt.js.map → BranchesView-Dlg78EQ8.js.map} +1 -1
- package/dist/{JSEditor-BhAbEjCP.js → JSEditor-DSucz6wV.js} +4 -4
- package/dist/{JSEditor-BhAbEjCP.js.map → JSEditor-DSucz6wV.js.map} +1 -1
- package/dist/{LogsExplorer-CqtKILj8.js → LogsExplorer-J4xfsuv3.js} +49 -83
- package/dist/LogsExplorer-J4xfsuv3.js.map +1 -0
- package/dist/{RLSEditor-CTxYbBdW.js → RLSEditor-BM64laoW.js} +483 -289
- package/dist/RLSEditor-BM64laoW.js.map +1 -0
- package/dist/{SQLEditor-BLuq_zDM.js → SQLEditor-CuAhR-zr.js} +4 -4
- package/dist/{SQLEditor-BLuq_zDM.js.map → SQLEditor-CuAhR-zr.js.map} +1 -1
- package/dist/{SchemaVisualizer-BJK2u3C0.js → SchemaVisualizer-OibKoD3g.js} +2 -2
- package/dist/{SchemaVisualizer-BJK2u3C0.js.map → SchemaVisualizer-OibKoD3g.js.map} +1 -1
- package/dist/{StorageView-nDaC2foF.js → StorageView-CvrnHmDG.js} +89 -76
- package/dist/StorageView-CvrnHmDG.js.map +1 -0
- package/dist/components/ApiKeys/ApiKeysView.d.ts +2 -0
- package/dist/index.es.js +38 -19
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1358 -489
- package/dist/index.umd.js.map +1 -1
- package/package.json +17 -17
- package/src/components/ApiKeys/ApiKeysView.tsx +580 -0
- package/src/components/Branches/BranchesView.tsx +3 -4
- package/src/components/JSEditor/JSEditorSidebar.tsx +3 -3
- package/src/components/LogsExplorer/LogsExplorer.tsx +46 -84
- package/src/components/RLSEditor/PolicyEditor.tsx +1 -0
- package/src/components/RLSEditor/RLSEditor.tsx +354 -117
- package/src/components/RebaseStudio.tsx +10 -1
- package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
- package/src/components/StorageView/StorageView.tsx +3 -3
- package/src/components/StudioHomePage.tsx +5 -1
- package/dist/LogsExplorer-CqtKILj8.js.map +0 -1
- package/dist/RLSEditor-CTxYbBdW.js.map +0 -1
- package/dist/StorageView-nDaC2foF.js.map +0 -1
|
@@ -5,14 +5,16 @@ import {
|
|
|
5
5
|
Alert,
|
|
6
6
|
AlertTriangleIcon,
|
|
7
7
|
Button,
|
|
8
|
-
Card,
|
|
9
8
|
Chip,
|
|
10
9
|
CircularProgress,
|
|
11
10
|
cls,
|
|
11
|
+
DatabaseIcon,
|
|
12
12
|
defaultBorderMixin,
|
|
13
13
|
IconButton,
|
|
14
14
|
iconSize,
|
|
15
15
|
KeyIcon,
|
|
16
|
+
Link2Icon,
|
|
17
|
+
LockIcon,
|
|
16
18
|
Paper,
|
|
17
19
|
RefreshCwIcon,
|
|
18
20
|
ResizablePanels,
|
|
@@ -25,8 +27,28 @@ import {
|
|
|
25
27
|
} from "@rebasepro/ui";
|
|
26
28
|
import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/core";
|
|
27
29
|
import { isPostgresCollection } from "@rebasepro/types";
|
|
30
|
+
import { REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_PREFIXES, JUNCTION_TABLES_SQL } from "@rebasepro/common";
|
|
28
31
|
import { PolicyEditor } from "./PolicyEditor";
|
|
29
32
|
|
|
33
|
+
type TableCategory = "collection" | "junction" | "internal" | "other";
|
|
34
|
+
|
|
35
|
+
function classifyTableClient(
|
|
36
|
+
tableName: string,
|
|
37
|
+
schemaName: string,
|
|
38
|
+
junctionTableNames: Set<string>,
|
|
39
|
+
isMappedToCollection: boolean
|
|
40
|
+
): TableCategory {
|
|
41
|
+
if (
|
|
42
|
+
REBASE_INTERNAL_SCHEMAS.includes(schemaName) ||
|
|
43
|
+
REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))
|
|
44
|
+
) {
|
|
45
|
+
return "internal";
|
|
46
|
+
}
|
|
47
|
+
if (isMappedToCollection) return "collection";
|
|
48
|
+
if (junctionTableNames.has(tableName)) return "junction";
|
|
49
|
+
return "other";
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
/**
|
|
31
53
|
* Validates and double-quotes a SQL identifier to prevent injection.
|
|
32
54
|
* Only allows safe Postgres identifiers (letters, digits, underscores).
|
|
@@ -57,6 +79,82 @@ interface TableRLSStatus {
|
|
|
57
79
|
policies: PostgresPolicy[];
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
// ─── Sidebar helper components ──────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function SidebarSection({ title, icon, expanded, onToggle, count, children }: {
|
|
85
|
+
title: string;
|
|
86
|
+
icon: React.ReactNode;
|
|
87
|
+
expanded: boolean;
|
|
88
|
+
onToggle: () => void;
|
|
89
|
+
count?: number;
|
|
90
|
+
children: React.ReactNode;
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="mb-2">
|
|
94
|
+
<div
|
|
95
|
+
className="flex items-center p-1.5 cursor-pointer hover:bg-surface-100 dark:hover:bg-surface-900 rounded transition-colors"
|
|
96
|
+
onClick={onToggle}
|
|
97
|
+
>
|
|
98
|
+
<svg className={cls("w-3 h-3 mr-1.5 transition-transform text-text-disabled dark:text-text-disabled-dark", expanded ? "rotate-90" : "")} fill="currentColor" viewBox="0 0 20 20"><path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"/></svg>
|
|
99
|
+
{icon}
|
|
100
|
+
<Typography variant="body2" className="text-text-primary dark:text-text-primary-dark font-medium text-xs truncate flex-grow ml-1.5">{title}</Typography>
|
|
101
|
+
{count !== undefined && (
|
|
102
|
+
<span className="text-[10px] text-text-disabled dark:text-text-disabled-dark font-medium tabular-nums mr-1">{count}</span>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
{expanded && (
|
|
106
|
+
<div className="ml-3 mt-0.5 space-y-0.5">
|
|
107
|
+
{children}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function SidebarTableRow({ table, isSelected, onSelect, badge, dimmed, t }: {
|
|
115
|
+
table: TableRLSStatus;
|
|
116
|
+
isSelected: boolean;
|
|
117
|
+
onSelect: () => void;
|
|
118
|
+
badge?: string;
|
|
119
|
+
dimmed?: boolean;
|
|
120
|
+
t: (key: string) => string;
|
|
121
|
+
}) {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
onClick={onSelect}
|
|
125
|
+
className={cls(
|
|
126
|
+
"flex items-center p-1 cursor-pointer rounded transition-colors group relative",
|
|
127
|
+
isSelected
|
|
128
|
+
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light"
|
|
129
|
+
: "hover:bg-surface-100 dark:hover:bg-surface-900 text-text-secondary dark:text-text-secondary-dark",
|
|
130
|
+
dimmed && !isSelected && "opacity-60"
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<svg className="w-3.5 h-3.5 mr-1 shrink-0 text-text-disabled dark:text-text-disabled-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
|
134
|
+
<Typography variant="body2" className="text-xs truncate flex-1 min-w-0">{table.tableName}</Typography>
|
|
135
|
+
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
|
136
|
+
{badge && (
|
|
137
|
+
<span className="text-[9px] uppercase tracking-wider font-semibold text-text-disabled dark:text-text-disabled-dark bg-surface-200 dark:bg-surface-800 rounded px-1 py-px">
|
|
138
|
+
{badge}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
{table.rlsEnabled ? (
|
|
142
|
+
<Tooltip title={t("studio_rls_enabled")}>
|
|
143
|
+
<div className="w-1.5 h-1.5 rounded-full bg-green-500"/>
|
|
144
|
+
</Tooltip>
|
|
145
|
+
) : (
|
|
146
|
+
<Tooltip title={t("studio_rls_disabled")}>
|
|
147
|
+
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 opacity-50"/>
|
|
148
|
+
</Tooltip>
|
|
149
|
+
)}
|
|
150
|
+
<span className="text-[10px] opacity-40 group-hover:opacity-100 min-w-[1.2rem] text-right font-medium">
|
|
151
|
+
{table.policies.length}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
60
158
|
export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
61
159
|
const { databaseAdmin } = useRebaseContext();
|
|
62
160
|
const snackbarController = useSnackbarController();
|
|
@@ -68,6 +166,7 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
|
68
166
|
const [tables, setTables] = useState<TableRLSStatus[]>([]);
|
|
69
167
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
|
70
168
|
const [activeTab, setActiveTab] = useState(0);
|
|
169
|
+
const [junctionTableNames, setJunctionTableNames] = useState<Set<string>>(new Set());
|
|
71
170
|
|
|
72
171
|
const [editingPolicy, setEditingPolicy] = useState<PostgresPolicy | "new" | null>(null);
|
|
73
172
|
|
|
@@ -80,7 +179,11 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
|
80
179
|
}
|
|
81
180
|
});
|
|
82
181
|
|
|
83
|
-
const [
|
|
182
|
+
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
|
183
|
+
collection: true,
|
|
184
|
+
other: true,
|
|
185
|
+
internal: false
|
|
186
|
+
});
|
|
84
187
|
|
|
85
188
|
// Sidebar tab: "tables" or "info"
|
|
86
189
|
const [sidebarTab, setSidebarTab] = useState<"tables" | "info">("tables");
|
|
@@ -188,8 +291,25 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
|
188
291
|
const sortedTables = Object.values(tableMap).sort((a, b) => a.tableName.localeCompare(b.tableName));
|
|
189
292
|
setTables(sortedTables);
|
|
190
293
|
|
|
294
|
+
// Detect junction tables (where all columns are FKs)
|
|
295
|
+
try {
|
|
296
|
+
const junctionResult = await databaseAdmin!.executeSql!(JUNCTION_TABLES_SQL);
|
|
297
|
+
const jRows = extractRows(junctionResult);
|
|
298
|
+
setJunctionTableNames(new Set(
|
|
299
|
+
jRows.map((r: Record<string, unknown>) => r.table_name as string).filter(Boolean)
|
|
300
|
+
));
|
|
301
|
+
} catch {
|
|
302
|
+
// Junction detection is best-effort
|
|
303
|
+
}
|
|
304
|
+
|
|
191
305
|
if (sortedTables.length > 0 && !selectedTable) {
|
|
192
|
-
|
|
306
|
+
// Auto-select first public/collection table, skip internal
|
|
307
|
+
const firstUserTable = sortedTables.find(t => !REBASE_INTERNAL_SCHEMAS.includes(t.schemaName));
|
|
308
|
+
if (firstUserTable) {
|
|
309
|
+
setSelectedTable(`${firstUserTable.schemaName}.${firstUserTable.tableName}`);
|
|
310
|
+
} else {
|
|
311
|
+
setSelectedTable(`${sortedTables[0].schemaName}.${sortedTables[0].tableName}`);
|
|
312
|
+
}
|
|
193
313
|
}
|
|
194
314
|
|
|
195
315
|
} catch (e: unknown) {
|
|
@@ -213,16 +333,30 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
|
213
333
|
return tables.find(t => `${t.schemaName}.${t.tableName}` === selectedTable) || null;
|
|
214
334
|
}, [selectedTable, tables]);
|
|
215
335
|
|
|
216
|
-
|
|
217
|
-
|
|
336
|
+
/** Categorize tables into 4 buckets for the sidebar. */
|
|
337
|
+
const categorizedTables = useMemo(() => {
|
|
338
|
+
const groups: Record<TableCategory, TableRLSStatus[]> = {
|
|
339
|
+
collection: [],
|
|
340
|
+
junction: [],
|
|
341
|
+
internal: [],
|
|
342
|
+
other: []
|
|
343
|
+
};
|
|
344
|
+
|
|
218
345
|
tables.forEach(table => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
346
|
+
const isMapped = !!collectionRegistry.collections?.find(
|
|
347
|
+
(c: { id?: string, path?: string, table?: string, slug?: string, collectionId?: string }) =>
|
|
348
|
+
c.id === table.tableName ||
|
|
349
|
+
c.path === table.tableName ||
|
|
350
|
+
c.table === table.tableName ||
|
|
351
|
+
c.slug === table.tableName ||
|
|
352
|
+
c.collectionId === table.tableName
|
|
353
|
+
);
|
|
354
|
+
const cat = classifyTableClient(table.tableName, table.schemaName, junctionTableNames, isMapped);
|
|
355
|
+
groups[cat].push(table);
|
|
223
356
|
});
|
|
357
|
+
|
|
224
358
|
return groups;
|
|
225
|
-
}, [tables]);
|
|
359
|
+
}, [tables, junctionTableNames, collectionRegistry.collections]);
|
|
226
360
|
|
|
227
361
|
const activeCollection = useMemo(() => {
|
|
228
362
|
if (!activeTableData) return null;
|
|
@@ -235,6 +369,13 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
|
|
|
235
369
|
) || null;
|
|
236
370
|
}, [activeTableData, collectionRegistry.collections]);
|
|
237
371
|
|
|
372
|
+
/** The category of the currently selected table. */
|
|
373
|
+
const activeTableCategory = useMemo((): TableCategory | null => {
|
|
374
|
+
if (!activeTableData) return null;
|
|
375
|
+
const isMapped = !!activeCollection;
|
|
376
|
+
return classifyTableClient(activeTableData.tableName, activeTableData.schemaName, junctionTableNames, isMapped);
|
|
377
|
+
}, [activeTableData, activeCollection, junctionTableNames]);
|
|
378
|
+
|
|
238
379
|
const mergedPolicies = useMemo(() => {
|
|
239
380
|
if (!activeTableData) return [];
|
|
240
381
|
|
|
@@ -335,61 +476,85 @@ totalPolicies };
|
|
|
335
476
|
<div className="flex-grow overflow-y-auto no-scrollbar p-1">
|
|
336
477
|
{isLoading && tables.length === 0 ? (
|
|
337
478
|
<div className="flex justify-center p-4"><CircularProgress size="small"/></div>
|
|
338
|
-
) :
|
|
479
|
+
) : tables.length === 0 ? (
|
|
339
480
|
<div className="p-4 text-center">
|
|
340
481
|
<Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark italic">{t("studio_rls_no_tables")}</Typography>
|
|
341
482
|
</div>
|
|
342
483
|
) : (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
484
|
+
<>
|
|
485
|
+
{/* ── Schema Collections ── */}
|
|
486
|
+
{categorizedTables.collection.length > 0 && (
|
|
487
|
+
<SidebarSection
|
|
488
|
+
title="Schema Collections"
|
|
489
|
+
icon={<DatabaseIcon size={12} className="text-primary dark:text-primary-light"/>}
|
|
490
|
+
expanded={expandedSections.collection ?? true}
|
|
491
|
+
onToggle={() => setExpandedSections(prev => ({ ...prev, collection: !(prev.collection ?? true) }))}
|
|
492
|
+
>
|
|
493
|
+
{categorizedTables.collection.map(table => (
|
|
494
|
+
<SidebarTableRow
|
|
495
|
+
key={`${table.schemaName}.${table.tableName}`}
|
|
496
|
+
table={table}
|
|
497
|
+
isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
|
|
498
|
+
onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
|
|
499
|
+
t={t}
|
|
500
|
+
/>
|
|
501
|
+
))}
|
|
502
|
+
</SidebarSection>
|
|
503
|
+
)}
|
|
353
504
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
505
|
+
{/* ── Other Tables (unmapped + junction) ── */}
|
|
506
|
+
{(categorizedTables.other.length > 0 || categorizedTables.junction.length > 0) && (
|
|
507
|
+
<SidebarSection
|
|
508
|
+
title="Other Tables"
|
|
509
|
+
icon={<AlertTriangleIcon size={12} className="text-yellow-500 dark:text-yellow-400"/>}
|
|
510
|
+
expanded={expandedSections.other ?? true}
|
|
511
|
+
onToggle={() => setExpandedSections(prev => ({ ...prev, other: !(prev.other ?? true) }))}
|
|
512
|
+
>
|
|
513
|
+
{categorizedTables.other.map(table => (
|
|
514
|
+
<SidebarTableRow
|
|
515
|
+
key={`${table.schemaName}.${table.tableName}`}
|
|
516
|
+
table={table}
|
|
517
|
+
isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
|
|
518
|
+
onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
|
|
519
|
+
t={t}
|
|
520
|
+
/>
|
|
521
|
+
))}
|
|
522
|
+
{categorizedTables.junction.map(table => (
|
|
523
|
+
<SidebarTableRow
|
|
524
|
+
key={`${table.schemaName}.${table.tableName}`}
|
|
525
|
+
table={table}
|
|
526
|
+
isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
|
|
527
|
+
onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
|
|
528
|
+
badge="Junction"
|
|
529
|
+
dimmed
|
|
530
|
+
t={t}
|
|
531
|
+
/>
|
|
532
|
+
))}
|
|
533
|
+
</SidebarSection>
|
|
534
|
+
)}
|
|
535
|
+
|
|
536
|
+
{/* ── Rebase Internal ── */}
|
|
537
|
+
{categorizedTables.internal.length > 0 && (
|
|
538
|
+
<SidebarSection
|
|
539
|
+
title="Rebase Internal"
|
|
540
|
+
icon={<LockIcon size={12} className="text-text-disabled dark:text-text-disabled-dark"/>}
|
|
541
|
+
expanded={expandedSections.internal ?? false}
|
|
542
|
+
onToggle={() => setExpandedSections(prev => ({ ...prev, internal: !(prev.internal ?? false) }))}
|
|
543
|
+
count={categorizedTables.internal.length}
|
|
544
|
+
>
|
|
545
|
+
{categorizedTables.internal.map(table => (
|
|
546
|
+
<SidebarTableRow
|
|
547
|
+
key={`${table.schemaName}.${table.tableName}`}
|
|
548
|
+
table={table}
|
|
549
|
+
isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
|
|
550
|
+
onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
|
|
551
|
+
dimmed
|
|
552
|
+
t={t}
|
|
553
|
+
/>
|
|
554
|
+
))}
|
|
555
|
+
</SidebarSection>
|
|
556
|
+
)}
|
|
557
|
+
</>
|
|
393
558
|
)}
|
|
394
559
|
</div>
|
|
395
560
|
</div>
|
|
@@ -495,10 +660,11 @@ totalPolicies };
|
|
|
495
660
|
size="small"
|
|
496
661
|
onClick={async () => {
|
|
497
662
|
const table = activeTableData.tableName;
|
|
663
|
+
const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData.schemaName)}.${sanitizeSqlIdentifier(table)}`;
|
|
498
664
|
const action = activeTableData.rlsEnabled ? "DISABLE" : "ENABLE";
|
|
499
665
|
if (!confirm(`Are you sure you want to ${action.toLowerCase()} Row Level Security on "${table}"?`)) return;
|
|
500
666
|
try {
|
|
501
|
-
await databaseAdmin!.executeSql!(`ALTER TABLE ${
|
|
667
|
+
await databaseAdmin!.executeSql!(`ALTER TABLE ${qualifiedTable} ${action} ROW LEVEL SECURITY`);
|
|
502
668
|
snackbarController.open({ type: "success",
|
|
503
669
|
message: `RLS ${action.toLowerCase()}d on ${table}` });
|
|
504
670
|
fetchRLSData();
|
|
@@ -527,7 +693,6 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
527
693
|
<Button
|
|
528
694
|
size="small"
|
|
529
695
|
color="primary"
|
|
530
|
-
disabled={!activeCollection}
|
|
531
696
|
onClick={() => setEditingPolicy("new")}
|
|
532
697
|
>
|
|
533
698
|
{t("studio_rls_create_policy")}
|
|
@@ -558,42 +723,77 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
558
723
|
schema={activeTableData.schemaName}
|
|
559
724
|
table={activeTableData.tableName}
|
|
560
725
|
onSave={async (newPolicy) => {
|
|
561
|
-
if (
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
726
|
+
if (activeCollection) {
|
|
727
|
+
// Collection-mapped table: save via schema-editor API
|
|
728
|
+
const rule: Record<string, unknown> = {
|
|
729
|
+
name: newPolicy.policyname,
|
|
730
|
+
operation: newPolicy.cmd?.toLowerCase(),
|
|
731
|
+
mode: newPolicy.permissive?.toLowerCase(),
|
|
732
|
+
using: newPolicy.qual || undefined,
|
|
733
|
+
withCheck: newPolicy.with_check || undefined,
|
|
734
|
+
roles: newPolicy.roles
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const existingRules = (isPostgresCollection(activeCollection) ? activeCollection.securityRules : undefined) || [];
|
|
738
|
+
let newRules;
|
|
739
|
+
if (editingPolicy === "new") {
|
|
740
|
+
newRules = [...existingRules, rule];
|
|
741
|
+
} else {
|
|
742
|
+
newRules = existingRules.map((r: { name?: string }) => r.name === editingPolicy.policyname ? rule : r);
|
|
743
|
+
}
|
|
578
744
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
745
|
+
try {
|
|
746
|
+
const response = await fetch(`${apiUrl}/api/schema-editor/collection/save`, {
|
|
747
|
+
method: "POST",
|
|
748
|
+
headers: { "Content-Type": "application/json" },
|
|
749
|
+
body: JSON.stringify({
|
|
750
|
+
collectionId: (activeCollection as { id?: string, path?: string, alias?: string }).id || (activeCollection as { id?: string, path?: string, alias?: string }).path || (activeCollection as { id?: string, path?: string, alias?: string }).alias || activeTableData.tableName,
|
|
751
|
+
collectionData: { securityRules: newRules }
|
|
752
|
+
})
|
|
753
|
+
});
|
|
754
|
+
if (!response.ok) throw new Error("Failed to save policy");
|
|
755
|
+
|
|
756
|
+
snackbarController.open({ type: "success",
|
|
591
757
|
message: "Policy saved successfully" });
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
758
|
+
setEditingPolicy(null);
|
|
759
|
+
fetchRLSData();
|
|
760
|
+
} catch (e: unknown) {
|
|
761
|
+
snackbarController.open({ type: "error",
|
|
762
|
+
message: e instanceof Error ? e.message : String(e) });
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
// Non-collection table (internal/junction/unmapped): apply policy directly via SQL
|
|
766
|
+
try {
|
|
767
|
+
const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData.schemaName)}.${sanitizeSqlIdentifier(activeTableData.tableName)}`;
|
|
768
|
+
const policyName = sanitizeSqlIdentifier(newPolicy.policyname || "unnamed_policy");
|
|
769
|
+
const cmd = newPolicy.cmd || "ALL";
|
|
770
|
+
const permissive = (newPolicy.permissive || "PERMISSIVE") === "PERMISSIVE" ? "PERMISSIVE" : "RESTRICTIVE";
|
|
771
|
+
const roles = newPolicy.roles && newPolicy.roles.length > 0
|
|
772
|
+
? newPolicy.roles.map(r => sanitizeSqlIdentifier(r)).join(", ")
|
|
773
|
+
: "public";
|
|
774
|
+
|
|
775
|
+
// Drop existing policy if editing
|
|
776
|
+
if (editingPolicy !== "new") {
|
|
777
|
+
await databaseAdmin!.executeSql!(`DROP POLICY IF EXISTS ${policyName} ON ${qualifiedTable}`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
let sql = `CREATE POLICY ${policyName} ON ${qualifiedTable}`;
|
|
781
|
+
sql += ` AS ${permissive}`;
|
|
782
|
+
sql += ` FOR ${cmd}`;
|
|
783
|
+
sql += ` TO ${roles}`;
|
|
784
|
+
if (newPolicy.qual) sql += ` USING (${newPolicy.qual})`;
|
|
785
|
+
if (newPolicy.with_check) sql += ` WITH CHECK (${newPolicy.with_check})`;
|
|
786
|
+
|
|
787
|
+
await databaseAdmin!.executeSql!(sql);
|
|
788
|
+
|
|
789
|
+
snackbarController.open({ type: "success",
|
|
790
|
+
message: `Policy "${newPolicy.policyname}" applied directly via SQL` });
|
|
791
|
+
setEditingPolicy(null);
|
|
792
|
+
fetchRLSData();
|
|
793
|
+
} catch (e: unknown) {
|
|
794
|
+
snackbarController.open({ type: "error",
|
|
596
795
|
message: e instanceof Error ? e.message : String(e) });
|
|
796
|
+
}
|
|
597
797
|
}
|
|
598
798
|
}}
|
|
599
799
|
onCancel={() => setEditingPolicy(null)}
|
|
@@ -602,16 +802,54 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
602
802
|
<div className="flex-grow flex flex-col overflow-hidden">
|
|
603
803
|
<div className="p-6 pt-4 flex-grow overflow-auto bg-surface-50 dark:bg-surface-900">
|
|
604
804
|
<div className="max-w-4xl mx-auto flex flex-col gap-6">
|
|
605
|
-
{
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
805
|
+
{/* Context-aware banner based on table category */}
|
|
806
|
+
{activeTableData && activeTableCategory === "internal" && (
|
|
807
|
+
<Alert color="info">
|
|
808
|
+
<div className="flex items-start gap-2">
|
|
809
|
+
<LockIcon size={16} className="shrink-0 mt-0.5"/>
|
|
810
|
+
<div>
|
|
811
|
+
<Typography variant="body2" className="mb-1 font-semibold">
|
|
812
|
+
Rebase System Table
|
|
813
|
+
</Typography>
|
|
814
|
+
<Typography variant="caption" className="opacity-80">
|
|
815
|
+
This table is managed internally by Rebase. Its security policies are configured automatically.
|
|
816
|
+
Editing policies on system tables is an advanced operation.
|
|
817
|
+
</Typography>
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
</Alert>
|
|
821
|
+
)}
|
|
822
|
+
{activeTableData && activeTableCategory === "junction" && (
|
|
823
|
+
<Alert color="info">
|
|
824
|
+
<div className="flex items-start gap-2">
|
|
825
|
+
<Link2Icon size={16} className="shrink-0 mt-0.5"/>
|
|
826
|
+
<div>
|
|
827
|
+
<Typography variant="body2" className="mb-1 font-semibold">
|
|
828
|
+
Junction Table
|
|
829
|
+
</Typography>
|
|
830
|
+
<Typography variant="caption" className="opacity-80">
|
|
831
|
+
This is an auto-generated junction table for a many-to-many relation.
|
|
832
|
+
Its access is typically managed through the related collections' policies.
|
|
833
|
+
You can still add RLS policies directly if needed.
|
|
834
|
+
</Typography>
|
|
835
|
+
</div>
|
|
836
|
+
</div>
|
|
837
|
+
</Alert>
|
|
838
|
+
)}
|
|
839
|
+
{activeTableData && activeTableCategory === "other" && (
|
|
840
|
+
<Alert color="warning">
|
|
841
|
+
<div className="flex items-start gap-2">
|
|
842
|
+
<AlertTriangleIcon size={16} className="shrink-0 mt-0.5"/>
|
|
843
|
+
<div>
|
|
844
|
+
<Typography variant="body2" className="mb-1 font-semibold">
|
|
845
|
+
Unmapped Table
|
|
846
|
+
</Typography>
|
|
847
|
+
<Typography variant="caption" className="opacity-80">
|
|
848
|
+
This table exists in the database but isn't mapped to a collection definition.
|
|
849
|
+
Import it into a Schema configuration file to manage security policies visually.
|
|
850
|
+
</Typography>
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
615
853
|
</Alert>
|
|
616
854
|
)}
|
|
617
855
|
|
|
@@ -714,7 +952,7 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
714
952
|
Import to codebase
|
|
715
953
|
</Button>
|
|
716
954
|
)}
|
|
717
|
-
<Button size="small" variant="text" color="primary" onClick={() => setEditingPolicy(policy)}
|
|
955
|
+
<Button size="small" variant="text" color="primary" onClick={() => setEditingPolicy(policy)}>
|
|
718
956
|
{t("studio_rls_edit")}
|
|
719
957
|
</Button>
|
|
720
958
|
{policy.status !== "code_only" && (
|
|
@@ -723,9 +961,10 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
723
961
|
size="small"
|
|
724
962
|
onClick={async () => {
|
|
725
963
|
const table = activeTableData!.tableName;
|
|
964
|
+
const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData!.schemaName)}.${sanitizeSqlIdentifier(table)}`;
|
|
726
965
|
if (!confirm(`Drop policy "${policy.policyname}" from table "${table}"?`)) return;
|
|
727
966
|
try {
|
|
728
|
-
await databaseAdmin!.executeSql!(`DROP POLICY ${sanitizeSqlIdentifier(policy.policyname)} ON ${
|
|
967
|
+
await databaseAdmin!.executeSql!(`DROP POLICY ${sanitizeSqlIdentifier(policy.policyname)} ON ${qualifiedTable}`);
|
|
729
968
|
snackbarController.open({ type: "success",
|
|
730
969
|
message: `Policy "${policy.policyname}" dropped` });
|
|
731
970
|
fetchRLSData();
|
|
@@ -754,16 +993,14 @@ message: e instanceof Error ? e.message : String(e) });
|
|
|
754
993
|
<Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark max-w-sm mb-4">
|
|
755
994
|
RLS is enabled on this table but no policies exist. All access is denied by default (Postgres deny-all). Create a policy to allow specific access.
|
|
756
995
|
</Typography>
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
</Button>
|
|
766
|
-
)}
|
|
996
|
+
<Button
|
|
997
|
+
size="small"
|
|
998
|
+
variant="filled"
|
|
999
|
+
color="primary"
|
|
1000
|
+
onClick={() => setEditingPolicy("new")}
|
|
1001
|
+
>
|
|
1002
|
+
{t("studio_rls_create_policy")}
|
|
1003
|
+
</Button>
|
|
767
1004
|
</div>
|
|
768
1005
|
)}
|
|
769
1006
|
|