@rebasepro/admin 0.12.0 → 0.12.1-canary.g06f263c

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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog--VwQl4pk.js} +75 -62
  3. package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog--VwQl4pk.js.map} +1 -1
  4. package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-QaEYldcT.js} +12 -9
  5. package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-QaEYldcT.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-DvRSUk9o.js} +5 -5
  7. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-DvRSUk9o.js.map} +1 -1
  8. package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +8 -0
  9. package/dist/collection_editor_ui.js +5 -5
  10. package/dist/collection_editor_ui.js.map +1 -1
  11. package/dist/editor.js +18 -10
  12. package/dist/editor.js.map +1 -1
  13. package/dist/{export-BzIpUdSK.js → export-BZZfQkT5.js} +2 -2
  14. package/dist/{export-BzIpUdSK.js.map → export-BZZfQkT5.js.map} +1 -1
  15. package/dist/{history-g9688IaK.js → history-Ao1HBAK3.js} +2 -2
  16. package/dist/{history-g9688IaK.js.map → history-Ao1HBAK3.js.map} +1 -1
  17. package/dist/{import-BjUG-cIY.js → import-AFJRUo99.js} +2 -2
  18. package/dist/{import-BjUG-cIY.js.map → import-AFJRUo99.js.map} +1 -1
  19. package/dist/index.js +105 -34
  20. package/dist/index.js.map +1 -1
  21. package/dist/markdown-CklalUUk.js.map +1 -1
  22. package/dist/{util-B5fJm1FA.js → util-ByPlsapJ.js} +103 -61
  23. package/dist/util-ByPlsapJ.js.map +1 -0
  24. package/package.json +32 -34
  25. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  26. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
  27. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
  28. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
  29. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
  30. package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
  31. package/src/components/AdminModeSyncer.tsx +1 -1
  32. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +1 -1
  33. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +1 -1
  34. package/src/components/CollectionViewBinding/SplitListView.tsx +1 -1
  35. package/src/components/DefaultAppBar.tsx +1 -1
  36. package/src/components/DefaultDrawer.tsx +1 -1
  37. package/src/components/DetailViewBinding.tsx +1 -1
  38. package/src/components/DrawerNavigationItem.tsx +1 -1
  39. package/src/components/EditViewBinding.tsx +1 -1
  40. package/src/components/HomePage/FavouritesView.tsx +1 -1
  41. package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
  42. package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
  43. package/src/components/RebaseLayout.tsx +1 -1
  44. package/src/components/RebaseNavigation.tsx +45 -5
  45. package/src/components/RebaseRouteDefs.tsx +1 -1
  46. package/src/components/SidePanelBinding.tsx +1 -1
  47. package/src/components/UserSelector.tsx +6 -5
  48. package/src/components/common/default_entity_actions.tsx +4 -3
  49. package/src/components/history/LastEditedByIndicator.tsx +4 -3
  50. package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
  51. package/src/hooks/useBuildSideDialogsController.tsx +2 -2
  52. package/src/hooks/useBuildSidePanel.tsx +1 -1
  53. package/src/hooks/useHistory.ts +6 -5
  54. package/src/hooks/useResolvedUsers.ts +8 -5
  55. package/src/routes/RebaseRoute.tsx +2 -2
  56. package/dist/util-B5fJm1FA.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/admin",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1-canary.g06f263c",
5
5
  "description": "Rebase CMS — content management views, forms, and routing",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -18,7 +18,7 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": ">=22.22.0"
22
22
  },
23
23
  "keywords": [
24
24
  "rebase",
@@ -54,66 +54,64 @@
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
56
  "@dnd-kit/utilities": "^3.2.2",
57
- "@floating-ui/dom": "^1.7.6",
57
+ "@floating-ui/dom": "^1.8.0",
58
58
  "cmdk": "^1.1.1",
59
59
  "date-fns": "^4.4.0",
60
60
  "exceljs": "^4.4.0",
61
- "fast-equals": "6.0.0",
62
- "fractional-indexing": "3.2.0",
63
- "fuse.js": "^7.4.2",
61
+ "fast-equals": "6.0.2",
62
+ "fractional-indexing": "4.0.0",
63
+ "fuse.js": "^7.5.0",
64
64
  "json5": "^2.2.3",
65
- "markdown-it": "^14.2.0",
65
+ "markdown-it": "^14.3.0",
66
66
  "markdown-it-ins": "^4.0.0",
67
67
  "markdown-it-mark": "^4.0.0",
68
68
  "markdown-it-task-lists": "^2.1.1",
69
69
  "prism-react-renderer": "^2.4.1",
70
70
  "prosemirror-commands": "^1.7.1",
71
- "prosemirror-dropcursor": "^1.8.2",
71
+ "prosemirror-dropcursor": "^1.8.3",
72
72
  "prosemirror-gapcursor": "^1.4.1",
73
73
  "prosemirror-history": "^1.5.0",
74
74
  "prosemirror-inputrules": "^1.5.1",
75
75
  "prosemirror-keymap": "^1.2.3",
76
- "prosemirror-markdown": "^1.13.4",
77
- "prosemirror-model": "^1.25.8",
76
+ "prosemirror-markdown": "^1.13.5",
77
+ "prosemirror-model": "^1.25.11",
78
78
  "prosemirror-schema-list": "^1.5.1",
79
79
  "prosemirror-state": "^1.4.4",
80
80
  "prosemirror-tables": "^1.8.5",
81
81
  "prosemirror-transform": "^1.12.0",
82
- "prosemirror-view": "^1.41.9",
83
- "react-dropzone": "^15.0.0",
82
+ "prosemirror-view": "^1.42.2",
83
+ "react-dropzone": "^19.1.1",
84
84
  "react-use-measure": "^2.1.7",
85
85
  "zod": "^4.4.3",
86
- "@rebasepro/admin-types": "0.12.0",
87
- "@rebasepro/app": "0.12.0",
88
- "@rebasepro/common": "0.12.0",
89
- "@rebasepro/forms": "0.12.0",
90
- "@rebasepro/types": "0.12.0",
91
- "@rebasepro/inference": "0.12.0",
92
- "@rebasepro/ui": "0.12.0",
93
- "@rebasepro/utils": "0.12.0"
86
+ "@rebasepro/app": "0.12.1-canary.g06f263c",
87
+ "@rebasepro/admin-types": "0.12.1-canary.g06f263c",
88
+ "@rebasepro/forms": "0.12.1-canary.g06f263c",
89
+ "@rebasepro/common": "0.12.1-canary.g06f263c",
90
+ "@rebasepro/inference": "0.12.1-canary.g06f263c",
91
+ "@rebasepro/types": "0.12.1-canary.g06f263c",
92
+ "@rebasepro/ui": "0.12.1-canary.g06f263c",
93
+ "@rebasepro/utils": "0.12.1-canary.g06f263c"
94
94
  },
95
95
  "peerDependencies": {
96
- "react": ">=19.0.0",
97
- "react-dom": ">=19.0.0",
98
- "react-router": "^7.0.0",
99
- "react-router-dom": "^7.0.0"
96
+ "react": ">=19.2.7",
97
+ "react-dom": ">=19.2.7",
98
+ "react-router": "^8.3.0"
100
99
  },
101
100
  "devDependencies": {
102
101
  "@jest/globals": "^30.4.1",
103
102
  "@testing-library/react": "^16.3.2",
104
103
  "@types/jest": "^30.0.0",
105
104
  "@types/markdown-it": "^14.1.2",
106
- "@types/node": "^25.9.3",
105
+ "@types/node": "^26.1.2",
107
106
  "@types/react": "^19.2.17",
108
107
  "@types/react-dom": "^19.2.3",
109
108
  "@types/react-measure": "^2.0.12",
110
- "@vitejs/plugin-react": "^6.0.2",
109
+ "@vitejs/plugin-react": "^6.0.4",
111
110
  "jest": "^30.4.2",
112
- "react-router": "^7.17.0",
113
- "react-router-dom": "^7.17.0",
114
- "ts-jest": "^29.4.11",
111
+ "react-router": "^8.3.0",
112
+ "ts-jest": "^29.4.12",
115
113
  "typescript": "^6.0.3",
116
- "vite": "^8.0.16"
114
+ "vite": "^8.1.5"
117
115
  },
118
116
  "files": [
119
117
  "dist",
@@ -131,13 +129,15 @@
131
129
  },
132
130
  "jest": {
133
131
  "transform": {
134
- "^.+\\.[jt]sx?$": "ts-jest"
132
+ "^.+\\.tsx?$": "ts-jest",
133
+ "^.+\\.m?jsx?$": "<rootDir>/../../scripts/jest/react-router-esm-transform.cjs"
135
134
  },
136
135
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
137
136
  "moduleFileExtensions": [
138
137
  "ts",
139
138
  "tsx",
140
139
  "js",
140
+ "mjs",
141
141
  "jsx",
142
142
  "json",
143
143
  "node"
@@ -146,9 +146,7 @@
146
146
  "testPathIgnorePatterns": [
147
147
  "/node_modules/"
148
148
  ],
149
- "transformIgnorePatterns": [
150
- "node_modules/(?!.*fractional-indexing)/"
151
- ],
149
+ "transformIgnorePatterns": [],
152
150
  "setupFilesAfterEnv": [
153
151
  "<rootDir>/test/setupTests.ts"
154
152
  ],
@@ -6,7 +6,7 @@ import { deepEqual as equal } from "fast-equals";
6
6
  import { CollectionsConfigController } from "./types/config_controller";
7
7
  import { useCustomizationController, useRebaseContext, useAuthController, useSnackbarController } from "@rebasepro/app";
8
8
  import { getTableName } from "@rebasepro/common";
9
- import { useNavigate } from "react-router-dom";
9
+ import { useNavigate } from "react-router";
10
10
  import { CollectionEditorController } from "./types/collection_editor_controller";
11
11
  import { CollectionInference } from "./types/collection_inference";
12
12
  import { CollectionGenerationCallback } from "./api/generateCollectionApi";
@@ -30,7 +30,8 @@ import {
30
30
  IconButton,
31
31
  LoadingButton,
32
32
  Tab,
33
- Tabs
33
+ Tabs,
34
+ Tooltip
34
35
  } from "@rebasepro/ui";
35
36
  import { EngineProperties, Entity, getDataSourceCapabilities, MapProperty, Properties, Property, TableMetadata, User } from "@rebasepro/types";
36
37
  import { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
@@ -254,11 +255,38 @@ export function CollectionEditor(props: CollectionEditorDialogProps & {
254
255
  ? applyPropertyConfigs(initialCollection, propertyConfigs)
255
256
  : copyFromProp
256
257
  ? (() => {
257
- // When duplicating, copy all properties but clear identifiers
258
- const { subcollections: _sub, ...rest } = copyFromProp as unknown as Record<string, unknown>;
258
+ // When duplicating, copy all properties but clear identifiers.
259
+ //
260
+ // "Identifiers" has to include the ones this dialog cannot
261
+ // show. `schema` has no field anywhere in the editor, so a
262
+ // duplicate of the Users collection — the one collection
263
+ // scaffolded with `schema: "rebase"` — silently produced a
264
+ // new collection whose table is created in `rebase` rather
265
+ // than `public`, with nothing on screen to explain why.
266
+ // Panel-authored collections are `public` collections.
267
+ //
268
+ // `slug`/`table` are cleared rather than carried: both are
269
+ // re-derived from the name the user types (see
270
+ // `updateName` in GeneralSettingsForm, which fills untouched
271
+ // fields while `isNewCollection`), and carrying them meant a
272
+ // duplicate pointed at the source's table until then.
273
+ //
274
+ // `auth` goes too — a duplicate must not become a second
275
+ // auth collection wired to the same user store.
276
+ const {
277
+ subcollections: _sub,
278
+ schema: _schema,
279
+ auth: _auth,
280
+ ...rest
281
+ } = copyFromProp as unknown as Record<string, unknown>;
259
282
  return {
260
283
  ...rest,
261
284
  name: "",
285
+ // Same shape the "new collection" branch below starts
286
+ // from, so validation and the registry lookups never see
287
+ // an undefined slug mid-edit.
288
+ slug: fallbackSlug,
289
+ table: "",
262
290
  ownerId: authController.user?.uid ?? ""
263
291
  } as AdminCollection<any>;
264
292
  })()
@@ -341,6 +369,10 @@ function CollectionEditorInternal<M extends Record<string, unknown>>({
341
369
  }
342
370
  ) {
343
371
 
372
+ /** Why the submit buttons below are dead, straight from the backend. */
373
+ const readOnlyTitle = configController?.readOnlyReason
374
+ || "This backend does not accept collection edits.";
375
+
344
376
  const importConfigRaw = useImportConfig();
345
377
  const urlControllerRaw = useUrlController();
346
378
  const collectionRegistryRaw = useCollectionRegistryController();
@@ -720,16 +752,23 @@ function CollectionEditorInternal<M extends Record<string, unknown>>({
720
752
  )}
721
753
  {fullScreen && !isNewCollection && (
722
754
  <div className="flex items-center gap-2">
723
- <LoadingButton
724
- variant="filled"
725
- color="primary"
726
- type="submit"
727
- onClick={() => formController.handleSubmit()}
728
- disabled={!dirty || isSubmitting || configController?.readOnly}
729
- loading={isSubmitting}
730
- >
731
- {configController?.readOnly ? "Update (Read-only)" : "Update"}
732
- </LoadingButton>
755
+ {/* "(Read-only)" says the button will not work but not why.
756
+ The controller now carries the backend's own reason —
757
+ production, `baas` mode, no `collectionsDir` — so show it. */}
758
+ <Tooltip title={configController?.readOnly ? readOnlyTitle : undefined}>
759
+ <div>
760
+ <LoadingButton
761
+ variant="filled"
762
+ color="primary"
763
+ type="submit"
764
+ onClick={() => formController.handleSubmit()}
765
+ disabled={!dirty || isSubmitting || configController?.readOnly}
766
+ loading={isSubmitting}
767
+ >
768
+ {configController?.readOnly ? "Update (Read-only)" : "Update"}
769
+ </LoadingButton>
770
+ </div>
771
+ </Tooltip>
733
772
  </div>
734
773
  )}
735
774
  </div>
@@ -935,15 +974,19 @@ function CollectionEditorInternal<M extends Record<string, unknown>>({
935
974
  {currentView === "properties" && "Create collection"}
936
975
  </LoadingButton>}
937
976
 
938
- {!isNewCollection && !fullScreen && <LoadingButton
939
- variant="filled"
940
- color="primary"
941
- type="submit"
942
- disabled={isSubmitting || configController?.readOnly}
943
- loading={isSubmitting}
944
- >
945
- {configController?.readOnly ? "Update collection (Read-only)" : "Update collection"}
946
- </LoadingButton>}
977
+ {!isNewCollection && !fullScreen && <Tooltip title={configController?.readOnly ? readOnlyTitle : undefined}>
978
+ <div>
979
+ <LoadingButton
980
+ variant="filled"
981
+ color="primary"
982
+ type="submit"
983
+ disabled={isSubmitting || configController?.readOnly}
984
+ loading={isSubmitting}
985
+ >
986
+ {configController?.readOnly ? "Update collection (Read-only)" : "Update collection"}
987
+ </LoadingButton>
988
+ </div>
989
+ </Tooltip>}
947
990
 
948
991
  </div>
949
992
  )}
@@ -1,6 +1,6 @@
1
1
 
2
2
  import { getPolicyNamesForRule, getPolicyNamesForRules } from "@rebasepro/utils";
3
- import { getEffectiveSecurityRules } from "@rebasepro/common";
3
+ import { getGeneratedPolicyNames } from "@rebasepro/common";
4
4
  import React, { useState, useEffect, useMemo } from "react";
5
5
 
6
6
  /**
@@ -39,6 +39,7 @@ import {
39
39
  Typography
40
40
  } from "@rebasepro/ui";
41
41
  import { useFormex } from "@rebasepro/forms";
42
+ import { useCollectionsConfigController } from "../../useCollectionsConfigController";
42
43
  import { useRebaseContext } from "@rebasepro/app";
43
44
  import type { AdminCollection } from "@rebasepro/admin-types";
44
45
  import type { PostgresPolicy, SecurityOperation, SecurityRule } from "@rebasepro/types";
@@ -114,6 +115,14 @@ export function CollectionRLSTab() {
114
115
  const { values, setFieldValue } = useFormex<CollectionWithSecurity>();
115
116
  const [editingPolicy, setEditingPolicy] = useState<PostgresPolicy | "new" | null>(null);
116
117
 
118
+ // Every other tab in this dialog disables its inputs when the backend will
119
+ // not accept an edit — the general and display forms behind a `fieldset
120
+ // disabled`, the properties editor per control. This one did not, so a
121
+ // read-only panel still invited you to write a policy, delete one, and
122
+ // reorder the lot, and only said no at the submit button on the way out.
123
+ const { readOnly, readOnlyReason } = useCollectionsConfigController();
124
+ const readOnlyTitle = readOnlyReason || "This backend does not accept collection edits.";
125
+
117
126
  const rules: SecurityRule[] = values.securityRules || [];
118
127
 
119
128
  const { databaseAdmin } = useRebaseContext();
@@ -209,26 +218,14 @@ export function CollectionRLSTab() {
209
218
 
210
219
  const tableName = values.id || values.table || values.alias;
211
220
 
212
- /**
213
- * Every policy name `rebase db push` would write for this collection.
214
- *
215
- * Two things make this more than `rules.map(r => r.name)`:
216
- * - a rule without an explicit `name` becomes `<table>_<op>_<hash>`, so
217
- * comparing `rule.name` to `policyname` never matches for it;
218
- * - the generator also injects the safe-by-default baseline
219
- * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
220
- *
221
- * Missing either made Rebase's own policies look hand-written, and offered to
222
- * import them back into the codebase that produced them.
223
- */
221
+ // Every policy name `rebase db push` would write for this collection — see
222
+ // `getGeneratedPolicyNames`. This used to be derived here by hand, and the
223
+ // Studio's RLS editor derived a different subset of it by hand, which is
224
+ // exactly how the two came to disagree about which policies were drift.
224
225
  const generatedPolicyNames = useMemo(() => {
225
226
  if (!tableName) return new Set<string>();
226
- const effectiveRules = getEffectiveSecurityRules(values as AdminCollection);
227
- return getPolicyNamesForRules(
228
- [...rules, ...effectiveRules],
229
- tableName
230
- );
231
- }, [rules, tableName, values]);
227
+ return getGeneratedPolicyNames(values as AdminCollection);
228
+ }, [tableName, values]);
232
229
 
233
230
  const unmappedPolicies = dbPolicies.filter(dp => !generatedPolicyNames.has(dp.policyname));
234
231
 
@@ -251,9 +248,13 @@ export function CollectionRLSTab() {
251
248
  <div className="w-full flex flex-col">
252
249
  <div className="flex items-center justify-between mb-8">
253
250
  <Typography variant="h5">Row Level Security</Typography>
254
- <Button variant="filled" color="neutral" onClick={() => setEditingPolicy("new")}>
255
- CREATE POLICY
256
- </Button>
251
+ <Tooltip title={readOnly ? readOnlyTitle : undefined}>
252
+ <div>
253
+ <Button variant="filled" color="neutral" disabled={readOnly} onClick={() => setEditingPolicy("new")}>
254
+ CREATE POLICY
255
+ </Button>
256
+ </div>
257
+ </Tooltip>
257
258
  </div>
258
259
 
259
260
  {rules.length === 0 ? (
@@ -282,7 +283,7 @@ export function CollectionRLSTab() {
282
283
  </div>
283
284
  </div>
284
285
  <div className="flex items-center gap-1 sm:gap-2 shrink-0">
285
- <Button size="small" variant="text" onClick={() => setEditingPolicy({
286
+ <Button size="small" variant="text" disabled={readOnly} onClick={() => setEditingPolicy({
286
287
  policyname: rule.name ?? "",
287
288
  tablename: values.id || values.table || values.alias || "your_table",
288
289
  permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
@@ -293,7 +294,7 @@ export function CollectionRLSTab() {
293
294
  })}>
294
295
  EDIT
295
296
  </Button>
296
- <IconButton size="small" onClick={() => {
297
+ <IconButton size="small" disabled={readOnly} onClick={() => {
297
298
  setFieldValue("securityRules", rules.filter((r: SecurityRule) => r.name !== rule.name));
298
299
  }}>
299
300
  <Trash2Icon size={iconSize.smallest} className="text-text-secondary dark:text-text-secondary-dark hover:text-red-500 dark:hover:text-red-500 transition-colors"/>
@@ -336,12 +337,19 @@ export function CollectionRLSTab() {
336
337
  </div>
337
338
  </div>
338
339
  <div className="flex items-center gap-1 sm:gap-2 shrink-0">
339
- <Button size="small" variant="outlined" color="primary" onClick={() => {
340
- const rule: SecurityRule = toSecurityRule(dp);
341
- setFieldValue("securityRules", [...rules, rule]);
342
- }}>
343
- Import to codebase
344
- </Button>
340
+ {/* "Import to codebase" is the one action here that
341
+ names its destination, and it is the one the
342
+ backend refuses when it has no codebase to write. */}
343
+ <Tooltip title={readOnly ? readOnlyTitle : undefined}>
344
+ <div>
345
+ <Button size="small" variant="outlined" color="primary" disabled={readOnly} onClick={() => {
346
+ const rule: SecurityRule = toSecurityRule(dp);
347
+ setFieldValue("securityRules", [...rules, rule]);
348
+ }}>
349
+ Import to codebase
350
+ </Button>
351
+ </div>
352
+ </Tooltip>
345
353
  </div>
346
354
  </Paper>
347
355
  ))}
@@ -10,7 +10,7 @@
10
10
  * (which has no react-router dependency).
11
11
  */
12
12
  import React from "react";
13
- import { useNavigate } from "react-router-dom";
13
+ import { useNavigate } from "react-router";
14
14
  import { useUrlController } from "../../_cms_internals";
15
15
  import { useSnackbarController } from "@rebasepro/app";
16
16
  import { CollectionStudioView, CollectionStudioViewProps } from "./CollectionStudioView";
@@ -9,7 +9,7 @@
9
9
  * (which has no react-router dependency).
10
10
  */
11
11
  import React from "react";
12
- import { useLocation, useNavigate } from "react-router-dom";
12
+ import { useLocation, useNavigate } from "react-router";
13
13
  import { useUrlController } from "../../_cms_internals";
14
14
  import { CollectionsStudioView, CollectionsStudioViewProps } from "./CollectionsStudioView";
15
15
 
@@ -2,42 +2,87 @@ import { CollectionsConfigController, SaveCollectionParams, UpdateCollectionPara
2
2
  import { Properties } from "@rebasepro/types";
3
3
  import { getSubcollections } from "@rebasepro/common";
4
4
 
5
- import React, { useMemo, useRef } from "react";
5
+ import React, { useEffect, useMemo, useRef, useState } from "react";
6
6
  import type { AdminCollection } from "@rebasepro/admin-types";
7
+ import { DEFAULT_API_PATH } from "@rebasepro/app";
8
+
9
+ /**
10
+ * What the backend said about its schema editor, and how sure we are.
11
+ *
12
+ * `unknown` covers both "still asking" and "could not ask" — a backend too old
13
+ * to have the endpoint, or one that is unreachable. Those fall back to the
14
+ * build-mode guess below rather than locking the editor for someone whose
15
+ * editor used to work.
16
+ */
17
+ type SchemaEditorAvailability =
18
+ | { state: "unknown" }
19
+ | { state: "known", enabled: boolean, reason?: string };
20
+
21
+ /**
22
+ * The guess this hook used to make on its own.
23
+ *
24
+ * `process.env.NODE_ENV` here is the *frontend bundle's* build mode. Whether
25
+ * the schema editor exists is decided by a different process entirely — the
26
+ * server's own `NODE_ENV`, its `collectionsDir`, whether its collections were
27
+ * introspected, whether `ts-morph` is installed. The two disagree in every
28
+ * ordinary setup: a dev frontend against a deployed API, a `baas` project, a
29
+ * hosted console. When they did, the editor offered itself and every save came
30
+ * back `404 Not Found`. It is kept only as the answer for a backend too old to
31
+ * be asked.
32
+ */
33
+ const buildModeGuess = (): boolean => process.env.NODE_ENV === "production";
34
+
7
35
  export function useLocalCollectionsConfigController(
8
36
  clientOrUrl: any,
9
37
  baseCollections: AdminCollection[] = [],
10
38
  options?: {
11
39
  readOnly?: boolean;
12
40
  getAuthToken?: () => Promise<string | null>;
41
+ /**
42
+ * Identity of the signed-in user, if any.
43
+ *
44
+ * Only used to re-ask the backend when it changes: whether the editor
45
+ * will accept a write depends on who is asking, and the answer to an
46
+ * anonymous probe does not survive a sign-in.
47
+ */
48
+ authKey?: string | null;
13
49
  }
14
50
  ): CollectionsConfigController {
15
51
 
16
52
  const parsedCollections = baseCollections;
17
-
53
+
18
54
  // Store latest options in a ref to prevent stale closures in the `request` function
19
55
  // due to useMemo caching the saveCollection function.
20
56
  const optionsRef = useRef(options);
21
57
  optionsRef.current = options;
22
58
 
59
+ // `/api` only by default — a backend configured with another `basePath`
60
+ // mounts the schema editor under that instead.
61
+ const clientBaseUrl = typeof clientOrUrl === "string"
62
+ ? clientOrUrl
63
+ : (clientOrUrl?.baseUrl ?? "");
64
+ const clientApiPath = (typeof clientOrUrl === "object" && clientOrUrl !== null && clientOrUrl.apiPath)
65
+ || DEFAULT_API_PATH;
66
+ const editorUrl = `${String(clientBaseUrl).replace(/\/$/, "")}${clientApiPath}/schema-editor`;
67
+
68
+ const resolveToken = async (): Promise<string | null> => {
69
+ let token = optionsRef.current?.getAuthToken ? await optionsRef.current.getAuthToken() : null;
70
+ if (!token && typeof clientOrUrl === "object" && clientOrUrl !== null && clientOrUrl.resolveToken) {
71
+ token = await clientOrUrl.resolveToken();
72
+ }
73
+ return token ?? null;
74
+ };
75
+
23
76
  const request = async (endpoint: string, payload: Record<string, unknown>) => {
24
77
  try {
25
- let token = optionsRef.current?.getAuthToken ? await optionsRef.current.getAuthToken() : null;
26
- let baseUrl = typeof clientOrUrl === "string" ? clientOrUrl : "";
27
-
28
- if (typeof clientOrUrl === "object" && clientOrUrl !== null) {
29
- baseUrl = clientOrUrl.baseUrl || baseUrl;
30
- if (!token && clientOrUrl.resolveToken) {
31
- token = await clientOrUrl.resolveToken();
32
- }
33
- }
78
+ const token = await resolveToken();
34
79
 
35
80
  const headers: Record<string, string> = { "Content-Type": "application/json" };
36
81
  if (token) {
37
82
  headers["Authorization"] = `Bearer ${token}`;
38
83
  }
39
84
 
40
- const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/schema-editor${endpoint}`, {
85
+ const response = await fetch(`${editorUrl}${endpoint}`, {
41
86
  method: "POST",
42
87
  headers,
43
88
  body: JSON.stringify(payload)
@@ -67,10 +112,77 @@ export function useLocalCollectionsConfigController(
67
112
  }
68
113
  };
69
114
 
115
+ // ── Ask the backend whether it will accept an edit ────────────────
116
+ const forcedReadOnly = options?.readOnly;
117
+ const authKey = options?.authKey ?? null;
118
+ const [availability, setAvailability] = useState<SchemaEditorAvailability>({ state: "unknown" });
119
+
120
+ useEffect(() => {
121
+ // Nothing to ask about: the caller has already decided.
122
+ if (forcedReadOnly === true) return;
123
+
124
+ let cancelled = false;
125
+ (async () => {
126
+ try {
127
+ const token = await resolveToken();
128
+ const response = await fetch(`${editorUrl}/status`, {
129
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
130
+ });
131
+ if (cancelled) return;
132
+
133
+ // A backend from before the endpoint existed. Guessing from the
134
+ // bundle's build mode is wrong, but it is what that backend's
135
+ // admin panel has always done, and locking the editor for
136
+ // someone whose editor works would be worse.
137
+ if (response.status === 404) {
138
+ setAvailability({ state: "unknown" });
139
+ return;
140
+ }
141
+
142
+ const body = await response.json().catch(() => ({})) as {
143
+ enabled?: boolean,
144
+ reason?: string,
145
+ error?: { message?: string }
146
+ };
147
+ if (cancelled) return;
148
+
149
+ if (response.ok) {
150
+ setAvailability({
151
+ state: "known",
152
+ enabled: body.enabled === true,
153
+ reason: body.reason
154
+ });
155
+ } else {
156
+ // 401/403 (not an admin) or 501 (no auth configured at all)
157
+ // — all of them mean this session cannot edit collections,
158
+ // and the body says why better than we could.
159
+ setAvailability({
160
+ state: "known",
161
+ enabled: false,
162
+ reason: body.error?.message
163
+ ?? `The backend refused to say whether collections are editable (HTTP ${response.status}).`
164
+ });
165
+ }
166
+ } catch {
167
+ if (!cancelled) setAvailability({ state: "unknown" });
168
+ }
169
+ })();
170
+
171
+ return () => { cancelled = true; };
172
+ // eslint-disable-next-line react-hooks/exhaustive-deps
173
+ }, [editorUrl, forcedReadOnly, authKey]);
174
+
175
+ const readOnly = forcedReadOnly
176
+ ?? (availability.state === "known" ? !availability.enabled : buildModeGuess());
177
+
178
+ const readOnlyReason = availability.state === "known" && availability.reason
179
+ ? availability.reason
180
+ : "Collections can only be edited against a backend running the schema editor, which is off in production.";
181
+
70
182
  return useMemo(() => ({
71
183
  loading: false,
72
- readOnly: options?.readOnly ?? false,
73
- readOnlyReason: "Local collection editing is only available in development mode.",
184
+ readOnly,
185
+ readOnlyReason,
74
186
  collections: parsedCollections,
75
187
  getCollection: (id: string) => {
76
188
  const found = parsedCollections.find(c => (c as AdminCollection & { id?: string }).id === id || c.slug === id);
@@ -119,5 +231,5 @@ collectionData: { propertiesOrder: newPropertiesOrder } });
119
231
 
120
232
  navigationEntries: [],
121
233
  saveNavigationEntries: async () => { }
122
- }), [clientOrUrl, parsedCollections, options?.readOnly, options?.getAuthToken]);
234
+ }), [clientOrUrl, parsedCollections, readOnly, readOnlyReason, options?.getAuthToken]);
123
235
  }
@@ -1,6 +1,6 @@
1
1
  import type { AppView } from "@rebasepro/admin-types";
2
2
  import React, { useEffect } from "react";
3
- import { useLocation } from "react-router-dom";
3
+ import { useLocation } from "react-router";
4
4
  ;
5
5
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
6
6
  import { useAdminModeController } from "@rebasepro/app";
@@ -13,7 +13,7 @@ import {
13
13
  ViewMode
14
14
  } from "@rebasepro/admin-types";
15
15
  import React, { useCallback, useEffect, useMemo, useState } from "react";
16
- import { useSearchParams } from "react-router-dom";
16
+ import { useSearchParams } from "react-router";
17
17
 
18
18
  import { deepEqual as equal } from "fast-equals"
19
19
  import { CollectionRowActions, CollectionTableBinding } from "../CollectionTableBinding";
@@ -9,7 +9,7 @@ import { ClearFilterSortButton } from "../ClearFilterSortButton";
9
9
  import { FiltersDialog } from "./FiltersDialog";
10
10
  import { FilterPresetsButton } from "./FilterPresetsButton";
11
11
  import { toArray } from "@rebasepro/utils";
12
- import { useNavigate } from "react-router-dom";
12
+ import { useNavigate } from "react-router";
13
13
  import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
14
14
  import { useAdminContext } from "../../hooks/useAdminContext";
15
15
  import { withViewMode } from "../../util/view_mode";
@@ -11,7 +11,7 @@ import {
11
11
  } from "@rebasepro/ui";
12
12
  import { useLargeLayout, UnsavedChangesDialog, useNavigationBlocker } from "@rebasepro/app";
13
13
  import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
14
- import { useNavigate, useLocation } from "react-router-dom";
14
+ import { useNavigate, useLocation } from "react-router";
15
15
  import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
16
16
  import { ErrorBoundary } from "@rebasepro/ui";
17
17
  import { withViewMode } from "../../util/view_mode";