@orion-studios/cms 0.5.2 → 0.5.4
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.
package/dist/server/index.js
CHANGED
|
@@ -389,7 +389,7 @@ var MIN_ROLE = {
|
|
|
389
389
|
"pages.delete": "admin",
|
|
390
390
|
"pages.restore": "editor",
|
|
391
391
|
"globals.read": "content",
|
|
392
|
-
"globals.write": "
|
|
392
|
+
"globals.write": "editor",
|
|
393
393
|
"media.read": "content",
|
|
394
394
|
"media.upload": "content",
|
|
395
395
|
"media.update": "content",
|
|
@@ -663,6 +663,8 @@ var errors = {
|
|
|
663
663
|
var isRecord2 = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
664
664
|
var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
665
665
|
var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
|
|
666
|
+
var USER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
667
|
+
var canonicalizeUserId = (value) => USER_ID_PATTERN.test(value) ? value.toLowerCase() : null;
|
|
666
668
|
var DEFAULT_MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
|
|
667
669
|
var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
|
|
668
670
|
"image/jpeg",
|
|
@@ -1706,23 +1708,30 @@ function createCmsRoutes(options) {
|
|
|
1706
1708
|
const listUsers = async (request) => {
|
|
1707
1709
|
const auth = await guard(request, "users.manage");
|
|
1708
1710
|
if (auth instanceof Response) return auth;
|
|
1709
|
-
const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
|
|
1710
|
-
if (authError) return errors.badRequest(authError.message);
|
|
1711
1711
|
const { data: profiles, error: profileError } = await db().from("cms_profiles").select("user_id, role, name");
|
|
1712
1712
|
if (profileError) return errors.badRequest(profileError.message);
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1713
|
+
const profileById = /* @__PURE__ */ new Map();
|
|
1714
|
+
for (const profile of profiles || []) {
|
|
1715
|
+
const userId = typeof profile.user_id === "string" ? canonicalizeUserId(profile.user_id) : null;
|
|
1716
|
+
if (!userId || !isCmsRole(profile.role)) continue;
|
|
1717
|
+
profileById.set(userId, {
|
|
1718
|
+
role: profile.role,
|
|
1719
|
+
name: typeof profile.name === "string" ? profile.name : ""
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
|
|
1723
|
+
if (authError) return errors.badRequest(authError.message);
|
|
1724
|
+
const users = authData.users.flatMap((user) => {
|
|
1725
|
+
const canonicalUserId = canonicalizeUserId(user.id);
|
|
1726
|
+
const profile = canonicalUserId ? profileById.get(canonicalUserId) : void 0;
|
|
1727
|
+
return profile ? [{
|
|
1719
1728
|
id: user.id,
|
|
1720
1729
|
email: user.email ?? null,
|
|
1721
|
-
name: profile
|
|
1722
|
-
role: profile
|
|
1730
|
+
name: profile.name,
|
|
1731
|
+
role: profile.role,
|
|
1723
1732
|
created_at: user.created_at ?? null,
|
|
1724
1733
|
last_sign_in_at: user.last_sign_in_at ?? null
|
|
1725
|
-
};
|
|
1734
|
+
}] : [];
|
|
1726
1735
|
});
|
|
1727
1736
|
return json({ users, assignableRoles: assignableRoles(auth.user.role) });
|
|
1728
1737
|
};
|
|
@@ -1757,20 +1766,31 @@ function createCmsRoutes(options) {
|
|
|
1757
1766
|
await logActivity(auth.user, "user.create", email);
|
|
1758
1767
|
return json({ user: { id: userId, email, name, role } }, 201);
|
|
1759
1768
|
};
|
|
1760
|
-
const
|
|
1761
|
-
const { data } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
|
|
1762
|
-
return
|
|
1769
|
+
const loadMembership = async (userId) => {
|
|
1770
|
+
const { data, error } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
|
|
1771
|
+
if (error) return { status: "query-error", message: error.message };
|
|
1772
|
+
if (!data) return { status: "missing" };
|
|
1773
|
+
if (!isCmsRole(data.role)) return { status: "invalid-role" };
|
|
1774
|
+
return { status: "valid", role: data.role };
|
|
1775
|
+
};
|
|
1776
|
+
const membershipError = (membership) => {
|
|
1777
|
+
if (membership.status === "missing") return errors.notFound();
|
|
1778
|
+
if (membership.status === "invalid-role") return errors.badRequest("Invalid CMS profile role.");
|
|
1779
|
+
return errors.badRequest(membership.message);
|
|
1763
1780
|
};
|
|
1764
1781
|
const updateUser = async (request, userId) => {
|
|
1765
1782
|
const auth = await guard(request, "users.manage");
|
|
1766
1783
|
if (auth instanceof Response) return auth;
|
|
1784
|
+
const canonicalUserId = canonicalizeUserId(userId);
|
|
1785
|
+
if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
|
|
1767
1786
|
const body = await readJson(request);
|
|
1768
1787
|
if (!body) return errors.badRequest("Invalid body.");
|
|
1769
|
-
const
|
|
1770
|
-
if (
|
|
1788
|
+
const membership = await loadMembership(canonicalUserId);
|
|
1789
|
+
if (membership.status !== "valid") return membershipError(membership);
|
|
1790
|
+
if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
|
|
1771
1791
|
if (body.role !== void 0) {
|
|
1772
1792
|
if (!isCmsRole(body.role)) return errors.badRequest("Invalid role.");
|
|
1773
|
-
if (
|
|
1793
|
+
if (canonicalUserId === auth.user.id && body.role !== auth.user.role) {
|
|
1774
1794
|
return errors.badRequest("You can't change your own role.");
|
|
1775
1795
|
}
|
|
1776
1796
|
if (!outranksOrEqual(auth.user.role, body.role)) {
|
|
@@ -1779,32 +1799,37 @@ function createCmsRoutes(options) {
|
|
|
1779
1799
|
}
|
|
1780
1800
|
if (typeof body.password === "string") {
|
|
1781
1801
|
if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
|
|
1782
|
-
const { error: passwordError } = await db().auth.admin.updateUserById(
|
|
1802
|
+
const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
|
|
1783
1803
|
password: body.password
|
|
1784
1804
|
});
|
|
1785
1805
|
if (passwordError) return errors.badRequest(passwordError.message);
|
|
1786
1806
|
}
|
|
1787
1807
|
if (body.role !== void 0 || typeof body.name === "string") {
|
|
1788
|
-
const patch = {
|
|
1808
|
+
const patch = {};
|
|
1789
1809
|
if (body.role !== void 0) patch.role = body.role;
|
|
1790
1810
|
if (typeof body.name === "string") patch.name = body.name.trim();
|
|
1791
|
-
const { error: profileError } = await db().from("cms_profiles").
|
|
1811
|
+
const { error: profileError } = await db().from("cms_profiles").update(patch).eq("user_id", canonicalUserId);
|
|
1792
1812
|
if (profileError) return errors.badRequest(profileError.message);
|
|
1793
1813
|
}
|
|
1794
|
-
await logActivity(auth.user, "user.update",
|
|
1814
|
+
await logActivity(auth.user, "user.update", canonicalUserId);
|
|
1795
1815
|
return json({ success: true });
|
|
1796
1816
|
};
|
|
1797
1817
|
const deleteUser = async (request, userId) => {
|
|
1798
1818
|
const auth = await guard(request, "users.manage");
|
|
1799
1819
|
if (auth instanceof Response) return auth;
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1820
|
+
const canonicalUserId = canonicalizeUserId(userId);
|
|
1821
|
+
if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
|
|
1822
|
+
const membership = await loadMembership(canonicalUserId);
|
|
1823
|
+
if (membership.status !== "valid") return membershipError(membership);
|
|
1824
|
+
if (canonicalUserId === auth.user.id) {
|
|
1825
|
+
return errors.badRequest("You can't remove your own account.");
|
|
1826
|
+
}
|
|
1827
|
+
if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
|
|
1828
|
+
const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
|
|
1804
1829
|
if (authError) return errors.badRequest(authError.message);
|
|
1805
|
-
const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id",
|
|
1830
|
+
const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
|
|
1806
1831
|
if (profileError) return errors.badRequest(profileError.message);
|
|
1807
|
-
await logActivity(auth.user, "user.delete",
|
|
1832
|
+
await logActivity(auth.user, "user.delete", canonicalUserId);
|
|
1808
1833
|
return json({ success: true });
|
|
1809
1834
|
};
|
|
1810
1835
|
const runSync = async (request) => {
|
|
@@ -2204,6 +2229,9 @@ function runRpc(store, fn, args = {}) {
|
|
|
2204
2229
|
}
|
|
2205
2230
|
const version = reviewedVersion(page, args.p_expected_version_id);
|
|
2206
2231
|
if (!version) return versionConflict("the expected draft is missing or stale");
|
|
2232
|
+
if (page.published_version_id === version.id) {
|
|
2233
|
+
return versionConflict("the expected draft is already published");
|
|
2234
|
+
}
|
|
2207
2235
|
page.publish_at = args.p_publish_at;
|
|
2208
2236
|
page.scheduled_version_id = version.id;
|
|
2209
2237
|
page.updated_at = nowIso();
|
package/dist/studio/index.js
CHANGED
|
@@ -149,7 +149,7 @@ var MIN_ROLE = {
|
|
|
149
149
|
"pages.delete": "admin",
|
|
150
150
|
"pages.restore": "editor",
|
|
151
151
|
"globals.read": "content",
|
|
152
|
-
"globals.write": "
|
|
152
|
+
"globals.write": "editor",
|
|
153
153
|
"media.read": "content",
|
|
154
154
|
"media.upload": "content",
|
|
155
155
|
"media.update": "content",
|
|
@@ -179,13 +179,14 @@ var SECTION_ACTION = {
|
|
|
179
179
|
forms: "forms.read",
|
|
180
180
|
submissions: "submissions.read",
|
|
181
181
|
analytics: "analytics.read",
|
|
182
|
-
globals: "globals.
|
|
182
|
+
globals: "globals.write",
|
|
183
183
|
redirects: "redirects.manage",
|
|
184
184
|
users: "users.manage"
|
|
185
185
|
};
|
|
186
186
|
var userForRole = (role) => ({ id: "", email: null, name: "", role });
|
|
187
187
|
var studioCan = (role, action) => can(userForRole(role), action);
|
|
188
188
|
var canViewStudioSection = (role, section) => studioCan(role, SECTION_ACTION[section]);
|
|
189
|
+
var resolveStudioSection = (role, section) => role && canViewStudioSection(role, section) ? section : "dashboard";
|
|
189
190
|
var studioAccess = (role) => ({
|
|
190
191
|
changePageStructure: studioCan(role, "pages.changeStructure"),
|
|
191
192
|
publishPages: studioCan(role, "pages.publish"),
|
|
@@ -3728,11 +3729,15 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3728
3729
|
useEffect11(() => {
|
|
3729
3730
|
if (!session) {
|
|
3730
3731
|
setRole(null);
|
|
3732
|
+
setSection("dashboard");
|
|
3733
|
+
setOpenPageId(null);
|
|
3734
|
+
setSubmissionsFormId(null);
|
|
3731
3735
|
return;
|
|
3732
3736
|
}
|
|
3733
3737
|
api.me().then(
|
|
3734
3738
|
({ user }) => {
|
|
3735
3739
|
setRole(user.role);
|
|
3740
|
+
setSection((currentSection) => resolveStudioSection(user.role, currentSection));
|
|
3736
3741
|
refreshMedia();
|
|
3737
3742
|
refreshUnread();
|
|
3738
3743
|
refreshPages();
|
|
@@ -3784,6 +3789,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3784
3789
|
if (!role) return /* @__PURE__ */ jsx12("div", { className: "ost-root ost-loading", children: "Loading Studio\u2026" });
|
|
3785
3790
|
const access = studioAccess(role);
|
|
3786
3791
|
const nav = NAV.filter((item) => canViewStudioSection(role, item.key));
|
|
3792
|
+
const selectedSection = resolveStudioSection(role, section);
|
|
3787
3793
|
const goTo = (key) => {
|
|
3788
3794
|
setSection(key);
|
|
3789
3795
|
setOpenPageId(null);
|
|
@@ -3806,7 +3812,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3806
3812
|
nav.map((item) => /* @__PURE__ */ jsxs12(
|
|
3807
3813
|
"button",
|
|
3808
3814
|
{
|
|
3809
|
-
className: `ost-nav-item${
|
|
3815
|
+
className: `ost-nav-item${selectedSection === item.key && !openPageId ? " is-active" : ""}`,
|
|
3810
3816
|
onClick: () => goTo(item.key),
|
|
3811
3817
|
type: "button",
|
|
3812
3818
|
children: [
|
|
@@ -3841,7 +3847,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3841
3847
|
role
|
|
3842
3848
|
},
|
|
3843
3849
|
openPageId
|
|
3844
|
-
) :
|
|
3850
|
+
) : selectedSection === "pages" ? /* @__PURE__ */ jsx12(PagesView, { api, canCreate: access.createPages, onOpen: openPage }) : selectedSection === "media" ? /* @__PURE__ */ jsx12(
|
|
3845
3851
|
MediaView,
|
|
3846
3852
|
{
|
|
3847
3853
|
api,
|
|
@@ -3852,7 +3858,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3852
3858
|
onChanged: refreshMedia,
|
|
3853
3859
|
onUpload: uploadMedia
|
|
3854
3860
|
}
|
|
3855
|
-
) :
|
|
3861
|
+
) : selectedSection === "forms" ? /* @__PURE__ */ jsx12(
|
|
3856
3862
|
FormsView,
|
|
3857
3863
|
{
|
|
3858
3864
|
api,
|
|
@@ -3864,7 +3870,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3864
3870
|
setSection("submissions");
|
|
3865
3871
|
}
|
|
3866
3872
|
}
|
|
3867
|
-
) :
|
|
3873
|
+
) : selectedSection === "submissions" && access.readSubmissions ? /* @__PURE__ */ jsx12(
|
|
3868
3874
|
SubmissionsView,
|
|
3869
3875
|
{
|
|
3870
3876
|
api,
|
|
@@ -3873,7 +3879,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3873
3879
|
onUnreadChanged: refreshUnread
|
|
3874
3880
|
},
|
|
3875
3881
|
submissionsFormId || "all"
|
|
3876
|
-
) :
|
|
3882
|
+
) : selectedSection === "analytics" && access.readAnalytics ? /* @__PURE__ */ jsx12(AnalyticsView, { api }) : selectedSection === "globals" ? /* @__PURE__ */ jsx12(
|
|
3877
3883
|
GlobalsView,
|
|
3878
3884
|
{
|
|
3879
3885
|
api,
|
|
@@ -3883,7 +3889,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3883
3889
|
onUploadMedia: uploadMedia,
|
|
3884
3890
|
pages
|
|
3885
3891
|
}
|
|
3886
|
-
) :
|
|
3892
|
+
) : selectedSection === "redirects" && access.manageRedirects ? /* @__PURE__ */ jsx12(RedirectsView, { api }) : selectedSection === "users" && access.manageUsers ? /* @__PURE__ */ jsx12(UsersView, { api, meId: session.user.id }) : /* @__PURE__ */ jsx12(
|
|
3887
3893
|
DashboardView,
|
|
3888
3894
|
{
|
|
3889
3895
|
api,
|
package/package.json
CHANGED
package/sql/bootstrap.sql
CHANGED
|
@@ -152,6 +152,16 @@ set publish_at = null,
|
|
|
152
152
|
where publish_at is not null
|
|
153
153
|
and scheduled_version_id is null;
|
|
154
154
|
|
|
155
|
+
-- A published version is not a future publication candidate. Clear invalid
|
|
156
|
+
-- schedules left by versions of the scheduling function that omitted this
|
|
157
|
+
-- guard, so repeated bootstrap runs repair existing databases fail closed.
|
|
158
|
+
update cms_pages
|
|
159
|
+
set publish_at = null,
|
|
160
|
+
scheduled_version_id = null,
|
|
161
|
+
updated_at = now()
|
|
162
|
+
where scheduled_version_id is not null
|
|
163
|
+
and scheduled_version_id = published_version_id;
|
|
164
|
+
|
|
155
165
|
create unique index if not exists cms_page_versions_page_id_id_idx
|
|
156
166
|
on cms_page_versions (page_id, id);
|
|
157
167
|
create index if not exists cms_pages_scheduled_publish_due_idx
|
|
@@ -624,6 +634,10 @@ begin
|
|
|
624
634
|
raise exception 'Page version conflict: expected draft is missing or stale';
|
|
625
635
|
end if;
|
|
626
636
|
|
|
637
|
+
if current_page.published_version_id = p_expected_version_id then
|
|
638
|
+
raise exception 'Page version conflict: expected draft is already published';
|
|
639
|
+
end if;
|
|
640
|
+
|
|
627
641
|
update cms_pages
|
|
628
642
|
set publish_at = p_publish_at,
|
|
629
643
|
scheduled_version_id = reviewed.id,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
-- SEC-005: reject scheduling a version that is already published.
|
|
2
|
+
|
|
3
|
+
-- Repair schedules accepted by the 0.5.2 function. The version is already
|
|
4
|
+
-- public, so there is no future immutable draft to publish. Editors must
|
|
5
|
+
-- explicitly select a newer draft.
|
|
6
|
+
update cms_pages
|
|
7
|
+
set publish_at = null,
|
|
8
|
+
scheduled_version_id = null,
|
|
9
|
+
updated_at = now()
|
|
10
|
+
where scheduled_version_id is not null
|
|
11
|
+
and scheduled_version_id = published_version_id;
|
|
12
|
+
|
|
13
|
+
create or replace function cms_schedule_page_publish(
|
|
14
|
+
p_page_id uuid,
|
|
15
|
+
p_expected_version_id bigint,
|
|
16
|
+
p_publish_at timestamptz,
|
|
17
|
+
p_actor uuid
|
|
18
|
+
) returns cms_pages
|
|
19
|
+
language plpgsql
|
|
20
|
+
security definer
|
|
21
|
+
set search_path = public
|
|
22
|
+
as $$
|
|
23
|
+
declare
|
|
24
|
+
current_page cms_pages;
|
|
25
|
+
reviewed cms_page_versions;
|
|
26
|
+
result cms_pages;
|
|
27
|
+
begin
|
|
28
|
+
select * into current_page
|
|
29
|
+
from cms_pages
|
|
30
|
+
where id = p_page_id
|
|
31
|
+
for update;
|
|
32
|
+
|
|
33
|
+
if current_page.id is null then
|
|
34
|
+
raise exception 'page % not found', p_page_id;
|
|
35
|
+
end if;
|
|
36
|
+
|
|
37
|
+
if p_publish_at is null then
|
|
38
|
+
update cms_pages
|
|
39
|
+
set publish_at = null,
|
|
40
|
+
scheduled_version_id = null,
|
|
41
|
+
updated_at = now()
|
|
42
|
+
where id = p_page_id
|
|
43
|
+
returning * into result;
|
|
44
|
+
return result;
|
|
45
|
+
end if;
|
|
46
|
+
|
|
47
|
+
select * into reviewed
|
|
48
|
+
from cms_page_versions
|
|
49
|
+
where id = p_expected_version_id
|
|
50
|
+
and page_id = p_page_id;
|
|
51
|
+
|
|
52
|
+
if reviewed.id is null
|
|
53
|
+
or current_page.draft_version_id is distinct from p_expected_version_id
|
|
54
|
+
or reviewed.title is distinct from current_page.title
|
|
55
|
+
or reviewed.seo is distinct from current_page.seo
|
|
56
|
+
or reviewed.layout is distinct from current_page.draft_layout then
|
|
57
|
+
raise exception 'Page version conflict: expected draft is missing or stale';
|
|
58
|
+
end if;
|
|
59
|
+
|
|
60
|
+
if current_page.published_version_id = p_expected_version_id then
|
|
61
|
+
raise exception 'Page version conflict: expected draft is already published';
|
|
62
|
+
end if;
|
|
63
|
+
|
|
64
|
+
update cms_pages
|
|
65
|
+
set publish_at = p_publish_at,
|
|
66
|
+
scheduled_version_id = reviewed.id,
|
|
67
|
+
updated_at = now()
|
|
68
|
+
where id = p_page_id
|
|
69
|
+
returning * into result;
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
end;
|
|
73
|
+
$$;
|
|
74
|
+
|
|
75
|
+
do $fn_lockdown$
|
|
76
|
+
begin
|
|
77
|
+
revoke execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) from public;
|
|
78
|
+
if exists (select 1 from pg_roles where rolname = 'anon') then
|
|
79
|
+
revoke execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) from anon;
|
|
80
|
+
end if;
|
|
81
|
+
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
82
|
+
revoke execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) from authenticated;
|
|
83
|
+
end if;
|
|
84
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
85
|
+
grant execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) to service_role;
|
|
86
|
+
end if;
|
|
87
|
+
end
|
|
88
|
+
$fn_lockdown$;
|