@orion-studios/cms 0.5.0 → 0.5.2

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.
@@ -58,7 +58,11 @@ function createStudioApi(options) {
58
58
  getPage: (id) => call("GET", `/pages/${id}`),
59
59
  createPage: (input) => call("POST", "/pages", input),
60
60
  saveDraft: (id, input) => call("PATCH", `/pages/${id}`, input),
61
- publish: (id) => call("POST", `/pages/${id}/publish`),
61
+ publish: (id, expectedVersionId) => call("POST", `/pages/${id}/publish`, { expectedVersionId }),
62
+ schedulePublish: (id, publishAt, expectedVersionId) => call("PATCH", `/pages/${id}`, {
63
+ publishAt,
64
+ ...expectedVersionId !== void 0 ? { expectedVersionId } : {}
65
+ }),
62
66
  unpublish: (id) => call("POST", `/pages/${id}/unpublish`),
63
67
  duplicatePage: (id) => call("POST", `/pages/${id}/duplicate`),
64
68
  deletePage: (id) => call("DELETE", `/pages/${id}`),
@@ -129,6 +133,75 @@ function createStudioApi(options) {
129
133
  };
130
134
  }
131
135
 
136
+ // src/server/permissions.ts
137
+ var ROLE_RANK = {
138
+ content: 0,
139
+ editor: 1,
140
+ admin: 2,
141
+ developer: 3
142
+ };
143
+ var MIN_ROLE = {
144
+ "pages.read": "content",
145
+ "pages.saveDraft": "content",
146
+ "pages.changeStructure": "editor",
147
+ "pages.publish": "editor",
148
+ "pages.create": "editor",
149
+ "pages.delete": "admin",
150
+ "pages.restore": "editor",
151
+ "globals.read": "content",
152
+ "globals.write": "content",
153
+ "media.read": "content",
154
+ "media.upload": "content",
155
+ "media.update": "content",
156
+ "media.replace": "editor",
157
+ "media.delete": "editor",
158
+ "forms.read": "content",
159
+ "forms.write": "editor",
160
+ "forms.delete": "admin",
161
+ "submissions.read": "editor",
162
+ "submissions.manage": "editor",
163
+ "redirects.manage": "editor",
164
+ "activity.read": "editor",
165
+ "analytics.read": "editor",
166
+ "sync.run": "admin",
167
+ "users.manage": "admin"
168
+ };
169
+ function can(user, action) {
170
+ if (!user) return false;
171
+ return ROLE_RANK[user.role] >= ROLE_RANK[MIN_ROLE[action]];
172
+ }
173
+
174
+ // src/studio/access.ts
175
+ var SECTION_ACTION = {
176
+ dashboard: "pages.read",
177
+ pages: "pages.read",
178
+ media: "media.read",
179
+ forms: "forms.read",
180
+ submissions: "submissions.read",
181
+ analytics: "analytics.read",
182
+ globals: "globals.read",
183
+ redirects: "redirects.manage",
184
+ users: "users.manage"
185
+ };
186
+ var userForRole = (role) => ({ id: "", email: null, name: "", role });
187
+ var studioCan = (role, action) => can(userForRole(role), action);
188
+ var canViewStudioSection = (role, section) => studioCan(role, SECTION_ACTION[section]);
189
+ var studioAccess = (role) => ({
190
+ changePageStructure: studioCan(role, "pages.changeStructure"),
191
+ publishPages: studioCan(role, "pages.publish"),
192
+ createPages: studioCan(role, "pages.create"),
193
+ deletePages: studioCan(role, "pages.delete"),
194
+ replaceMedia: studioCan(role, "media.replace"),
195
+ deleteMedia: studioCan(role, "media.delete"),
196
+ writeForms: studioCan(role, "forms.write"),
197
+ deleteForms: studioCan(role, "forms.delete"),
198
+ readSubmissions: studioCan(role, "submissions.read"),
199
+ manageSubmissions: studioCan(role, "submissions.manage"),
200
+ readAnalytics: studioCan(role, "analytics.read"),
201
+ manageRedirects: studioCan(role, "redirects.manage"),
202
+ manageUsers: studioCan(role, "users.manage")
203
+ });
204
+
132
205
  // src/studio/auth.tsx
133
206
  import { createClient } from "@supabase/supabase-js";
134
207
  import { useCallback, useEffect, useMemo, useState as useState2 } from "react";
@@ -196,14 +269,14 @@ function getBrowserSupabase() {
196
269
  }
197
270
  return browserClient;
198
271
  }
199
- var isMemoryMode = () => (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) && (process.env.NODE_ENV !== "production" || process.env.NEXT_PUBLIC_CMS_MEMORY === "true");
272
+ var isStudioMemoryMode = () => (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) && process.env.NODE_ENV !== "production";
200
273
  var MEMORY_DEV_TOKEN = "orion-dev-token";
201
274
  var memorySession = {
202
275
  access_token: MEMORY_DEV_TOKEN,
203
276
  user: { id: "dev", email: "dev@local" }
204
277
  };
205
278
  function useStudioSession() {
206
- const memory = isMemoryMode();
279
+ const memory = isStudioMemoryMode();
207
280
  const supabase = useMemo(() => memory ? null : getBrowserSupabase(), [memory]);
208
281
  const [session, setSession] = useState2(memory ? memorySession : null);
209
282
  const [loading, setLoading] = useState2(!memory);
@@ -634,24 +707,28 @@ function DashboardView({
634
707
  api,
635
708
  siteName,
636
709
  onOpenPage,
637
- onOpenSubmissions
710
+ onOpenSubmissions,
711
+ canReadAnalytics,
712
+ canReadSubmissions
638
713
  }) {
639
714
  const [data, setData] = useState4(null);
640
715
  const [week, setWeek] = useState4(null);
641
716
  const [error, setError] = useState4("");
642
717
  useEffect3(() => {
643
718
  api.dashboard().then(setData, (e) => setError(e.message));
644
- const to = /* @__PURE__ */ new Date();
645
- const from = new Date(to.getTime() - 7 * 864e5);
646
- api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
647
- (summary) => setWeek({
648
- visitors: summary.kpis.visitors,
649
- calls: summary.kpis.calls,
650
- formSubmits: summary.kpis.formSubmits
651
- }),
652
- () => void 0
653
- );
654
- }, [api]);
719
+ if (canReadAnalytics) {
720
+ const to = /* @__PURE__ */ new Date();
721
+ const from = new Date(to.getTime() - 7 * 864e5);
722
+ api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
723
+ (summary) => setWeek({
724
+ visitors: summary.kpis.visitors,
725
+ calls: summary.kpis.calls,
726
+ formSubmits: summary.kpis.formSubmits
727
+ }),
728
+ () => void 0
729
+ );
730
+ }
731
+ }, [api, canReadAnalytics]);
655
732
  if (error) return /* @__PURE__ */ jsx4("div", { className: "ost-view", children: /* @__PURE__ */ jsx4("div", { className: "ost-error", children: error }) });
656
733
  if (!data) return /* @__PURE__ */ jsx4("div", { className: "ost-view", children: /* @__PURE__ */ jsx4("div", { className: "ost-loading", children: "Loading\u2026" }) });
657
734
  const { pages, submissions, activity } = data;
@@ -681,7 +758,7 @@ function DashboardView({
681
758
  ] }) }),
682
759
  /* @__PURE__ */ jsxs4("div", { className: "ost-dash-columns", children: [
683
760
  /* @__PURE__ */ jsxs4("div", { className: "ost-dash-column", children: [
684
- /* @__PURE__ */ jsxs4("section", { className: "ost-card ost-dash-card", children: [
761
+ canReadSubmissions && submissions ? /* @__PURE__ */ jsxs4("section", { className: "ost-card ost-dash-card", children: [
685
762
  /* @__PURE__ */ jsxs4("header", { className: "ost-dash-card-head", children: [
686
763
  /* @__PURE__ */ jsx4("h3", { children: "Submissions" }),
687
764
  /* @__PURE__ */ jsx4("button", { className: "ost-btn", onClick: onOpenSubmissions, type: "button", children: "Open inbox" })
@@ -706,7 +783,7 @@ function DashboardView({
706
783
  },
707
784
  submission.id
708
785
  ))
709
- ] }),
786
+ ] }) : null,
710
787
  /* @__PURE__ */ jsxs4("section", { className: "ost-card ost-dash-card", children: [
711
788
  /* @__PURE__ */ jsx4("header", { className: "ost-dash-card-head", children: /* @__PURE__ */ jsx4("h3", { children: "Waiting to publish" }) }),
712
789
  pages.pendingDrafts.length === 0 ? /* @__PURE__ */ jsx4("p", { className: "ost-muted", children: "Every page is live and up to date." }) : pages.pendingDrafts.map((page) => /* @__PURE__ */ jsxs4("button", { className: "ost-dash-row", onClick: () => onOpenPage(page.id), type: "button", children: [
@@ -805,6 +882,7 @@ function FormsView({
805
882
  api,
806
883
  canWrite,
807
884
  canDelete,
885
+ canReadSubmissions,
808
886
  onOpenSubmissions
809
887
  }) {
810
888
  const [forms, setForms] = useState5(null);
@@ -870,19 +948,19 @@ function FormsView({
870
948
  ] })
871
949
  ] }),
872
950
  /* @__PURE__ */ jsxs5("span", { className: "ost-row", children: [
873
- /* @__PURE__ */ jsxs5("button", { className: "ost-btn", onClick: () => onOpenSubmissions(form.id), type: "button", children: [
951
+ canReadSubmissions ? /* @__PURE__ */ jsxs5("button", { className: "ost-btn", onClick: () => onOpenSubmissions(form.id), type: "button", children: [
874
952
  form.submissionCount ?? 0,
875
953
  " submission",
876
954
  (form.submissionCount ?? 0) === 1 ? "" : "s",
877
955
  form.unreadCount ? ` (${form.unreadCount} new)` : ""
878
- ] }),
956
+ ] }) : null,
879
957
  canDelete ? /* @__PURE__ */ jsx5(
880
958
  "button",
881
959
  {
882
960
  className: "ost-btn ost-btn-danger",
883
961
  onClick: async () => {
884
962
  if (!window.confirm(
885
- `Delete "${form.title}" and its ${form.submissionCount ?? 0} submissions? This cannot be undone.`
963
+ canReadSubmissions ? `Delete "${form.title}" and its ${form.submissionCount ?? 0} submissions? This cannot be undone.` : `Delete "${form.title}"? This cannot be undone.`
886
964
  ))
887
965
  return;
888
966
  await api.deleteForm(form.slug).catch(() => void 0);
@@ -2043,8 +2121,8 @@ function PageEditor({
2043
2121
  const changedSincePublish = useMemo3(() => {
2044
2122
  if (!page) return false;
2045
2123
  if (page.status !== "published") return true;
2046
- return dirty || JSON.stringify(layout) !== JSON.stringify(page.published_layout ?? []);
2047
- }, [page, layout, dirty]);
2124
+ return dirty || title !== (page.published_title ?? "") || JSON.stringify(seo) !== JSON.stringify(page.published_seo ?? {}) || JSON.stringify(layout) !== JSON.stringify(page.published_layout ?? []);
2125
+ }, [page, title, seo, layout, dirty]);
2048
2126
  const canvasRef = useRef(null);
2049
2127
  const layoutRef = useRef(layout);
2050
2128
  layoutRef.current = layout;
@@ -2094,22 +2172,24 @@ function PageEditor({
2094
2172
  };
2095
2173
  }, [layout, registry, commitInline]);
2096
2174
  const save = useCallback2(async () => {
2097
- const prior = savePromiseRef.current ?? Promise.resolve(true);
2098
- const run = prior.catch(() => void 0).then(async () => {
2175
+ const prior = savePromiseRef.current ?? Promise.resolve(null);
2176
+ const run = prior.catch(() => null).then(async () => {
2099
2177
  const revAtStart = editRevRef.current;
2100
2178
  setBusy("save");
2101
2179
  setError("");
2102
2180
  try {
2103
2181
  const { page: saved } = await api.saveDraft(pageId, { title, seo, layout });
2104
- setPage(saved);
2105
- if (editRevRef.current === revAtStart) {
2106
- setDirty(false);
2107
- setMessage("Draft saved.");
2182
+ if (editRevRef.current !== revAtStart) {
2183
+ setError("The draft changed while saving. Save again before publishing or scheduling.");
2184
+ return null;
2108
2185
  }
2109
- return true;
2186
+ setPage(saved);
2187
+ setDirty(false);
2188
+ setMessage("Draft saved.");
2189
+ return saved;
2110
2190
  } catch (saveError) {
2111
2191
  setError(saveError instanceof Error ? saveError.message : "Save failed.");
2112
- return false;
2192
+ return null;
2113
2193
  } finally {
2114
2194
  setBusy(null);
2115
2195
  }
@@ -2122,13 +2202,17 @@ function PageEditor({
2122
2202
  }
2123
2203
  }, [api, pageId, title, seo, layout]);
2124
2204
  const publish = async () => {
2125
- if (dirty && !await save()) return;
2205
+ const reviewed = dirty ? await save() : page;
2206
+ if (!reviewed?.draft_version_id) {
2207
+ setError("Save the draft again before publishing.");
2208
+ return;
2209
+ }
2126
2210
  setBusy("publish");
2127
2211
  setError("");
2128
2212
  try {
2129
- const { page: published } = await api.publish(pageId);
2213
+ const { page: published } = await api.publish(pageId, reviewed.draft_version_id);
2130
2214
  setPage(published);
2131
- setMessage("Published \u2014 live on the site.");
2215
+ setMessage("Published. The reviewed version is live on the site.");
2132
2216
  } catch (publishError) {
2133
2217
  setError(publishError instanceof Error ? publishError.message : "Publish failed.");
2134
2218
  } finally {
@@ -2540,17 +2624,20 @@ function PageSettings({
2540
2624
  const schedule = async (value) => {
2541
2625
  setError("");
2542
2626
  setNotice("");
2543
- if (value && !await onFlushDraft()) {
2627
+ const reviewed = value ? await onFlushDraft() : null;
2628
+ if (value && !reviewed?.draft_version_id) {
2544
2629
  setError("Could not save the current draft before scheduling.");
2545
2630
  return;
2546
2631
  }
2547
2632
  try {
2548
- const { page: updated } = await api.saveDraft(page.id, {
2549
- publishAt: value ? new Date(value).toISOString() : null
2550
- });
2633
+ const { page: updated } = await api.schedulePublish(
2634
+ page.id,
2635
+ value ? new Date(value).toISOString() : null,
2636
+ reviewed?.draft_version_id ?? void 0
2637
+ );
2551
2638
  onPageChanged(updated);
2552
2639
  setPublishAt(updated.publish_at ? updated.publish_at.slice(0, 16) : "");
2553
- setNotice(value ? "Publish scheduled." : "Schedule cleared.");
2640
+ setNotice(value ? `Saved version ${updated.scheduled_version_id} is scheduled.` : "Schedule cleared.");
2554
2641
  } catch (scheduleError) {
2555
2642
  setError(scheduleError instanceof Error ? scheduleError.message : "Could not schedule.");
2556
2643
  }
@@ -2650,7 +2737,7 @@ function PageSettings({
2650
2737
  /* @__PURE__ */ jsx7("button", { className: "ost-btn", disabled: !publishAt, onClick: () => schedule(publishAt), type: "button", children: "Schedule" }),
2651
2738
  page.publish_at ? /* @__PURE__ */ jsx7("button", { className: "ost-btn", onClick: () => schedule(null), type: "button", children: "Clear" }) : null
2652
2739
  ] }),
2653
- /* @__PURE__ */ jsx7("p", { className: "ost-muted", children: "The current draft goes live automatically at the chosen time." })
2740
+ /* @__PURE__ */ jsx7("p", { className: "ost-muted", children: "The saved version selected when you schedule goes live at the chosen time. Later draft edits stay private." })
2654
2741
  ] }) : null,
2655
2742
  /* @__PURE__ */ jsxs7("section", { className: "ost-settings-section", children: [
2656
2743
  /* @__PURE__ */ jsx7("h4", { children: "Actions" }),
@@ -3249,6 +3336,7 @@ function MediaView({
3249
3336
  media,
3250
3337
  mediaUrl,
3251
3338
  canDelete,
3339
+ canReplace,
3252
3340
  onUpload,
3253
3341
  onChanged
3254
3342
  }) {
@@ -3331,6 +3419,7 @@ function MediaView({
3331
3419
  {
3332
3420
  api,
3333
3421
  canDelete,
3422
+ canReplace,
3334
3423
  media: selected,
3335
3424
  mediaUrl,
3336
3425
  onChanged: () => {
@@ -3348,6 +3437,7 @@ function MediaDetail({
3348
3437
  media,
3349
3438
  mediaUrl,
3350
3439
  canDelete,
3440
+ canReplace,
3351
3441
  onChanged,
3352
3442
  onClose,
3353
3443
  onError
@@ -3402,7 +3492,7 @@ function MediaDetail({
3402
3492
  children: saving ? "Saving\u2026" : "Save details"
3403
3493
  }
3404
3494
  ),
3405
- /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3495
+ canReplace ? /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3406
3496
  replacing ? "Replacing\u2026" : "Replace file",
3407
3497
  /* @__PURE__ */ jsx11(
3408
3498
  "input",
@@ -3428,7 +3518,7 @@ function MediaDetail({
3428
3518
  type: "file"
3429
3519
  }
3430
3520
  )
3431
- ] }),
3521
+ ] }) : null,
3432
3522
  canDelete ? /* @__PURE__ */ jsx11(
3433
3523
  "button",
3434
3524
  {
@@ -3590,8 +3680,8 @@ var NAV = [
3590
3680
  { key: "submissions", label: "Submissions" },
3591
3681
  { key: "analytics", label: "Analytics" },
3592
3682
  { key: "globals", label: "Globals" },
3593
- { key: "redirects", label: "Redirects", minRole: "editor" },
3594
- { key: "users", label: "Users", minRole: "admin" }
3683
+ { key: "redirects", label: "Redirects" },
3684
+ { key: "users", label: "Users" }
3595
3685
  ];
3596
3686
  function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3597
3687
  const { session, loading, getToken, signOut } = useStudioSession();
@@ -3692,20 +3782,8 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3692
3782
  ] }) });
3693
3783
  }
3694
3784
  if (!role) return /* @__PURE__ */ jsx12("div", { className: "ost-root ost-loading", children: "Loading Studio\u2026" });
3695
- const canChangeStructure = role !== "content";
3696
- const canPublish = role !== "content";
3697
- const canCreatePages = role !== "content";
3698
- const canDeleteMedia = role !== "content";
3699
- const canWriteForms = role !== "content";
3700
- const canManageSubmissions = role !== "content";
3701
- const canManageUsers = role === "admin" || role === "developer";
3702
- const canManageRedirects = role !== "content";
3703
- const canDeleteForms = role === "admin" || role === "developer";
3704
- const nav = NAV.filter((item) => {
3705
- if (item.minRole === "admin") return canManageUsers;
3706
- if (item.minRole === "editor") return role !== "content";
3707
- return true;
3708
- });
3785
+ const access = studioAccess(role);
3786
+ const nav = NAV.filter((item) => canViewStudioSection(role, item.key));
3709
3787
  const goTo = (key) => {
3710
3788
  setSection(key);
3711
3789
  setOpenPageId(null);
@@ -3748,8 +3826,8 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3748
3826
  PageEditor,
3749
3827
  {
3750
3828
  api,
3751
- canChangeStructure,
3752
- canPublish,
3829
+ canChangeStructure: access.changePageStructure,
3830
+ canPublish: access.publishPages,
3753
3831
  media,
3754
3832
  mediaUrl,
3755
3833
  onBack: () => {
@@ -3763,11 +3841,12 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3763
3841
  role
3764
3842
  },
3765
3843
  openPageId
3766
- ) : section === "pages" ? /* @__PURE__ */ jsx12(PagesView, { api, canCreate: canCreatePages, onOpen: openPage }) : section === "media" ? /* @__PURE__ */ jsx12(
3844
+ ) : section === "pages" ? /* @__PURE__ */ jsx12(PagesView, { api, canCreate: access.createPages, onOpen: openPage }) : section === "media" ? /* @__PURE__ */ jsx12(
3767
3845
  MediaView,
3768
3846
  {
3769
3847
  api,
3770
- canDelete: canDeleteMedia,
3848
+ canDelete: access.deleteMedia,
3849
+ canReplace: access.replaceMedia,
3771
3850
  media,
3772
3851
  mediaUrl,
3773
3852
  onChanged: refreshMedia,
@@ -3777,23 +3856,24 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3777
3856
  FormsView,
3778
3857
  {
3779
3858
  api,
3780
- canDelete: canDeleteForms,
3781
- canWrite: canWriteForms,
3859
+ canDelete: access.deleteForms,
3860
+ canReadSubmissions: access.readSubmissions,
3861
+ canWrite: access.writeForms,
3782
3862
  onOpenSubmissions: (formId) => {
3783
3863
  setSubmissionsFormId(formId);
3784
3864
  setSection("submissions");
3785
3865
  }
3786
3866
  }
3787
- ) : section === "submissions" ? /* @__PURE__ */ jsx12(
3867
+ ) : section === "submissions" && access.readSubmissions ? /* @__PURE__ */ jsx12(
3788
3868
  SubmissionsView,
3789
3869
  {
3790
3870
  api,
3791
- canManage: canManageSubmissions,
3871
+ canManage: access.manageSubmissions,
3792
3872
  initialFormId: submissionsFormId,
3793
3873
  onUnreadChanged: refreshUnread
3794
3874
  },
3795
3875
  submissionsFormId || "all"
3796
- ) : section === "analytics" ? /* @__PURE__ */ jsx12(AnalyticsView, { api }) : section === "globals" ? /* @__PURE__ */ jsx12(
3876
+ ) : section === "analytics" && access.readAnalytics ? /* @__PURE__ */ jsx12(AnalyticsView, { api }) : section === "globals" ? /* @__PURE__ */ jsx12(
3797
3877
  GlobalsView,
3798
3878
  {
3799
3879
  api,
@@ -3803,10 +3883,12 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3803
3883
  onUploadMedia: uploadMedia,
3804
3884
  pages
3805
3885
  }
3806
- ) : section === "redirects" && canManageRedirects ? /* @__PURE__ */ jsx12(RedirectsView, { api }) : section === "users" && canManageUsers ? /* @__PURE__ */ jsx12(UsersView, { api, meId: session.user.id }) : /* @__PURE__ */ jsx12(
3886
+ ) : section === "redirects" && access.manageRedirects ? /* @__PURE__ */ jsx12(RedirectsView, { api }) : section === "users" && access.manageUsers ? /* @__PURE__ */ jsx12(UsersView, { api, meId: session.user.id }) : /* @__PURE__ */ jsx12(
3807
3887
  DashboardView,
3808
3888
  {
3809
3889
  api,
3890
+ canReadAnalytics: access.readAnalytics,
3891
+ canReadSubmissions: access.readSubmissions,
3810
3892
  onOpenPage: openPage,
3811
3893
  onOpenSubmissions: () => goTo("submissions"),
3812
3894
  siteName
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {