@rebasepro/studio 0.15.0 → 0.16.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 (36) hide show
  1. package/dist/{ApiExplorer-CDOlUQzk.js → ApiExplorer-9iwGvNnt.js} +5 -5
  2. package/dist/ApiExplorer-9iwGvNnt.js.map +1 -0
  3. package/dist/{JSEditor-BaSrzWhF.js → JSEditor-BZnvK1n6.js} +14 -14
  4. package/dist/JSEditor-BZnvK1n6.js.map +1 -0
  5. package/dist/{RLSEditor-CV8R1G3W.js → RLSEditor-CBX4uD66.js} +163 -49
  6. package/dist/RLSEditor-CBX4uD66.js.map +1 -0
  7. package/dist/{SQLEditor-BmI0iIWK.js → SQLEditor-ByecwX8B.js} +16 -16
  8. package/dist/SQLEditor-ByecwX8B.js.map +1 -0
  9. package/dist/{SchemaVisualizer-vxKSG80C.js → SchemaVisualizer-D6tMZgeC.js} +4 -4
  10. package/dist/SchemaVisualizer-D6tMZgeC.js.map +1 -0
  11. package/dist/{StorageView-CwDI4sBG.js → StorageView-CGm-cacx.js} +9 -9
  12. package/dist/StorageView-CGm-cacx.js.map +1 -0
  13. package/dist/components/RLSEditor/PolicyEditor.d.ts +9 -1
  14. package/dist/components/RLSEditor/policy-presets.d.ts +74 -0
  15. package/dist/index.es.js +6 -6
  16. package/package.json +9 -9
  17. package/src/components/ApiExplorer/ApiExplorer.tsx +2 -2
  18. package/src/components/ApiExplorer/EndpointDetail.tsx +1 -1
  19. package/src/components/ApiExplorer/TryItPanel.tsx +1 -1
  20. package/src/components/JSEditor/JSEditor.tsx +7 -7
  21. package/src/components/JSEditor/JSEditorSidebar.tsx +6 -6
  22. package/src/components/RLSEditor/PolicyEditor.tsx +31 -96
  23. package/src/components/RLSEditor/RLSEditor.tsx +46 -4
  24. package/src/components/RLSEditor/policy-presets.ts +176 -0
  25. package/src/components/SQLEditor/SQLEditor.tsx +10 -10
  26. package/src/components/SQLEditor/SQLEditorSidebar.tsx +4 -4
  27. package/src/components/SQLEditor/SchemaBrowser.tsx +1 -1
  28. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +1 -1
  29. package/src/components/SchemaVisualizer/TableNode.tsx +2 -2
  30. package/src/components/StorageView/StorageView.tsx +8 -8
  31. package/dist/ApiExplorer-CDOlUQzk.js.map +0 -1
  32. package/dist/JSEditor-BaSrzWhF.js.map +0 -1
  33. package/dist/RLSEditor-CV8R1G3W.js.map +0 -1
  34. package/dist/SQLEditor-BmI0iIWK.js.map +0 -1
  35. package/dist/SchemaVisualizer-vxKSG80C.js.map +0 -1
  36. package/dist/StorageView-CwDI4sBG.js.map +0 -1
@@ -0,0 +1,74 @@
1
+ export type PolicyCommand = "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE";
2
+ export declare const COMMAND_OPTIONS: PolicyCommand[];
3
+ /**
4
+ * The roles a policy's `TO` list can name when nothing better is known.
5
+ *
6
+ * Not `authenticated` / `anon` / `admin`, which is what this list used to be.
7
+ * The first two are Supabase's role names and Rebase never creates them, so
8
+ * `CREATE POLICY ... TO authenticated` — which is literally what the editor
9
+ * builds and executes — fails with `role "authenticated" does not exist`. The
10
+ * driver's `validatePolicyPgRoles` rejects the same three names in
11
+ * `SecurityRule.pgRoles` and says why: they are another platform's convention,
12
+ * and application roles belong in a condition, not in the `TO` list.
13
+ *
14
+ * `admin` was the more dangerous entry, because it is a plausible *application*
15
+ * role. Had one existed as a database role too, the policy would have been
16
+ * created successfully and then matched nothing at all: requests run as
17
+ * {@link REBASE_USER_ROLE} after a `SET LOCAL ROLE`, and a `TO` list naming a
18
+ * role the request never assumes filters every row without erroring.
19
+ *
20
+ * So: `public` (what the framework's own generator emits) and the role requests
21
+ * actually arrive as. "Signed-in users" and "admins" are not roles here — they
22
+ * are conditions over `rebase.uid()` and `rebase.roles()`, which is what the
23
+ * presets below now compile to.
24
+ */
25
+ export declare const FALLBACK_ROLE_OPTIONS: string[];
26
+ /**
27
+ * The `TO` list the editor offers, given what the database reported and what
28
+ * the policy being edited already targets.
29
+ *
30
+ * Three sources, and each is there for a reason the others do not cover:
31
+ *
32
+ * - {@link FALLBACK_ROLE_OPTIONS} always, because `public` is a **keyword, not
33
+ * a row in `pg_roles`** — `fetchAvailableRoles` cannot return it, so seeding
34
+ * from the live fetch alone would drop the one role every generated policy
35
+ * targets.
36
+ * - the live roles, so an operator's own `app_read` is reachable without
37
+ * hand-editing SQL.
38
+ * - the edited policy's own roles, because a `MultiSelect` silently drops a
39
+ * value with no matching item. That value is the `TO` list of a policy
40
+ * already enforcing something, so opening an unrelated policy for a one-word
41
+ * rename would have quietly rewritten who it applies to.
42
+ */
43
+ export declare function roleOptionsFor(fetched: readonly string[] | undefined, policyRoles: readonly string[] | undefined): string[];
44
+ /**
45
+ * Preset conditions, compiled by the framework's own policy compiler rather
46
+ * than written out here.
47
+ *
48
+ * `policyToPostgres` is the function `db push` generates policies with, so a
49
+ * preset cannot drift from what the framework emits — and the one that matters
50
+ * has drifted before: `authenticated()` used to compile to a bare
51
+ * `IS NOT NULL`, which is true for anonymous visitors, and any copy of that
52
+ * string sitting in a preset would still be handing out the old grant today.
53
+ */
54
+ export declare const SIGNED_IN_SQL: string;
55
+ export declare const IS_ADMIN_SQL: string;
56
+ export declare const OWNS_ROW_SQL: string;
57
+ export interface PolicyPreset {
58
+ id: string;
59
+ label: string;
60
+ description: string;
61
+ policyname: string;
62
+ cmd: PolicyCommand;
63
+ permissive: "PERMISSIVE" | "RESTRICTIVE";
64
+ roles: string[];
65
+ qual: string;
66
+ with_check: string;
67
+ }
68
+ /**
69
+ * Every preset targets `TO public`, which is what the framework's generator
70
+ * emits and what actually reaches a request running as `rebase_user`. The part
71
+ * that used to be expressed as a role — "authenticated", "admin" — is a
72
+ * condition now, so it is enforced where Postgres will actually evaluate it.
73
+ */
74
+ export declare const POLICY_PRESETS: PolicyPreset[];
package/dist/index.es.js CHANGED
@@ -549,15 +549,15 @@ function SyntaxHighlightedSnippet() {
549
549
  }
550
550
  //#endregion
551
551
  //#region src/components/RebaseStudio.tsx
552
- var SQLEditor = lazyChunk(() => import("./SQLEditor-BmI0iIWK.js").then((m) => ({ default: m.SQLEditor })));
553
- var JSEditor = lazyChunk(() => import("./JSEditor-BaSrzWhF.js").then((m) => ({ default: m.JSEditor })));
554
- var RLSEditor = lazyChunk(() => import("./RLSEditor-CV8R1G3W.js").then((m) => ({ default: m.RLSEditor })));
555
- var StorageView = lazyChunk(() => import("./StorageView-CwDI4sBG.js").then((m) => ({ default: m.StorageView })));
552
+ var SQLEditor = lazyChunk(() => import("./SQLEditor-ByecwX8B.js").then((m) => ({ default: m.SQLEditor })));
553
+ var JSEditor = lazyChunk(() => import("./JSEditor-BZnvK1n6.js").then((m) => ({ default: m.JSEditor })));
554
+ var RLSEditor = lazyChunk(() => import("./RLSEditor-CBX4uD66.js").then((m) => ({ default: m.RLSEditor })));
555
+ var StorageView = lazyChunk(() => import("./StorageView-CGm-cacx.js").then((m) => ({ default: m.StorageView })));
556
556
  var CronJobsView = lazyChunk(() => import("./CronJobsView-BHtJZeJZ.js").then((m) => ({ default: m.CronJobsView })));
557
- var SchemaVisualizer = lazyChunk(() => import("./SchemaVisualizer-vxKSG80C.js").then((m) => ({ default: m.SchemaVisualizer })));
557
+ var SchemaVisualizer = lazyChunk(() => import("./SchemaVisualizer-D6tMZgeC.js").then((m) => ({ default: m.SchemaVisualizer })));
558
558
  var BranchesView = lazyChunk(() => import("./BranchesView-CM3B-ER3.js").then((m) => ({ default: m.BranchesView })));
559
559
  var BackupsView = lazyChunk(() => import("./BackupsView-DNS6LdVg.js").then((m) => ({ default: m.BackupsView })));
560
- var ApiExplorer = lazyChunk(() => import("./ApiExplorer-CDOlUQzk.js").then((m) => ({ default: m.ApiExplorer })));
560
+ var ApiExplorer = lazyChunk(() => import("./ApiExplorer-9iwGvNnt.js").then((m) => ({ default: m.ApiExplorer })));
561
561
  var LogsExplorer = lazyChunk(() => import("./LogsExplorer-DI8SVpya.js").then((m) => ({ default: m.LogsExplorer })));
562
562
  var ApiKeysView = lazyChunk(() => import("./ApiKeysView-C_UoUPuR.js").then((m) => ({ default: m.ApiKeysView })));
563
563
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/studio",
3
3
  "type": "module",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.es.js",
7
7
  "module": "./dist/index.es.js",
@@ -16,19 +16,19 @@
16
16
  "pgsql-ast-parser": "12.0.2",
17
17
  "prism-react-renderer": "^2.4.1",
18
18
  "react-dropzone": "^19.1.1",
19
- "@rebasepro/app": "0.15.0",
20
- "@rebasepro/types": "0.15.0",
21
- "@rebasepro/admin-types": "0.15.0",
22
- "@rebasepro/client": "0.15.0",
23
- "@rebasepro/ui": "0.15.0",
24
- "@rebasepro/common": "0.15.0",
25
- "@rebasepro/utils": "0.15.0"
19
+ "@rebasepro/app": "0.16.0",
20
+ "@rebasepro/client": "0.16.0",
21
+ "@rebasepro/common": "0.16.0",
22
+ "@rebasepro/ui": "0.16.0",
23
+ "@rebasepro/types": "0.16.0",
24
+ "@rebasepro/admin-types": "0.16.0",
25
+ "@rebasepro/utils": "0.16.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=19.2.7",
29
29
  "react-dom": ">=19.2.7",
30
30
  "react-router": "^8.3.0",
31
- "@rebasepro/admin": "0.15.0"
31
+ "@rebasepro/admin": "0.16.0"
32
32
  },
33
33
  "peerDependenciesMeta": {
34
34
  "@rebasepro/admin": {
@@ -210,7 +210,7 @@ allEndpoints: [] };
210
210
  <span className="truncate text-[13px] opacity-90">{ep.summary || ep.shortPath}</span>
211
211
  <span
212
212
  className={cls(
213
- "text-[10px] font-bold uppercase shrink-0",
213
+ "text-[10px] font-semibold uppercase shrink-0",
214
214
  METHOD_COLORS[ep.method] ?? "text-text-secondary"
215
215
  )}
216
216
  >
@@ -247,7 +247,7 @@ allEndpoints: [] };
247
247
  <div className="flex items-center gap-3 min-w-0">
248
248
  <span
249
249
  className={cls(
250
- "text-xs font-bold uppercase",
250
+ "text-xs font-semibold uppercase",
251
251
  METHOD_COLORS[selectedEndpoint.method] ?? ""
252
252
  )}
253
253
  >
@@ -275,5 +275,5 @@ function StatusBadge({ code }: { code: string }) {
275
275
  ? "text-amber-600 dark:text-amber-400"
276
276
  : "text-red-600 dark:text-red-400";
277
277
 
278
- return <span className={cls("text-xs font-bold font-mono", color)}>{code}</span>;
278
+ return <span className={cls("text-xs font-semibold font-mono", color)}>{code}</span>;
279
279
  }
@@ -456,7 +456,7 @@ function ResponseBadge({ status }: { status: number }) {
456
456
  else if (status >= 400 && status < 500) colorClass = "text-amber-500";
457
457
 
458
458
  return (
459
- <span className={cls("text-xs font-bold font-mono", colorClass)}>
459
+ <span className={cls("text-xs font-semibold font-mono", colorClass)}>
460
460
  {status || "ERR"}
461
461
  </span>
462
462
  );
@@ -778,7 +778,7 @@ message: t("studio_sql_markdown_copy_failed") });
778
778
  <div className="h-full w-full flex flex-col bg-surface-50 dark:bg-surface-950 overflow-hidden min-h-0">
779
779
  {/* Result header — matches SQL editor */}
780
780
  <div className={cls("p-2 px-4 bg-surface-100 dark:bg-surface-900 border-b shrink-0 flex items-center", defaultBorderMixin)}>
781
- <Typography variant="caption" className="font-bold text-text-disabled dark:text-text-disabled-dark uppercase tracking-widest text-[10px]">
781
+ <Typography variant="caption" className="font-semibold text-text-disabled dark:text-text-disabled-dark uppercase tracking-widest text-[10px]">
782
782
  {t("studio_sql_query_results")}
783
783
  </Typography>
784
784
 
@@ -846,7 +846,7 @@ message: t("studio_sql_markdown_copy_failed") });
846
846
  {matchedCollections.length > 0 && (
847
847
  <div className={cls("px-4 py-1.5 border-b flex items-center gap-2 shrink-0 bg-surface-50 dark:bg-surface-900", defaultBorderMixin)}>
848
848
  <Tooltip title={t("studio_sql_admin_collections_tooltip")}>
849
- <Typography variant="caption" className="text-[10px] font-bold uppercase tracking-widest text-text-disabled dark:text-text-disabled-dark mr-1 shrink-0 cursor-help">{t("studio_sql_collections_label")}</Typography>
849
+ <Typography variant="caption" className="text-[10px] font-semibold uppercase tracking-widest text-text-disabled dark:text-text-disabled-dark mr-1 shrink-0 cursor-help">{t("studio_sql_collections_label")}</Typography>
850
850
  </Tooltip>
851
851
  <div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
852
852
  {matchedCollections.map(mc => (
@@ -1002,11 +1002,11 @@ id: String(ra.entityId) })}
1002
1002
  <div className={cls("p-2 px-4 border-t bg-surface-50 dark:bg-surface-900 flex justify-between items-center shrink-0", defaultBorderMixin)}>
1003
1003
  <div className="flex space-x-4">
1004
1004
  <div className="flex items-center text-[11px]">
1005
- <span className="font-bold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter">{t("studio_sql_rows")}</span>
1005
+ <span className="font-semibold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter">{t("studio_sql_rows")}</span>
1006
1006
  <span className="font-mono text-text-secondary dark:text-text-secondary-dark">{tableData.data.length}</span>
1007
1007
  </div>
1008
1008
  <div className="flex items-center text-[11px]">
1009
- <span className="font-bold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter">{t("studio_sql_time")}</span>
1009
+ <span className="font-semibold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter">{t("studio_sql_time")}</span>
1010
1010
  <span className="font-mono text-text-secondary dark:text-text-secondary-dark">{result.duration.toFixed(0)}ms</span>
1011
1011
  </div>
1012
1012
  </div>
@@ -1014,7 +1014,7 @@ id: String(ra.entityId) })}
1014
1014
  <Button
1015
1015
  size="small"
1016
1016
  variant="text"
1017
- className="text-[10px] uppercase font-bold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1017
+ className="text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1018
1018
  onClick={handleExportMarkdown}
1019
1019
  >
1020
1020
  {t("studio_sql_copy_markdown")}
@@ -1022,7 +1022,7 @@ id: String(ra.entityId) })}
1022
1022
  <Button
1023
1023
  size="small"
1024
1024
  variant="text"
1025
- className="text-[10px] uppercase font-bold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1025
+ className="text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1026
1026
  onClick={exportResult}
1027
1027
  >
1028
1028
  {t("studio_sql_export_json")}
@@ -1030,7 +1030,7 @@ id: String(ra.entityId) })}
1030
1030
  <Button
1031
1031
  size="small"
1032
1032
  variant="text"
1033
- className="text-[10px] uppercase font-bold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1033
+ className="text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1034
1034
  onClick={handleExportCSV}
1035
1035
  >
1036
1036
  {t("studio_sql_export_csv")}
@@ -119,7 +119,7 @@ export const JSEditorSidebar = ({
119
119
  {activeTab === "collections" && (
120
120
  <div className="flex flex-col h-full">
121
121
  <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)}>
122
- <Typography variant="caption" className="font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
122
+ <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
123
123
  Collections
124
124
  </Typography>
125
125
  </div>
@@ -147,7 +147,7 @@ export const JSEditorSidebar = ({
147
147
  {activeTab === "reference" && (
148
148
  <div className="flex flex-col h-full">
149
149
  <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)}>
150
- <Typography variant="caption" className="font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
150
+ <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
151
151
  SDK Reference
152
152
  </Typography>
153
153
  </div>
@@ -187,7 +187,7 @@ export const JSEditorSidebar = ({
187
187
  return (
188
188
  <div className="flex flex-col h-full">
189
189
  <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)}>
190
- <Typography variant="caption" className="font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">Snippets</Typography>
190
+ <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">Snippets</Typography>
191
191
  </div>
192
192
  <div className="flex-grow overflow-y-auto p-2 space-y-2 no-scrollbar">
193
193
  {snippets.length === 0 ? (
@@ -200,7 +200,7 @@ export const JSEditorSidebar = ({
200
200
  <>
201
201
  {favorites.length > 0 && (
202
202
  <div className="mb-3">
203
- <Typography variant="caption" className="text-[10px] font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark mb-2 px-1 flex items-center">
203
+ <Typography variant="caption" className="text-[10px] font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark mb-2 px-1 flex items-center">
204
204
  <svg className="w-3 h-3 mr-1 text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>
205
205
  Favorites
206
206
  </Typography>
@@ -214,7 +214,7 @@ export const JSEditorSidebar = ({
214
214
  {others.length > 0 && (
215
215
  <div>
216
216
  {favorites.length > 0 && (
217
- <Typography variant="caption" className="text-[10px] font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark mb-2 px-1">
217
+ <Typography variant="caption" className="text-[10px] font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark mb-2 px-1">
218
218
  Others
219
219
  </Typography>
220
220
  )}
@@ -236,7 +236,7 @@ export const JSEditorSidebar = ({
236
236
  {activeTab === "history" && (
237
237
  <div className="flex flex-col h-full">
238
238
  <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)}>
239
- <Typography variant="caption" className="font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">History</Typography>
239
+ <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">History</Typography>
240
240
  </div>
241
241
  <div className="flex-grow overflow-y-auto p-1 space-y-1 no-scrollbar">
242
242
  {history.length === 0 ? (
@@ -1,5 +1,4 @@
1
-
2
- import React, { useState, useEffect } from "react";
1
+ import React, { useEffect, useMemo, useState } from "react";
3
2
  import {
4
3
  Button,
5
4
  cls,
@@ -20,6 +19,13 @@ import {
20
19
  Typography
21
20
  } from "@rebasepro/ui";
22
21
  import { useTranslation } from "@rebasepro/app";
22
+ import { RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL } from "@rebasepro/types";
23
+ import {
24
+ COMMAND_OPTIONS,
25
+ POLICY_PRESETS,
26
+ roleOptionsFor,
27
+ type PolicyCommand
28
+ } from "./policy-presets";
23
29
  import { MonacoEditor } from "../SQLEditor/MonacoEditor";
24
30
  import type { PostgresPolicy } from "./RLSEditor";
25
31
 
@@ -27,99 +33,23 @@ export interface PolicyEditorProps {
27
33
  policy?: PostgresPolicy;
28
34
  schema: string;
29
35
  table: string;
36
+ /**
37
+ * Native PostgreSQL roles this database actually has, for the `TO` list.
38
+ *
39
+ * Supplied by the caller from `fetchAvailableRoles()`. Merged with the
40
+ * always-offered defaults by `roleOptionsFor`, never used in place of them:
41
+ * `public` is a keyword and never comes back from that query.
42
+ */
43
+ roleOptions?: readonly string[];
30
44
  onSave: (policyData: Partial<PostgresPolicy>) => void;
31
45
  onCancel: () => void;
32
46
  }
33
47
 
34
- type PolicyCommand = "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE";
35
- const COMMAND_OPTIONS: PolicyCommand[] = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"];
36
- const ROLE_OPTIONS = ["public", "authenticated", "anon", "admin"];
37
-
38
- interface PolicyPreset {
39
- id: string;
40
- label: string;
41
- description: string;
42
- policyname: string;
43
- cmd: PolicyCommand;
44
- permissive: "PERMISSIVE" | "RESTRICTIVE";
45
- roles: string[];
46
- qual: string;
47
- with_check: string;
48
- }
49
-
50
- const POLICY_PRESETS: PolicyPreset[] = [
51
- {
52
- id: "public_read",
53
- label: "Enable read access to everyone",
54
- description: "Anyone can read data, regardless of authentication status.",
55
- policyname: "Enable read access for all users",
56
- cmd: "SELECT",
57
- permissive: "PERMISSIVE",
58
- roles: ["public"],
59
- qual: "true",
60
- with_check: ""
61
- },
62
- {
63
- id: "auth_read",
64
- label: "Enable read access for authenticated users only",
65
- description: "Only logged-in users are allowed to read data.",
66
- policyname: "Enable read access for authenticated users",
67
- cmd: "SELECT",
68
- permissive: "PERMISSIVE",
69
- roles: ["authenticated"],
70
- qual: "true",
71
- with_check: ""
72
- },
73
- {
74
- id: "auth_insert",
75
- label: "Enable insert for authenticated users only",
76
- description: "Only logged-in users are allowed to insert new data.",
77
- policyname: "Enable insert for authenticated users only",
78
- cmd: "INSERT",
79
- permissive: "PERMISSIVE",
80
- roles: ["authenticated"],
81
- qual: "",
82
- with_check: "true"
83
- },
84
- {
85
- id: "user_select_own",
86
- label: "Users can read their own rows",
87
- description: "Users can only read rows where the uid matches their auth.uid()",
88
- policyname: "Users can select their own data",
89
- cmd: "SELECT",
90
- permissive: "PERMISSIVE",
91
- roles: ["authenticated"],
92
- qual: "auth.uid() = uid",
93
- with_check: ""
94
- },
95
- {
96
- id: "user_update_own",
97
- label: "Users can update their own rows",
98
- description: "Users can only update rows where the uid matches their auth.uid()",
99
- policyname: "Users can update their own data",
100
- cmd: "UPDATE",
101
- permissive: "PERMISSIVE",
102
- roles: ["authenticated"],
103
- qual: "auth.uid() = uid",
104
- with_check: "auth.uid() = uid"
105
- },
106
- {
107
- id: "user_delete_own",
108
- label: "Users can delete their own rows",
109
- description: "Users can only delete rows where the uid matches their auth.uid()",
110
- policyname: "Users can delete their own data",
111
- cmd: "DELETE",
112
- permissive: "PERMISSIVE",
113
- roles: ["authenticated"],
114
- qual: "auth.uid() = uid",
115
- with_check: ""
116
- }
117
- ];
118
-
119
48
  export const PolicyEditor = ({
120
49
  policy,
121
50
  schema,
122
51
  table,
52
+ roleOptions,
123
53
  onSave,
124
54
  onCancel
125
55
  }: PolicyEditorProps) => {
@@ -135,6 +65,11 @@ export const PolicyEditor = ({
135
65
 
136
66
  const [selectedPreset, setSelectedPreset] = useState<string>("");
137
67
 
68
+ const availableRoles = useMemo(
69
+ () => roleOptionsFor(roleOptions, policy?.roles),
70
+ [roleOptions, policy]
71
+ );
72
+
138
73
  useEffect(() => {
139
74
  if (policy) {
140
75
  setName(policy.policyname || "");
@@ -300,7 +235,7 @@ export const PolicyEditor = ({
300
235
  onValueChange={setRoles}
301
236
  placeholder={t("studio_policy_roles_placeholder")}
302
237
  >
303
- {ROLE_OPTIONS.map(role => (
238
+ {availableRoles.map(role => (
304
239
  <MultiSelectItem key={role} value={role}>
305
240
  {role}
306
241
  </MultiSelectItem>
@@ -396,7 +331,7 @@ export const PolicyEditor = ({
396
331
  {t("studio_policy_help_step3_desc")}
397
332
  </Typography>
398
333
  <div className={cls("bg-surface-100 dark:bg-surface-950 px-3 py-2 rounded-md font-mono text-sm my-2", defaultBorderMixin)}>
399
- Example: auth.uid() = uid
334
+ Example: {RLS_UID_SQL} = uid
400
335
  </div>
401
336
  <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
402
337
  {t("studio_policy_help_step3_example")}
@@ -409,7 +344,7 @@ export const PolicyEditor = ({
409
344
  {t("studio_policy_help_step4_desc")}
410
345
  </Typography>
411
346
  <div className={cls("bg-surface-100 dark:bg-surface-950 px-3 py-2 rounded-md font-mono text-sm my-2", defaultBorderMixin)}>
412
- Example: auth.uid() = uid
347
+ Example: {RLS_UID_SQL} = uid
413
348
  </div>
414
349
  <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
415
350
  {t("studio_policy_help_step4_example")}
@@ -423,16 +358,16 @@ export const PolicyEditor = ({
423
358
  </Typography>
424
359
  <ul className="list-disc pl-5 space-y-2 text-sm text-text-secondary dark:text-text-secondary-dark font-normal">
425
360
  <li>
426
- <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">auth.uid()</code>
427
- <span className="block mt-0.5">Returns the current user&apos;s ID as text. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">auth.uid() = uid</code></span>
361
+ <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">{RLS_UID_SQL}</code>
362
+ <span className="block mt-0.5">Returns the current user&apos;s ID as text. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">{RLS_UID_SQL} = uid</code></span>
428
363
  </li>
429
364
  <li>
430
- <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">auth.jwt()</code>
431
- <span className="block mt-0.5">Returns the full JWT payload as JSONB so you can check custom claims. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">auth.jwt() -&gt;&gt; &apos;email&apos; = &apos;admin@example.com&apos;</code></span>
365
+ <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">{RLS_JWT_SQL}</code>
366
+ <span className="block mt-0.5">Returns the full JWT payload as JSONB so you can check custom claims. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">{RLS_JWT_SQL} -&gt;&gt; &apos;email&apos; = &apos;admin@example.com&apos;</code></span>
432
367
  </li>
433
368
  <li>
434
- <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">auth.roles()</code>
435
- <span className="block mt-0.5">Returns the user&apos;s role IDs as a comma-separated string. Best used with: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">string_to_array(auth.roles(), &apos;,&apos;) @&gt; ARRAY[&apos;admin&apos;]</code></span>
369
+ <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">{RLS_ROLES_SQL}</code>
370
+ <span className="block mt-0.5">Returns the user&apos;s role IDs as a comma-separated string. Best used with: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">string_to_array({RLS_ROLES_SQL}, &apos;,&apos;) @&gt; ARRAY[&apos;admin&apos;]</code></span>
436
371
  </li>
437
372
  </ul>
438
373
  </Paper>
@@ -181,6 +181,25 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
181
181
 
182
182
  const [editingPolicy, setEditingPolicy] = useState<PostgresPolicy | "new" | null>(null);
183
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
+
184
203
  const [sidebarSize, setSidebarSize] = useState(() => {
185
204
  try {
186
205
  const saved = localStorage.getItem("rebase_rls_editor_sidebar_size");
@@ -428,7 +447,14 @@ status: "live" };
428
447
  tablename: activeTableData.tableName,
429
448
  permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
430
449
  cmd: (ops[opIdx] ?? rule.operation ?? "ALL").toUpperCase() as PostgresPolicy["cmd"],
431
- roles: [...(rule.roles ?? ["public"])],
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"])],
432
458
  qual: rule.using || null,
433
459
  with_check: rule.withCheck || null,
434
460
  // "both" = defined in code and live in Postgres (potentially edited)
@@ -503,7 +529,7 @@ totalPolicies };
503
529
  {sidebarTab === "tables" && (
504
530
  <div className="flex flex-col h-full">
505
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)}>
506
- <Typography variant="caption" className="font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
532
+ <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
507
533
  {t("studio_schema_tables")}
508
534
  </Typography>
509
535
  <IconButton size="small" onClick={fetchRLSData} title="Refresh">
@@ -600,7 +626,7 @@ totalPolicies };
600
626
  {sidebarTab === "info" && (
601
627
  <div className="flex flex-col h-full">
602
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)}>
603
- <Typography variant="caption" className="font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
629
+ <Typography variant="caption" className="font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
604
630
  Overview
605
631
  </Typography>
606
632
  </div>
@@ -759,6 +785,7 @@ message: e instanceof Error ? e.message : String(e) });
759
785
  policy={editingPolicy === "new" ? undefined : editingPolicy}
760
786
  schema={activeTableData.schemaName}
761
787
  table={activeTableData.tableName}
788
+ roleOptions={pgRoleOptions}
762
789
  onSave={async (newPolicy) => {
763
790
  /*
764
791
  * Where a policy for a *mapped* table belongs depends
@@ -787,7 +814,22 @@ message: e instanceof Error ? e.message : String(e) });
787
814
  mode: newPolicy.permissive?.toLowerCase(),
788
815
  using: newPolicy.qual || undefined,
789
816
  withCheck: newPolicy.with_check || undefined,
790
- roles: newPolicy.roles
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
+ : {})
791
833
  };
792
834
 
793
835
  const existingRules = (isPostgresCollectionConfig(activeCollection) ? activeCollection.securityRules : undefined) || [];