@rebasepro/studio 0.6.1 → 0.8.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 (37) 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-Ca4XYGRp.js} +7 -7
  6. package/dist/JSEditor-Ca4XYGRp.js.map +1 -0
  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/{StorageView-CvrnHmDG.js → StorageView-BMhD29YO.js} +33 -5
  16. package/dist/StorageView-BMhD29YO.js.map +1 -0
  17. package/dist/components/ApiKeys/ApiKeysView.d.ts +2 -0
  18. package/dist/index.es.js +141 -94
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/index.umd.js +1221 -317
  21. package/dist/index.umd.js.map +1 -1
  22. package/package.json +8 -8
  23. package/src/components/ApiKeys/ApiKeysView.tsx +580 -0
  24. package/src/components/Branches/BranchesView.tsx +3 -4
  25. package/src/components/JSEditor/JSEditorSidebar.tsx +3 -3
  26. package/src/components/JSEditor/JSMonacoEditor.tsx +3 -3
  27. package/src/components/LogsExplorer/LogsExplorer.tsx +46 -84
  28. package/src/components/RLSEditor/RLSEditor.tsx +354 -117
  29. package/src/components/RebaseStudio.tsx +10 -1
  30. package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
  31. package/src/components/StorageView/StorageView.tsx +43 -4
  32. package/src/components/StudioHomePage.tsx +144 -85
  33. package/src/utils/pgColumnToProperty.ts +2 -2
  34. package/dist/JSEditor-BhAbEjCP.js.map +0 -1
  35. package/dist/LogsExplorer-CqtKILj8.js.map +0 -1
  36. package/dist/RLSEditor-DpF1u9EC.js.map +0 -1
  37. package/dist/StorageView-CvrnHmDG.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 [expandedSchemas, setExpandedSchemas] = useState<Record<string, boolean>>({ public: true });
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
- setSelectedTable(`${sortedTables[0].schemaName}.${sortedTables[0].tableName}`);
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
- const groupedTables = useMemo(() => {
217
- const groups: Record<string, TableRLSStatus[]> = {};
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
- if (!groups[table.schemaName]) {
220
- groups[table.schemaName] = [];
221
- }
222
- groups[table.schemaName].push(table);
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
- ) : Object.keys(groupedTables).length === 0 ? (
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
- Object.entries(groupedTables).map(([schemaName, schemaTables]) => (
344
- <div key={schemaName} className="mb-2">
345
- <div
346
- className="flex items-center p-1 cursor-pointer hover:bg-surface-100 dark:hover:bg-surface-950 rounded transition-colors"
347
- onClick={() => setExpandedSchemas(prev => ({ ...prev,
348
- [schemaName]: !prev[schemaName] }))}
349
- >
350
- <svg className={cls("w-3 h-3 mr-1 transition-transform", expandedSchemas[schemaName] ? "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>
351
- <Typography variant="body2" className="text-text-primary dark:text-text-primary-dark font-medium text-xs truncate flex-grow">{schemaName}</Typography>
352
- </div>
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
- {expandedSchemas[schemaName] && (
355
- <div className="ml-3 mt-1 space-y-0.5">
356
- {schemaTables.map(table => {
357
- const key = `${table.schemaName}.${table.tableName}`;
358
- const isSelected = selectedTable === key;
359
- return (
360
- <div
361
- key={key}
362
- onClick={() => setSelectedTable(key)}
363
- className={cls(
364
- "flex items-center p-1 cursor-pointer rounded transition-colors group relative",
365
- isSelected
366
- ? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light"
367
- : "hover:bg-surface-100 dark:hover:bg-surface-950 text-text-secondary dark:text-text-secondary-dark"
368
- )}
369
- >
370
- <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>
371
- <Typography variant="body2" className="text-xs truncate flex-1 min-w-0">{table.tableName}</Typography>
372
- <div className="flex items-center gap-1.5 shrink-0 ml-2">
373
- {table.rlsEnabled ? (
374
- <Tooltip title={t("studio_rls_enabled")}>
375
- <div className="w-1.5 h-1.5 rounded-full bg-green-500"/>
376
- </Tooltip>
377
- ) : (
378
- <Tooltip title={t("studio_rls_disabled")}>
379
- <div className="w-1.5 h-1.5 rounded-full bg-orange-400 opacity-50"/>
380
- </Tooltip>
381
- )}
382
- <span className="text-[10px] opacity-40 group-hover:opacity-100 min-w-[1.2rem] text-right font-medium">
383
- {table.policies.length}
384
- </span>
385
- </div>
386
- </div>
387
- );
388
- })}
389
- </div>
390
- )}
391
- </div>
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 ${sanitizeSqlIdentifier(table)} ${action} ROW LEVEL SECURITY`);
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 (!activeCollection) return;
562
- const rule: Record<string, unknown> = {
563
- name: newPolicy.policyname,
564
- operation: newPolicy.cmd?.toLowerCase(),
565
- mode: newPolicy.permissive?.toLowerCase(),
566
- using: newPolicy.qual || undefined,
567
- withCheck: newPolicy.with_check || undefined,
568
- roles: newPolicy.roles
569
- };
570
-
571
- const existingRules = (isPostgresCollection(activeCollection) ? activeCollection.securityRules : undefined) || [];
572
- let newRules;
573
- if (editingPolicy === "new") {
574
- newRules = [...existingRules, rule];
575
- } else {
576
- newRules = existingRules.map((r: { name?: string }) => r.name === editingPolicy.policyname ? rule : r);
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
- try {
580
- const response = await fetch(`${apiUrl}/api/schema-editor/collection/save`, {
581
- method: "POST",
582
- headers: { "Content-Type": "application/json" },
583
- body: JSON.stringify({
584
- 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,
585
- collectionData: { securityRules: newRules }
586
- })
587
- });
588
- if (!response.ok) throw new Error("Failed to save policy");
589
-
590
- snackbarController.open({ type: "success",
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
- setEditingPolicy(null);
593
- fetchRLSData();
594
- } catch (e: unknown) {
595
- snackbarController.open({ type: "error",
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
- {activeTableData && !activeCollection && (
606
- <Alert
607
- color="warning"
608
- >
609
- <Typography variant="body2" className="mb-1">
610
- Table not managed by Rebase
611
- </Typography>
612
- <Typography variant="caption" className="opacity-80">
613
- This table is not mapped to a Rebase Schema via code. To edit security policies visually, you must first import this table into a Schema configuration file.
614
- </Typography>
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)} disabled={!activeCollection}>
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 ${sanitizeSqlIdentifier(table)}`);
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
- {activeCollection && (
758
- <Button
759
- size="small"
760
- variant="filled"
761
- color="primary"
762
- onClick={() => setEditingPolicy("new")}
763
- >
764
- {t("studio_rls_create_policy")}
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