@rebasepro/studio 0.17.3 → 0.18.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 (81) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +12 -4
  3. package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
  4. package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
  5. package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
  6. package/dist/BackupsView-DVkKzaPR.js.map +1 -0
  7. package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
  8. package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
  9. package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
  10. package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
  11. package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
  12. package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
  13. package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
  14. package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
  15. package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
  16. package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
  17. package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
  18. package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
  19. package/dist/components/RLSEditor/saveRules.d.ts +38 -0
  20. package/dist/components/RebaseStudio.d.ts +1 -1
  21. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
  22. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
  23. package/dist/components/load-failure-view.d.ts +17 -0
  24. package/dist/components/load-failure.d.ts +47 -0
  25. package/dist/index.es.js +56 -36
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/load-failure-C6OMJSGC.js +31 -0
  28. package/dist/load-failure-C6OMJSGC.js.map +1 -0
  29. package/dist/load-failure-view-CBTB_h-h.js +48 -0
  30. package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
  31. package/package.json +36 -21
  32. package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
  33. package/dist/BackupsView-DNS6LdVg.js.map +0 -1
  34. package/dist/BranchesView-CM3B-ER3.js.map +0 -1
  35. package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
  36. package/dist/RLSEditor-CBX4uD66.js.map +0 -1
  37. package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
  38. package/dist/StorageView-BGDeC5GI.js.map +0 -1
  39. package/dist/components/StorageView/storage-failure.d.ts +0 -34
  40. package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
  41. package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
  42. package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
  43. package/src/components/ApiExplorer/parseSpec.ts +0 -104
  44. package/src/components/ApiExplorer/types.ts +0 -84
  45. package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
  46. package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
  47. package/src/components/ApiKeys/permissions.ts +0 -145
  48. package/src/components/AuthSimulationSelector.tsx +0 -72
  49. package/src/components/Backups/BackupsView.tsx +0 -185
  50. package/src/components/Branches/BranchesView.tsx +0 -386
  51. package/src/components/CronJobs/CronJobsView.tsx +0 -413
  52. package/src/components/JSEditor/JSEditor.tsx +0 -1100
  53. package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
  54. package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
  55. package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
  56. package/src/components/LogsExplorer/useLogTail.ts +0 -356
  57. package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
  58. package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
  59. package/src/components/RLSEditor/index.ts +0 -1
  60. package/src/components/RLSEditor/policy-presets.ts +0 -176
  61. package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
  62. package/src/components/RebaseStudio.tsx +0 -174
  63. package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
  64. package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
  65. package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
  66. package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
  67. package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
  68. package/src/components/SQLEditor/sql_editor_types.ts +0 -18
  69. package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
  70. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
  71. package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
  72. package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
  73. package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
  74. package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
  75. package/src/components/StorageView/StorageView.tsx +0 -1588
  76. package/src/components/StorageView/storage-failure.ts +0 -63
  77. package/src/components/StudioHomePage.tsx +0 -430
  78. package/src/index.ts +0 -31
  79. package/src/utils/parseSpec.test.ts +0 -309
  80. package/src/utils/sql_utils.test.ts +0 -265
  81. package/src/utils/sql_utils.ts +0 -291
@@ -1,1128 +0,0 @@
1
-
2
- import { useStudioCollectionRegistry, useStudioCapabilities } from "@rebasepro/app";
3
- import { useApiBase, useApiConfig } from "@rebasepro/app";
4
- import React, { useState, useEffect, useCallback, useMemo } from "react";
5
- import {
6
- Alert,
7
- AlertTriangleIcon,
8
- Button,
9
- Chip,
10
- CircularProgress,
11
- cls,
12
- DatabaseIcon,
13
- defaultBorderMixin,
14
- IconButton,
15
- iconSize,
16
- KeyIcon,
17
- Link2Icon,
18
- LockIcon,
19
- Paper,
20
- RefreshCwIcon,
21
- ResizablePanels,
22
- ShieldIcon,
23
- Tab,
24
- Tabs,
25
- Tooltip,
26
- Trash2Icon,
27
- Typography
28
- } from "@rebasepro/ui";
29
- import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/app";
30
- import { isPostgresCollectionConfig } from "@rebasepro/types";
31
- import { REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_PREFIXES, JUNCTION_TABLES_SQL } from "@rebasepro/common";
32
- import { getPolicyNamesForRule, getPolicyNamesForRules, getPolicyOperations } from "@rebasepro/utils";
33
- import { resolveJunctionSpecs, getJunctionSecurityRules, getEffectiveSecurityRules } from "@rebasepro/common";
34
- import { PolicyEditor } from "./PolicyEditor";
35
- import { saveSecurityRulesToCodebase } from "./saveSecurityRules";
36
-
37
- type TableCategory = "collection" | "junction" | "internal" | "other";
38
-
39
- function classifyTableClient(
40
- tableName: string,
41
- schemaName: string,
42
- junctionTableNames: Set<string>,
43
- isMappedToCollection: boolean
44
- ): TableCategory {
45
- if (
46
- REBASE_INTERNAL_SCHEMAS.includes(schemaName) ||
47
- REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))
48
- ) {
49
- return "internal";
50
- }
51
- if (isMappedToCollection) return "collection";
52
- if (junctionTableNames.has(tableName)) return "junction";
53
- return "other";
54
- }
55
-
56
- /**
57
- * Validates and double-quotes a SQL identifier to prevent injection.
58
- * Only allows safe Postgres identifiers (letters, digits, underscores).
59
- * Throws if the identifier contains unsafe characters.
60
- */
61
- function sanitizeSqlIdentifier(name: string): string {
62
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
63
- throw new Error(`Invalid SQL identifier: "${name}". Only letters, digits, and underscores are allowed.`);
64
- }
65
- return `"${name}"`;
66
- }
67
-
68
- // Re-exported for the components that used to import it from here.
69
- export type { PostgresPolicy } from "@rebasepro/types";
70
- import type { CollectionConfig, PostgresPolicy } from "@rebasepro/types";
71
-
72
- interface TableRLSStatus {
73
- schemaName: string;
74
- tableName: string;
75
- rlsEnabled: boolean;
76
- policies: PostgresPolicy[];
77
- }
78
-
79
- // ─── Sidebar helper components ──────────────────────────────────────
80
-
81
- function SidebarSection({ title, icon, expanded, onToggle, count, children }: {
82
- title: string;
83
- icon: React.ReactNode;
84
- expanded: boolean;
85
- onToggle: () => void;
86
- count?: number;
87
- children: React.ReactNode;
88
- }) {
89
- return (
90
- <div className="mb-2">
91
- <div
92
- className="flex items-center p-1.5 cursor-pointer hover:bg-surface-100 dark:hover:bg-surface-900 rounded transition-colors"
93
- onClick={onToggle}
94
- >
95
- <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>
96
- {icon}
97
- <Typography variant="body2" className="text-text-primary dark:text-text-primary-dark font-medium text-xs truncate flex-grow ml-1.5">{title}</Typography>
98
- {count !== undefined && (
99
- <span className="text-[10px] text-text-disabled dark:text-text-disabled-dark font-medium tabular-nums mr-1">{count}</span>
100
- )}
101
- </div>
102
- {expanded && (
103
- <div className="ml-3 mt-0.5 space-y-0.5">
104
- {children}
105
- </div>
106
- )}
107
- </div>
108
- );
109
- }
110
-
111
- function SidebarTableRow({ table, isSelected, onSelect, badge, dimmed, t }: {
112
- table: TableRLSStatus;
113
- isSelected: boolean;
114
- onSelect: () => void;
115
- badge?: string;
116
- dimmed?: boolean;
117
- t: (key: string) => string;
118
- }) {
119
- return (
120
- <div
121
- onClick={onSelect}
122
- className={cls(
123
- "flex items-center p-1 cursor-pointer rounded transition-colors group relative",
124
- isSelected
125
- ? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light"
126
- : "hover:bg-surface-100 dark:hover:bg-surface-900 text-text-secondary dark:text-text-secondary-dark",
127
- dimmed && !isSelected && "opacity-60"
128
- )}
129
- >
130
- <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>
131
- <Typography variant="body2" className="text-xs truncate flex-1 min-w-0">{table.tableName}</Typography>
132
- <div className="flex items-center gap-1.5 shrink-0 ml-2">
133
- {badge && (
134
- <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">
135
- {badge}
136
- </span>
137
- )}
138
- {table.rlsEnabled ? (
139
- <Tooltip title={t("studio_rls_enabled")}>
140
- <div className="w-1.5 h-1.5 rounded-full bg-green-500"/>
141
- </Tooltip>
142
- ) : (
143
- <Tooltip title={t("studio_rls_disabled")}>
144
- <div className="w-1.5 h-1.5 rounded-full bg-orange-400 opacity-50"/>
145
- </Tooltip>
146
- )}
147
- <span className="text-[10px] opacity-40 group-hover:opacity-100 min-w-[1.2rem] text-right font-medium">
148
- {table.policies.length}
149
- </span>
150
- </div>
151
- </div>
152
- );
153
- }
154
-
155
- export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
156
- const { databaseAdmin } = useRebaseContext();
157
- const snackbarController = useSnackbarController();
158
- const collectionRegistry = useStudioCollectionRegistry();
159
- const { codebase: hasCodebase } = useStudioCapabilities();
160
- const apiConfig = useApiConfig();
161
- /* `apiUrl` is a bare origin; the routes live under the backend's
162
- `basePath`, which is `/api` only by default. */
163
- const apiBase = useApiBase() ?? `${apiUrl.replace(/\/+$/, "")}/api`;
164
-
165
- const saveSecurityRules = useCallback(
166
- (collectionId: string, securityRules: unknown[]) => saveSecurityRulesToCodebase({
167
- apiBase,
168
- collectionId,
169
- securityRules,
170
- getAuthToken: apiConfig?.getAuthToken
171
- }),
172
- [apiBase, apiConfig]);
173
- const { t } = useTranslation();
174
-
175
- const [isLoading, setIsLoading] = useState(true);
176
- const [error, setError] = useState<string | null>(null);
177
- const [tables, setTables] = useState<TableRLSStatus[]>([]);
178
- const [selectedTable, setSelectedTable] = useState<string | null>(null);
179
- const [activeTab, setActiveTab] = useState(0);
180
- const [junctionTableNames, setJunctionTableNames] = useState<Set<string>>(new Set());
181
-
182
- const [editingPolicy, setEditingPolicy] = useState<PostgresPolicy | "new" | null>(null);
183
-
184
- /**
185
- * Native PostgreSQL roles, for the policy editor's `TO` list.
186
- *
187
- * `fetchAvailableRoles` and not `fetchApplicationRoles`: a policy's `TO`
188
- * clause names database roles, and an application role put there produces a
189
- * policy that either fails to create or matches nothing. The editor falls
190
- * back to a static pair when the driver cannot answer.
191
- */
192
- const [pgRoleOptions, setPgRoleOptions] = useState<string[]>([]);
193
-
194
- useEffect(() => {
195
- let mounted = true;
196
- if (!databaseAdmin?.fetchAvailableRoles) return;
197
- databaseAdmin.fetchAvailableRoles()
198
- .then((roles) => { if (mounted) setPgRoleOptions(roles); })
199
- .catch((e) => console.error("Failed to fetch database roles", e));
200
- return () => { mounted = false; };
201
- }, [databaseAdmin]);
202
-
203
- const [sidebarSize, setSidebarSize] = useState(() => {
204
- try {
205
- const saved = localStorage.getItem("rebase_rls_editor_sidebar_size");
206
- return saved !== null ? parseFloat(saved) : 20;
207
- } catch (e) {
208
- return 20;
209
- }
210
- });
211
-
212
- const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
213
- collection: true,
214
- other: true,
215
- internal: false
216
- });
217
-
218
- // Sidebar tab: "tables" or "info"
219
- const [sidebarTab, setSidebarTab] = useState<"tables" | "info">("tables");
220
-
221
- useEffect(() => {
222
- try {
223
- localStorage.setItem("rebase_rls_editor_sidebar_size", sidebarSize.toString());
224
- } catch (e) { /* ignore */ }
225
- }, [sidebarSize]);
226
-
227
- const fetchRLSData = useCallback(async () => {
228
- if (!databaseAdmin?.executeSql) {
229
- setError(t("studio_sql_sql_not_supported"));
230
- setIsLoading(false);
231
- return;
232
- }
233
-
234
- setIsLoading(true);
235
- setError(null);
236
- try {
237
- // 1. Fetch tables and whether RLS is enabled
238
- const tablesSql = `
239
- SELECT
240
- schemaname,
241
- tablename,
242
- rowsecurity
243
- FROM pg_tables
244
- WHERE schemaname NOT IN ('information_schema', 'pg_catalog')
245
- ORDER BY schemaname, tablename;
246
- `;
247
- const tablesResult = await databaseAdmin!.executeSql!(tablesSql);
248
-
249
- // 2. Fetch all policies
250
- const policiesSql = `
251
- SELECT
252
- schemaname,
253
- tablename,
254
- policyname,
255
- permissive,
256
- roles,
257
- cmd,
258
- qual,
259
- with_check
260
- FROM pg_policies
261
- WHERE schemaname NOT IN ('information_schema', 'pg_catalog');
262
- `;
263
- const policiesResult = await databaseAdmin!.executeSql!(policiesSql);
264
-
265
- const extractRows = (result: unknown): Record<string, unknown>[] => {
266
- if (result && typeof result === "object" && "rows" in result && Array.isArray((result as { rows: Record<string, unknown>[] }).rows)) {
267
- return (result as { rows: Record<string, unknown>[] }).rows;
268
- }
269
- if (Array.isArray(result)) return result as Record<string, unknown>[];
270
- return [];
271
- };
272
-
273
- const tRows = extractRows(tablesResult);
274
- const pRows = extractRows(policiesResult);
275
-
276
- const tableMap: Record<string, TableRLSStatus> = {};
277
-
278
- tRows.forEach((tRow: Record<string, unknown>) => {
279
- const t = tRow as { schemaname?: string, SCHEMANAME?: string, tablename?: string, TABLENAME?: string, rowsecurity?: boolean, ROWSECURITY?: boolean };
280
- const schema = t.schemaname || t.SCHEMANAME || "public";
281
- const table = t.tablename || t.TABLENAME || "";
282
- const rlsEnabled = t.rowsecurity || t.ROWSECURITY || false;
283
-
284
- const key = `${schema}.${table}`;
285
- tableMap[key] = {
286
- schemaName: schema,
287
- tableName: table,
288
- rlsEnabled: rlsEnabled,
289
- policies: []
290
- };
291
- });
292
-
293
- pRows.forEach((pRow: Record<string, unknown>) => {
294
- const p = pRow as { schemaname?: string, SCHEMANAME?: string, tablename?: string, TABLENAME?: string, roles?: string | string[], ROLES?: string | string[], policyname?: string, POLICYNAME?: string, permissive?: "PERMISSIVE" | "RESTRICTIVE", PERMISSIVE?: "PERMISSIVE" | "RESTRICTIVE", cmd?: "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "ALL", CMD?: "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "ALL", qual?: string | null, QUAL?: string | null, with_check?: string | null, WITH_CHECK?: string | null };
295
- const schema = p.schemaname || p.SCHEMANAME || "public";
296
- const table = p.tablename || p.TABLENAME || "";
297
- const key = `${schema}.${table}`;
298
-
299
- if (tableMap[key]) {
300
- // Postgres roles come back as an array string like "{public}" or literal array
301
- let parsedRoles: string[] = [];
302
- const r = p.roles || p.ROLES;
303
- if (Array.isArray(r)) {
304
- parsedRoles = r;
305
- } else if (typeof r === "string") {
306
- parsedRoles = r.replace(/^{|}$/g, "").split(",").map(s => s.trim());
307
- }
308
-
309
- tableMap[key].policies.push({
310
- policyname: p.policyname || p.POLICYNAME || "",
311
- tablename: table,
312
- permissive: p.permissive || p.PERMISSIVE || "PERMISSIVE",
313
- roles: parsedRoles,
314
- cmd: p.cmd || p.CMD || "ALL",
315
- qual: p.qual || p.QUAL || null,
316
- with_check: p.with_check || p.WITH_CHECK || null
317
- });
318
- }
319
- });
320
-
321
- const sortedTables = Object.values(tableMap).sort((a, b) => a.tableName.localeCompare(b.tableName));
322
- setTables(sortedTables);
323
-
324
- // Detect junction tables (where all columns are FKs)
325
- try {
326
- const junctionResult = await databaseAdmin!.executeSql!(JUNCTION_TABLES_SQL);
327
- const jRows = extractRows(junctionResult);
328
- setJunctionTableNames(new Set(
329
- jRows.map((r: Record<string, unknown>) => r.table_name as string).filter(Boolean)
330
- ));
331
- } catch {
332
- // Junction detection is best-effort
333
- }
334
-
335
- if (sortedTables.length > 0 && !selectedTable) {
336
- // Auto-select first public/collection table, skip internal
337
- const firstUserTable = sortedTables.find(t => !REBASE_INTERNAL_SCHEMAS.includes(t.schemaName));
338
- if (firstUserTable) {
339
- setSelectedTable(`${firstUserTable.schemaName}.${firstUserTable.tableName}`);
340
- } else {
341
- setSelectedTable(`${sortedTables[0].schemaName}.${sortedTables[0].tableName}`);
342
- }
343
- }
344
-
345
- } catch (e: unknown) {
346
- console.error("RLS fetch error:", e);
347
- setError("Failed to fetch RLS policies: " + (e instanceof Error ? e.message : String(e)));
348
- } finally {
349
- setIsLoading(false);
350
- }
351
- }, [databaseAdmin, selectedTable]);
352
-
353
- useEffect(() => {
354
- setEditingPolicy(null);
355
- }, [selectedTable]);
356
-
357
- useEffect(() => {
358
- fetchRLSData();
359
- }, [fetchRLSData]);
360
-
361
- const activeTableData = useMemo(() => {
362
- if (!selectedTable) return null;
363
- return tables.find(t => `${t.schemaName}.${t.tableName}` === selectedTable) || null;
364
- }, [selectedTable, tables]);
365
-
366
- /** Categorize tables into 4 buckets for the sidebar. */
367
- const categorizedTables = useMemo(() => {
368
- const groups: Record<TableCategory, TableRLSStatus[]> = {
369
- collection: [],
370
- junction: [],
371
- internal: [],
372
- other: []
373
- };
374
-
375
- tables.forEach(table => {
376
- const isMapped = !!collectionRegistry.collections?.find(
377
- (c: { id?: string, path?: string, table?: string, slug?: string, collectionId?: string }) =>
378
- c.id === table.tableName ||
379
- c.path === table.tableName ||
380
- c.table === table.tableName ||
381
- c.slug === table.tableName ||
382
- c.collectionId === table.tableName
383
- );
384
- const cat = classifyTableClient(table.tableName, table.schemaName, junctionTableNames, isMapped);
385
- groups[cat].push(table);
386
- });
387
-
388
- return groups;
389
- }, [tables, junctionTableNames, collectionRegistry.collections]);
390
-
391
- const activeCollection = useMemo(() => {
392
- if (!activeTableData) return null;
393
- return collectionRegistry.collections?.find((c: { id?: string, path?: string, table?: string, slug?: string, collectionId?: string }) =>
394
- c.id === activeTableData.tableName ||
395
- c.path === activeTableData.tableName ||
396
- c.table === activeTableData.tableName ||
397
- c.slug === activeTableData.tableName ||
398
- c.collectionId === activeTableData.tableName
399
- ) || null;
400
- }, [activeTableData, collectionRegistry.collections]);
401
-
402
- /** The category of the currently selected table. */
403
- const activeTableCategory = useMemo((): TableCategory | null => {
404
- if (!activeTableData) return null;
405
- const isMapped = !!activeCollection;
406
- return classifyTableClient(activeTableData.tableName, activeTableData.schemaName, junctionTableNames, isMapped);
407
- }, [activeTableData, activeCollection, junctionTableNames]);
408
-
409
- const mergedPolicies = useMemo(() => {
410
- if (!activeTableData) return [];
411
-
412
- const policiesMap: Record<string, PostgresPolicy> = {};
413
-
414
- // Load live policies
415
- (activeTableData.policies || []).forEach(p => {
416
- policiesMap[p.policyname] = { ...p,
417
- status: "live" };
418
- });
419
-
420
- // Merge code-based policies.
421
- //
422
- // A rule without an explicit `name` still produces policies — Postgres gets
423
- // `<table>_<op>_<hash>`, one per operation. Skipping those rules left their
424
- // live policies looking like hand-written SQL ("DB Only"), so derive the
425
- // names the generator would emit and match on those.
426
- //
427
- // `getEffectiveSecurityRules` rather than `securityRules`, for the same
428
- // reason one step further out: the generator also injects the
429
- // safe-by-default baseline (`<table>_default_admin_read` and the three
430
- // `_default_admin_write_*`), which appears in no collection's
431
- // `securityRules`. Reading the declared rules alone made four policies
432
- // *Rebase itself wrote* look like drift on every table in the project —
433
- // badged "DB Only" and offered for import back into the codebase that
434
- // produced them. The admin panel's own RLS tab already derived them this
435
- // way; this view, its sibling, did not. Both start from
436
- // `getEffectiveSecurityRules` now — this one needs the rule bodies to
437
- // render a policy row, the other only the names it compiles to
438
- // (`getGeneratedPolicyNames`).
439
- if (activeCollection && isPostgresCollectionConfig(activeCollection)) {
440
- getEffectiveSecurityRules(activeCollection as unknown as CollectionConfig).forEach((rule) => {
441
- const ops = getPolicyOperations(rule);
442
- const policyNames = getPolicyNamesForRule(rule, activeTableData.tableName);
443
-
444
- policyNames.forEach((policyName, opIdx) => {
445
- policiesMap[policyName] = {
446
- policyname: policyName,
447
- tablename: activeTableData.tableName,
448
- permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
449
- cmd: (ops[opIdx] ?? rule.operation ?? "ALL").toUpperCase() as PostgresPolicy["cmd"],
450
- // `pgRoles`, not `roles`. This is a policy's `TO` list —
451
- // database roles — while `SecurityRule.roles` holds
452
- // *application* roles, which the generator compiles into
453
- // the USING clause via `rebase.roles()` and never puts in
454
- // the `TO` list. Reading the wrong field made every rule
455
- // scoped `roles: ["admin"]` render as `TO admin`, a
456
- // policy the framework has never written.
457
- roles: [...(rule.pgRoles ?? ["public"])],
458
- qual: rule.using || null,
459
- with_check: rule.withCheck || null,
460
- // "both" = defined in code and live in Postgres (potentially edited)
461
- status: policiesMap[policyName] ? "both" : "code_only"
462
- };
463
- });
464
- });
465
- }
466
-
467
- // Junction tables have no collection, but their policies are generated
468
- // too — derived from the endpoints' relations. Recognise them so they
469
- // don't show as hand-written SQL ("DB Only"). If the registry's
470
- // collections don't carry resolvable relations, they simply stay "live".
471
- if (!activeCollection) {
472
- try {
473
- const registryCollections = (collectionRegistry.collections ?? []) as unknown as Parameters<typeof resolveJunctionSpecs>[0];
474
- const spec = resolveJunctionSpecs(registryCollections).get(activeTableData.tableName);
475
- if (spec) {
476
- const generatedNames = getPolicyNamesForRules(getJunctionSecurityRules(spec), spec.table);
477
- for (const p of Object.values(policiesMap)) {
478
- if (generatedNames.has(p.policyname)) p.status = "both";
479
- }
480
- }
481
- } catch {
482
- /* serialized configs without relation closures — leave as live */
483
- }
484
- }
485
-
486
- return Object.values(policiesMap).sort((a, b) => a.policyname.localeCompare(b.policyname));
487
- }, [activeTableData, activeCollection, collectionRegistry.collections]);
488
-
489
- // Stats for the info tab
490
- const rlsStats = useMemo(() => {
491
- const total = tables.length;
492
- const enabled = tables.filter(t => t.rlsEnabled).length;
493
- const withPolicies = tables.filter(t => t.policies.length > 0).length;
494
- const totalPolicies = tables.reduce((sum, t) => sum + t.policies.length, 0);
495
- return { total,
496
- enabled,
497
- withPolicies,
498
- totalPolicies };
499
- }, [tables]);
500
-
501
- const renderPolicyTag = (label: string, value: string) => {
502
- return (
503
- <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface-100 dark:bg-surface-950 border border-surface-200 dark:border-surface-700/50">
504
- <span className="text-[10px] uppercase text-text-secondary dark:text-text-secondary-dark font-medium tracking-wider">
505
- {label}:
506
- </span>
507
- <span className="font-mono text-xs text-text-primary dark:text-text-primary-dark break-all">
508
- {value}
509
- </span>
510
- </div>
511
- );
512
- };
513
-
514
- return (
515
- <div className="flex h-full w-full bg-white dark:bg-surface-950 overflow-hidden text-text-primary dark:text-text-primary-dark">
516
- <ResizablePanels
517
- orientation="horizontal"
518
- panelSizePercent={sidebarSize}
519
- onPanelSizeChange={setSidebarSize}
520
- minPanelSizePx={220}
521
- firstPanel={
522
- <div className={cls("flex flex-col h-full w-full bg-white dark:bg-surface-950 border-r", defaultBorderMixin)}>
523
- <Tabs value={sidebarTab} onValueChange={(v) => setSidebarTab(v as "tables" | "info")} variant="boxy" className="border-b border-surface-200 dark:border-surface-950">
524
- <Tab value="tables">Tables</Tab>
525
- <Tab value="info">Info</Tab>
526
- </Tabs>
527
-
528
- <div className="flex-grow overflow-hidden relative">
529
- {sidebarTab === "tables" && (
530
- <div className="flex flex-col h-full">
531
- <div className={cls("flex items-center justify-between px-3 py-2 border-b bg-surface-50 dark:bg-surface-900 min-h-[48px]", defaultBorderMixin)}>
532
- <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
533
- {t("studio_schema_tables")}
534
- </Typography>
535
- <IconButton size="small" onClick={fetchRLSData} title="Refresh">
536
- <RefreshCwIcon size={iconSize.smallest}/>
537
- </IconButton>
538
- </div>
539
- <div className="flex-grow overflow-y-auto no-scrollbar p-1">
540
- {isLoading && tables.length === 0 ? (
541
- <div className="flex justify-center p-4"><CircularProgress size="small"/></div>
542
- ) : tables.length === 0 ? (
543
- <div className="p-4 text-center">
544
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark italic">{t("studio_rls_no_tables")}</Typography>
545
- </div>
546
- ) : (
547
- <>
548
- {/* ── Schema Collections ── */}
549
- {categorizedTables.collection.length > 0 && (
550
- <SidebarSection
551
- title="Schema Collections"
552
- icon={<DatabaseIcon size={12} className="text-primary dark:text-primary-light"/>}
553
- expanded={expandedSections.collection ?? true}
554
- onToggle={() => setExpandedSections(prev => ({ ...prev, collection: !(prev.collection ?? true) }))}
555
- >
556
- {categorizedTables.collection.map(table => (
557
- <SidebarTableRow
558
- key={`${table.schemaName}.${table.tableName}`}
559
- table={table}
560
- isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
561
- onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
562
- t={t}
563
- />
564
- ))}
565
- </SidebarSection>
566
- )}
567
-
568
- {/* ── Other Tables (unmapped + junction) ── */}
569
- {(categorizedTables.other.length > 0 || categorizedTables.junction.length > 0) && (
570
- <SidebarSection
571
- title="Other Tables"
572
- icon={<AlertTriangleIcon size={12} className="text-yellow-500 dark:text-yellow-400"/>}
573
- expanded={expandedSections.other ?? true}
574
- onToggle={() => setExpandedSections(prev => ({ ...prev, other: !(prev.other ?? true) }))}
575
- >
576
- {categorizedTables.other.map(table => (
577
- <SidebarTableRow
578
- key={`${table.schemaName}.${table.tableName}`}
579
- table={table}
580
- isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
581
- onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
582
- t={t}
583
- />
584
- ))}
585
- {categorizedTables.junction.map(table => (
586
- <SidebarTableRow
587
- key={`${table.schemaName}.${table.tableName}`}
588
- table={table}
589
- isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
590
- onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
591
- badge="Junction"
592
- dimmed
593
- t={t}
594
- />
595
- ))}
596
- </SidebarSection>
597
- )}
598
-
599
- {/* ── Rebase Internal ── */}
600
- {categorizedTables.internal.length > 0 && (
601
- <SidebarSection
602
- title="Rebase Internal"
603
- icon={<LockIcon size={12} className="text-text-disabled dark:text-text-disabled-dark"/>}
604
- expanded={expandedSections.internal ?? false}
605
- onToggle={() => setExpandedSections(prev => ({ ...prev, internal: !(prev.internal ?? false) }))}
606
- count={categorizedTables.internal.length}
607
- >
608
- {categorizedTables.internal.map(table => (
609
- <SidebarTableRow
610
- key={`${table.schemaName}.${table.tableName}`}
611
- table={table}
612
- isSelected={selectedTable === `${table.schemaName}.${table.tableName}`}
613
- onSelect={() => setSelectedTable(`${table.schemaName}.${table.tableName}`)}
614
- dimmed
615
- t={t}
616
- />
617
- ))}
618
- </SidebarSection>
619
- )}
620
- </>
621
- )}
622
- </div>
623
- </div>
624
- )}
625
-
626
- {sidebarTab === "info" && (
627
- <div className="flex flex-col h-full">
628
- <div className={cls("flex items-center justify-between px-3 py-2 border-b bg-surface-50 dark:bg-surface-900 min-h-[48px]", defaultBorderMixin)}>
629
- <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
630
- Overview
631
- </Typography>
632
- </div>
633
- <div className="flex-grow overflow-y-auto p-3 space-y-3 no-scrollbar">
634
- <div className={cls("p-3 rounded-lg border bg-white dark:bg-surface-900", defaultBorderMixin)}>
635
- <div className="flex items-center gap-2 mb-2">
636
- <ShieldIcon size={iconSize.smallest} className="text-primary"/>
637
- <Typography variant="body2" className="font-semibold text-[13px]">RLS Studio</Typography>
638
- </div>
639
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark text-[11px] leading-relaxed block">
640
- Manage Row Level Security policies for your PostgreSQL tables. Enable RLS and create fine-grained access policies.
641
- </Typography>
642
- </div>
643
-
644
- <div className="space-y-2">
645
- <div className={cls("p-2.5 rounded border bg-white dark:bg-surface-900 flex items-center justify-between", defaultBorderMixin)}>
646
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark text-[11px]">Total tables</Typography>
647
- <Typography variant="body2" className="font-mono text-[13px] font-medium">{rlsStats.total}</Typography>
648
- </div>
649
- <div className={cls("p-2.5 rounded border bg-white dark:bg-surface-900 flex items-center justify-between", defaultBorderMixin)}>
650
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark text-[11px]">RLS enabled</Typography>
651
- <div className="flex items-center gap-1.5">
652
- <div className="w-1.5 h-1.5 rounded-full bg-green-500"/>
653
- <Typography variant="body2" className="font-mono text-[13px] font-medium">{rlsStats.enabled}</Typography>
654
- </div>
655
- </div>
656
- <div className={cls("p-2.5 rounded border bg-white dark:bg-surface-900 flex items-center justify-between", defaultBorderMixin)}>
657
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark text-[11px]">Tables with policies</Typography>
658
- <Typography variant="body2" className="font-mono text-[13px] font-medium">{rlsStats.withPolicies}</Typography>
659
- </div>
660
- <div className={cls("p-2.5 rounded border bg-white dark:bg-surface-900 flex items-center justify-between", defaultBorderMixin)}>
661
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark text-[11px]">Total policies</Typography>
662
- <Typography variant="body2" className="font-mono text-[13px] font-medium">{rlsStats.totalPolicies}</Typography>
663
- </div>
664
- </div>
665
-
666
- {/* Security health indicators */}
667
- {rlsStats.total - rlsStats.enabled > 0 && (
668
- <div className={cls("p-2.5 rounded border border-yellow-200 dark:border-yellow-900/50 bg-yellow-50 dark:bg-yellow-900/20 flex items-start gap-2", defaultBorderMixin)}>
669
- <AlertTriangleIcon size={14} className="text-yellow-600 dark:text-yellow-500 mt-0.5 shrink-0"/>
670
- <div>
671
- <Typography variant="caption" className="text-yellow-800 dark:text-yellow-400 text-[11px] font-semibold block">
672
- {rlsStats.total - rlsStats.enabled} table{rlsStats.total - rlsStats.enabled > 1 ? "s" : ""} without RLS
673
- </Typography>
674
- <Typography variant="caption" className="text-yellow-700 dark:text-yellow-600 text-[10px] block mt-0.5">
675
- These tables have no row-level access control. If auth enforcement is disabled, data may be publicly accessible.
676
- </Typography>
677
- </div>
678
- </div>
679
- )}
680
-
681
- {rlsStats.enabled > 0 && rlsStats.enabled - rlsStats.withPolicies > 0 && (
682
- <div className={cls("p-2.5 rounded border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 flex items-start gap-2", defaultBorderMixin)}>
683
- <ShieldIcon size={14} className="text-blue-600 dark:text-blue-400 mt-0.5 shrink-0"/>
684
- <div>
685
- <Typography variant="caption" className="text-blue-800 dark:text-blue-300 text-[11px] font-semibold block">
686
- {rlsStats.enabled - rlsStats.withPolicies} table{rlsStats.enabled - rlsStats.withPolicies > 1 ? "s" : ""} with RLS but no policies
687
- </Typography>
688
- <Typography variant="caption" className="text-blue-700 dark:text-blue-500 text-[10px] block mt-0.5">
689
- RLS is enabled but no permissive policies exist. All access is denied by default (Postgres deny-all).
690
- </Typography>
691
- </div>
692
- </div>
693
- )}
694
- </div>
695
- </div>
696
- )}
697
- </div>
698
- </div>
699
- }
700
- secondPanel={
701
- <div className="flex-grow flex flex-col min-w-0 h-full w-full bg-white dark:bg-surface-950">
702
- {/* Toolbar Header matching SQL/JS Editor style */}
703
- <div className={cls("flex items-center justify-between pr-2 border-b bg-white dark:bg-surface-950 min-h-[46px]", defaultBorderMixin)}>
704
- <div className="flex items-center flex-grow overflow-hidden px-4">
705
- <Typography variant="subtitle2" className="font-mono text-text-secondary dark:text-text-secondary-dark truncate">
706
- {activeTableData ? `${activeTableData.schemaName}.${activeTableData.tableName}` : t("studio_rls_select_table")}
707
- </Typography>
708
- {activeTableData && (
709
- <div className="ml-3">
710
- {activeTableData.rlsEnabled ? (
711
- <Chip size="smallest" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800">{t("studio_rls_enabled")}</Chip>
712
- ) : (
713
- <Chip size="smallest" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800">{t("studio_rls_disabled")}</Chip>
714
- )}
715
- </div>
716
- )}
717
- </div>
718
- <div className="flex shrink-0 items-center justify-end gap-1.5">
719
- {activeTableData && (
720
- <>
721
- <Button
722
- variant="text"
723
- size="small"
724
- onClick={async () => {
725
- const table = activeTableData.tableName;
726
- const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData.schemaName)}.${sanitizeSqlIdentifier(table)}`;
727
- const action = activeTableData.rlsEnabled ? "DISABLE" : "ENABLE";
728
- if (!confirm(`Are you sure you want to ${action.toLowerCase()} Row Level Security on "${table}"?`)) return;
729
- try {
730
- await databaseAdmin!.executeSql!(`ALTER TABLE ${qualifiedTable} ${action} ROW LEVEL SECURITY`);
731
- snackbarController.open({ type: "success",
732
- message: `RLS ${action.toLowerCase()}d on ${table}` });
733
- fetchRLSData();
734
- } catch (e: unknown) {
735
- snackbarController.open({ type: "error",
736
- message: e instanceof Error ? e.message : String(e) });
737
- }
738
- }}
739
- >
740
- {activeTableData.rlsEnabled ? t("studio_rls_disable_rls") : t("studio_rls_enable_rls")}
741
- </Button>
742
-
743
- <div className="h-4 w-px bg-surface-200 dark:bg-surface-950 mx-1"/>
744
-
745
- <Button
746
- variant="text"
747
- size="small"
748
- onClick={fetchRLSData}
749
- startIcon={<RefreshCwIcon size={iconSize.smallest}/>}
750
- >
751
- Refresh
752
- </Button>
753
-
754
- <div className="h-4 w-px bg-surface-200 dark:bg-surface-950 mx-1"/>
755
-
756
- <Button
757
- size="small"
758
- color="primary"
759
- onClick={() => setEditingPolicy("new")}
760
- >
761
- {t("studio_rls_create_policy")}
762
- </Button>
763
- </>
764
- )}
765
- </div>
766
- </div>
767
-
768
- {isLoading && !activeTableData ? (
769
- <div className="flex-grow flex items-center justify-center h-full">
770
- <CircularProgress size="small"/>
771
- </div>
772
- ) : error ? (
773
- <div className="p-6 h-full flex items-center justify-center">
774
- <ErrorView title={t("studio_rls_error")} error={error} onRetry={fetchRLSData}/>
775
- </div>
776
- ) : !activeTableData ? (
777
- <div className="flex-grow flex items-center justify-center text-text-disabled h-full">
778
- <div className="text-center">
779
- <svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
780
- <Typography variant="body2">{t("studio_rls_select_table")}</Typography>
781
- </div>
782
- </div>
783
- ) : editingPolicy ? (
784
- <PolicyEditor
785
- policy={editingPolicy === "new" ? undefined : editingPolicy}
786
- schema={activeTableData.schemaName}
787
- table={activeTableData.tableName}
788
- roleOptions={pgRoleOptions}
789
- onSave={async (newPolicy) => {
790
- /*
791
- * Where a policy for a *mapped* table belongs depends
792
- * on the host, not on the table.
793
- *
794
- * Beside its own source, the collection file is the
795
- * right home: the rule is checked in, and the next
796
- * migration applies it. The hosted console has no
797
- * source — the container is rebuilt from the
798
- * customer's repository on every deploy — and the
799
- * schema-editor routes it would POST to are not even
800
- * mounted, because the framework switches them off
801
- * under NODE_ENV=production, which every tenant runs.
802
- * So this branch used to make "Create Policy" and
803
- * "Edit" fail with "Failed to save policy" on every
804
- * mapped table in the console, which is most of them.
805
- * There, the database is the only place a policy can
806
- * live, so write it there — the same path unmapped
807
- * tables have always used.
808
- */
809
- if (activeCollection && hasCodebase) {
810
- // Collection-mapped table: save via schema-editor API
811
- const rule: Record<string, unknown> = {
812
- name: newPolicy.policyname,
813
- operation: newPolicy.cmd?.toLowerCase(),
814
- mode: newPolicy.permissive?.toLowerCase(),
815
- using: newPolicy.qual || undefined,
816
- withCheck: newPolicy.with_check || undefined,
817
- // The editor edits a `PostgresPolicy`, whose
818
- // `roles` is the `TO` list — so it maps to
819
- // `pgRoles`. Writing it to `roles` filed
820
- // database roles as *application* roles, and
821
- // the generator then compiled them into a
822
- // `rebase.roles()` check no user could
823
- // satisfy: the rule saved cleanly, pushed
824
- // cleanly, and matched nothing.
825
- //
826
- // Omitted at the default so a rule that
827
- // targets `public` — nearly all of them —
828
- // does not carry an advanced field it does
829
- // not need.
830
- ...(newPolicy.roles && !(newPolicy.roles.length === 1 && newPolicy.roles[0] === "public")
831
- ? { pgRoles: newPolicy.roles }
832
- : {})
833
- };
834
-
835
- const existingRules = (isPostgresCollectionConfig(activeCollection) ? activeCollection.securityRules : undefined) || [];
836
- let newRules;
837
- if (editingPolicy === "new") {
838
- newRules = [...existingRules, rule];
839
- } else {
840
- newRules = existingRules.map((r: { name?: string }) => r.name === editingPolicy.policyname ? rule : r);
841
- }
842
-
843
- try {
844
- await saveSecurityRules(
845
- (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,
846
- newRules
847
- );
848
-
849
- snackbarController.open({ type: "success",
850
- message: "Policy saved successfully" });
851
- setEditingPolicy(null);
852
- fetchRLSData();
853
- } catch (e: unknown) {
854
- snackbarController.open({ type: "error",
855
- message: e instanceof Error ? e.message : String(e) });
856
- }
857
- } else {
858
- // No codebase to write to (hosted console), or an
859
- // unmapped table (internal/junction/other): apply
860
- // the policy to the database directly.
861
- try {
862
- const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData.schemaName)}.${sanitizeSqlIdentifier(activeTableData.tableName)}`;
863
- const policyName = sanitizeSqlIdentifier(newPolicy.policyname || "unnamed_policy");
864
- const cmd = newPolicy.cmd || "ALL";
865
- const permissive = (newPolicy.permissive || "PERMISSIVE") === "PERMISSIVE" ? "PERMISSIVE" : "RESTRICTIVE";
866
- const roles = newPolicy.roles && newPolicy.roles.length > 0
867
- ? newPolicy.roles.map(r => sanitizeSqlIdentifier(r)).join(", ")
868
- : "public";
869
-
870
- // Drop existing policy if editing
871
- if (editingPolicy !== "new") {
872
- await databaseAdmin!.executeSql!(`DROP POLICY IF EXISTS ${policyName} ON ${qualifiedTable}`);
873
- }
874
-
875
- let sql = `CREATE POLICY ${policyName} ON ${qualifiedTable}`;
876
- sql += ` AS ${permissive}`;
877
- sql += ` FOR ${cmd}`;
878
- sql += ` TO ${roles}`;
879
- if (newPolicy.qual) sql += ` USING (${newPolicy.qual})`;
880
- if (newPolicy.with_check) sql += ` WITH CHECK (${newPolicy.with_check})`;
881
-
882
- await databaseAdmin!.executeSql!(sql);
883
-
884
- snackbarController.open({ type: "success",
885
- message: `Policy "${newPolicy.policyname}" applied to the database` });
886
- setEditingPolicy(null);
887
- fetchRLSData();
888
- } catch (e: unknown) {
889
- snackbarController.open({ type: "error",
890
- message: e instanceof Error ? e.message : String(e) });
891
- }
892
- }
893
- }}
894
- onCancel={() => setEditingPolicy(null)}
895
- />
896
- ) : (
897
- <div className="flex-grow flex flex-col overflow-hidden">
898
- <div className="p-6 pt-4 flex-grow overflow-auto bg-surface-50 dark:bg-surface-900">
899
- <div className="max-w-4xl mx-auto flex flex-col gap-6">
900
- {/* Context-aware banner based on table category */}
901
- {activeTableData && activeTableCategory === "internal" && (
902
- <Alert color="info">
903
- <div className="flex items-start gap-2">
904
- <LockIcon size={16} className="shrink-0 mt-0.5"/>
905
- <div>
906
- <Typography variant="body2" className="mb-1 font-semibold">
907
- Rebase System Table
908
- </Typography>
909
- <Typography variant="caption" className="opacity-80">
910
- This table is managed internally by Rebase. Its security policies are configured automatically.
911
- Editing policies on system tables is an advanced operation.
912
- </Typography>
913
- </div>
914
- </div>
915
- </Alert>
916
- )}
917
- {activeTableData && activeTableCategory === "junction" && (
918
- <Alert color="info">
919
- <div className="flex items-start gap-2">
920
- <Link2Icon size={16} className="shrink-0 mt-0.5"/>
921
- <div>
922
- <Typography variant="body2" className="mb-1 font-semibold">
923
- Junction Table
924
- </Typography>
925
- <Typography variant="caption" className="opacity-80">
926
- This is an auto-generated junction table for a many-to-many relation.
927
- Rebase derives its policies: rows are readable when both related rows
928
- are, and writable following the declaring collection&apos;s update rules
929
- (plus the server/admin baseline). You can still add RLS policies
930
- directly to broaden access.
931
- </Typography>
932
- </div>
933
- </div>
934
- </Alert>
935
- )}
936
- {activeTableData && activeTableCategory === "other" && (
937
- <Alert color="warning">
938
- <div className="flex items-start gap-2">
939
- <AlertTriangleIcon size={16} className="shrink-0 mt-0.5"/>
940
- <div>
941
- <Typography variant="body2" className="mb-1 font-semibold">
942
- Unmapped Table
943
- </Typography>
944
- <Typography variant="caption" className="opacity-80">
945
- This table exists in the database but isn&apos;t mapped to a collection definition.
946
- Import it into a Schema configuration file to manage security policies visually.
947
- </Typography>
948
- </div>
949
- </div>
950
- </Alert>
951
- )}
952
-
953
- {activeTableData && !activeTableData.rlsEnabled && (
954
- <div className={cls("p-4 sm:p-5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-900/50 rounded-lg flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between", defaultBorderMixin)}>
955
- <div className="flex gap-3 items-start">
956
- <div className="mt-1 bg-yellow-100 dark:bg-yellow-900/50 p-1.5 rounded-md shrink-0 flex items-center justify-center">
957
- <AlertTriangleIcon size={iconSize.smallest}/>
958
- </div>
959
- <div>
960
- <Typography variant="subtitle2" className="text-yellow-800 dark:text-yellow-500">
961
- Row Level Security (RLS) is disabled
962
- </Typography>
963
- <Typography variant="body2" className="text-yellow-700 dark:text-yellow-600/90 mt-1 max-w-2xl">
964
- Your table is completely readable and writable by anyone with access privileges. Enable RLS to create policies that restrict access to specific rows.
965
- </Typography>
966
- </div>
967
- </div>
968
- <Button
969
- size="medium"
970
- variant="filled"
971
- color="neutral"
972
- onClick={() => setEditingPolicy("new")}
973
- className="shrink-0 whitespace-nowrap"
974
- disabled={!activeCollection}
975
- >
976
- {t("studio_rls_create_policy")}
977
- </Button>
978
- </div>
979
- )}
980
-
981
- {activeTableData && mergedPolicies && mergedPolicies.length > 0 && (
982
- <div className="flex flex-col gap-3">
983
- <Typography variant="subtitle2" className="text-text-secondary dark:text-text-secondary-dark uppercase tracking-wider mb-1">{t("studio_rls_policies")}</Typography>
984
- {mergedPolicies.map(policy => (
985
- <Paper key={policy.policyname} className={cls("p-3 sm:px-4 sm:py-3 flex flex-col sm:flex-row sm:items-center justify-between gap-4 border rounded-lg", defaultBorderMixin)}>
986
- <div className="flex flex-col gap-2 min-w-0">
987
- <div className="flex items-center gap-2">
988
- <KeyIcon size={iconSize.smallest} className="text-text-secondary dark:text-text-secondary-dark shrink-0"/>
989
- <Typography variant="body2" className="truncate">{policy.policyname}</Typography>
990
- {policy.status === "code_only" && (
991
- <Tooltip title="This policy is defined in your code but hasn't been applied to the database yet.">
992
- <div className="px-1.5 py-0.5 rounded text-[10px] uppercase bg-primary/10 text-primary border border-primary/20 shrink-0">
993
- Unapplied
994
- </div>
995
- </Tooltip>
996
- )}
997
- {/* "DB Only" is a *drift* signal — it means the
998
- codebase does not declare this policy. Where
999
- there is no codebase to compare against, every
1000
- policy trivially qualifies, so the badge said
1001
- nothing and said it about everything. */}
1002
- {policy.status === "live" && hasCodebase && (
1003
- <Tooltip title="This policy is live in the database but missing from your codebase schema.">
1004
- <div className="px-1.5 py-0.5 rounded text-[10px] uppercase bg-orange-500/10 text-orange-600 border border-orange-500/20 shrink-0">
1005
- DB Only
1006
- </div>
1007
- </Tooltip>
1008
- )}
1009
- </div>
1010
- <div className="flex flex-wrap gap-1.5 text-sm">
1011
- {renderPolicyTag("Action", policy.cmd)}
1012
- {renderPolicyTag("Roles", Array.isArray(policy.roles) ? policy.roles.join(", ") : policy.roles)}
1013
- </div>
1014
- </div>
1015
- <div className="flex gap-2 shrink-0 items-center">
1016
- {/* Writes a collection source file, so it needs a
1017
- codebase on the other end. In the console the
1018
- endpoint is not mounted and the button 404'd. */}
1019
- {policy.status === "live" && activeCollection && hasCodebase && (
1020
- <Button
1021
- size="small"
1022
- variant="outlined"
1023
- color="primary"
1024
- onClick={async () => {
1025
- const rule: Record<string, unknown> = {
1026
- name: policy.policyname,
1027
- operation: policy.cmd?.toLowerCase(),
1028
- mode: policy.permissive?.toLowerCase(),
1029
- using: policy.qual || undefined,
1030
- withCheck: policy.with_check || undefined,
1031
- roles: policy.roles
1032
- };
1033
-
1034
- const existingRules = (isPostgresCollectionConfig(activeCollection) ? activeCollection.securityRules : undefined) || [];
1035
- const newRules = [...existingRules, rule];
1036
-
1037
- try {
1038
- await saveSecurityRules(
1039
- (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,
1040
- newRules
1041
- );
1042
-
1043
- snackbarController.open({ type: "success",
1044
- message: "Policy imported successfully" });
1045
- fetchRLSData();
1046
- } catch (e: unknown) {
1047
- snackbarController.open({ type: "error",
1048
- message: e instanceof Error ? e.message : String(e) });
1049
- }
1050
- }}
1051
- >
1052
- Import to codebase
1053
- </Button>
1054
- )}
1055
- <Button size="small" variant="text" color="primary" onClick={() => setEditingPolicy(policy)}>
1056
- {t("studio_rls_edit")}
1057
- </Button>
1058
- {policy.status !== "code_only" && (
1059
- <Tooltip title={t("studio_rls_delete")} asChild={true}>
1060
- <IconButton
1061
- size="small"
1062
- onClick={async () => {
1063
- const table = activeTableData!.tableName;
1064
- const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData!.schemaName)}.${sanitizeSqlIdentifier(table)}`;
1065
- if (!confirm(`Drop policy "${policy.policyname}" from table "${table}"?`)) return;
1066
- try {
1067
- await databaseAdmin!.executeSql!(`DROP POLICY ${sanitizeSqlIdentifier(policy.policyname)} ON ${qualifiedTable}`);
1068
- snackbarController.open({ type: "success",
1069
- message: `Policy "${policy.policyname}" dropped` });
1070
- fetchRLSData();
1071
- } catch (e: unknown) {
1072
- snackbarController.open({ type: "error",
1073
- message: e instanceof Error ? e.message : String(e) });
1074
- }
1075
- }}
1076
- >
1077
- <Trash2Icon size={iconSize.smallest}/>
1078
- </IconButton>
1079
- </Tooltip>
1080
- )}
1081
- </div>
1082
- </Paper>
1083
- ))}
1084
- </div>
1085
- )}
1086
-
1087
- {activeTableData && mergedPolicies.length === 0 && activeTableData.rlsEnabled && (
1088
- <div className="flex flex-col items-center justify-center py-12 text-center">
1089
- <ShieldIcon size={40} className="text-surface-300 dark:text-surface-600 mb-4"/>
1090
- <Typography variant="subtitle2" className="text-text-secondary dark:text-text-secondary-dark mb-2">
1091
- No policies defined
1092
- </Typography>
1093
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark max-w-sm mb-4">
1094
- 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.
1095
- </Typography>
1096
- <Button
1097
- size="small"
1098
- variant="filled"
1099
- color="primary"
1100
- onClick={() => setEditingPolicy("new")}
1101
- >
1102
- {t("studio_rls_create_policy")}
1103
- </Button>
1104
- </div>
1105
- )}
1106
-
1107
- {activeTableData && mergedPolicies.length === 0 && !activeTableData.rlsEnabled && activeCollection && (
1108
- <div className="flex flex-col items-center justify-center py-12 text-center">
1109
- <AlertTriangleIcon size={40} className="text-yellow-400 dark:text-yellow-600 mb-4"/>
1110
- <Typography variant="subtitle2" className="text-text-secondary dark:text-text-secondary-dark mb-2">
1111
- No access control
1112
- </Typography>
1113
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark max-w-sm">
1114
- This table has neither RLS nor policies. Enable RLS and create policies to restrict row-level access.
1115
- </Typography>
1116
- </div>
1117
- )}
1118
-
1119
- </div>
1120
- </div>
1121
- </div>
1122
- )}
1123
- </div>
1124
- }
1125
- />
1126
- </div>
1127
- );
1128
- };