Skip to content

Commit d4637df

Browse files
authored
Merge pull request #586 from performant-software/fix/role-based-ui
add role-based UI restrictions for TinaCMS editors
2 parents 6e6f4cb + 0c8f87b commit d4637df

File tree

10 files changed

+295
-11
lines changed

10 files changed

+295
-11
lines changed

docs/clerk-rbac-fixes.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Clerk RBAC Fixes
2+
3+
Tracking fixes and improvements for PR #577 (Clerk-based RBAC for TinaCMS).
4+
5+
## Quick Wins
6+
7+
- [ ] **Clerk logout doesn't fully sign out**`session.remove()` should be `signOut()` in `tina/auth-provider.ts` (PR #583)
8+
- [ ] **Unpublished posts/paths visible in SSR** — detail pages don't check published status; should return 404 (PR #584)
9+
- [ ] **Dev script timeout**`scripts/dev.sh` needs pre-build step to avoid Netlify CLI timeout (local only, not PRed)
10+
11+
## Known Limitations (PR #586)
12+
13+
- [ ] **`_ownershipNotice` appears as sort option** — The pseudo-field shows up in the collection list sort dropdown. Need to find a way to exclude it or use a different approach for injecting the banner.
14+
- [ ] **CSS selectors depend on Tina's utility classes** — Form disabling targets classes like `.relative.w-full.flex-1.overflow-hidden` and `.border-b.border-gray-100`. These could break if TinaCMS updates its markup in a future version.
15+
- [ ] **Pre-existing content without `creator` field is editable by all editors** — Posts/paths created before the RBAC feature have no `creator.id`, so `OwnershipNotice` treats them as owned by the current user. Need a migration strategy or a policy decision (e.g., admin-only for unclaimed content).
16+
17+
## Compatibility & Maintenance Questions
18+
19+
- [x] **Non-Clerk site compatibility** — Resolved: `cmsCallback` gates role-ui behind `useSSO`, and `getUserRole` defaults to admin when no Clerk user is found. Non-Clerk sites are unaffected by RBAC restrictions.
20+
- [ ] **Migrate non-Clerk sites to Clerk** — Existing sites using native Tina user management (`TINA_PUBLIC_AUTH_USE_SSO=false`) should be migrated to Clerk for RBAC. Needs: a migration guide or script covering Clerk org setup, user invitations, env var changes (`CLERK_SECRET`, `TINA_PUBLIC_CLERK_PUBLIC_KEY`, `TINA_PUBLIC_CLERK_ORG_ID`, `TINA_PUBLIC_AUTH_USE_SSO=true`), and assigning `creator` fields to existing content so ownership works.
21+
- [ ] **Retesting after Tina/Astro upgrades** — The CSS selectors and MutationObserver approach depend on Tina's internal DOM structure. Define a manual or automated test checklist to run after upgrading TinaCMS or Astro (sidebar locking, form disabling, ownership banner, save guard).
22+
- [ ] **Adding new content types** — When new collections (beyond Posts/Paths) are added that editors should access, they need: the `_ownershipNotice` field, `creator` fields, `beforeSubmit` guard, and to NOT be in the `ADMIN_ONLY_COLLECTIONS` list. Document this as part of the "add a new collection" checklist.
23+
- [ ] **Adding features to existing content types** — New fields on Posts/Paths are automatically covered by the CSS-disable approach (targets the entire form scroll area). But new field types with unusual DOM elements might not be caught. Test RBAC after adding fields.
24+
25+
## Pre-existing Issues (not caused by RBAC work)
26+
27+
- [ ] **Timeline MDX component not rendering in post preview** — Map and table components render correctly, but `<timeline>` renders as code. Component is registered in `PostContent.tsx` and used in `demo-of-post-functionality.mdx`. May be a data parsing issue with the HTML-encoded JSON in the `data` attribute.
28+
29+
## Visual Editing
30+
31+
- [ ] **Post title/author/date don't live-update in Tina preview** — These fields are server-rendered in `Post.astro` for fast page shell loading; only `body` is in the `PostContent.tsx` React component wired to `useTina()`. This split was deliberate (Derek's server islands work, `4229b63`; Rebecca kept it when adding visual editing, `27eaec4`). Fix would move metadata rendering into `PostContent.tsx`, trading server-render speed for live editability. Same issue likely applies to paths.
32+
33+
## Deferred (needs Rebecca's review)
34+
35+
- [ ] **#580 Improve error feedback** — Tina shows generic errors for unauthorized actions. Backend already returns descriptive messages but Tina's frontend doesn't surface them. Rebecca says this involves intercepting Tina's native error handling.
36+
- [ ] **#581 Hide admin-only sidebar links** — Addressed in PR #586 with MutationObserver approach. Rebecca should review the implementation.
37+
- [ ] **#580 (partial) Read-only fields for editors** — Addressed in PR #586 with CSS-disable approach. Rebecca should review.

scripts/build.content.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export const fetchContent = async () => {
2020
// Copy the "content" folder to the current directory
2121
fs.cpSync(`${TEMP_DIR}/content`, './content', { recursive: true });
2222

23+
// Append any custom Netlify config to the main one
24+
// (mainly used for redirecting the admin site to a Performant Studio subdomain)
25+
if (fs.existsSync('./content/netlify.toml')) {
26+
const customConfig = fs.readFileSync('./content/netlify.toml', 'utf8');
27+
const existingConfig = fs.readFileSync('./netlify.toml', 'utf8');
28+
const newConfig = `${existingConfig}\n\n${customConfig}`;
29+
fs.writeFileSync('./netlify.toml', newConfig);
30+
}
31+
2332
// Remove the temporary directory.
2433
fs.rmSync(TEMP_DIR, { recursive: true });
2534
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useEffect, useMemo } from 'react';
2+
import { useCMS } from 'tinacms';
3+
import { getUserRole } from '../utils/getUserRole';
4+
5+
/**
6+
* A pseudo-field component that renders a read-only banner when
7+
* the current user doesn't own the content, or if the content is
8+
* published and can't be edited further. Placed at the top
9+
* of the fields list in posts/paths collections.
10+
*
11+
* Also injects a data attribute on the form for CSS-based
12+
* disabling of other fields.
13+
*/
14+
const NotEditableNotice = (props: any) => {
15+
const cms = useCMS();
16+
const { isAdmin, userId } = getUserRole(cms);
17+
18+
const creatorId = props.tinaForm?.values?.creator?.id;
19+
const isOwner = !creatorId || creatorId === userId;
20+
const isReadOnly = !isAdmin && (!isOwner || props.tinaForm?.values?.published);
21+
22+
const msg = useMemo(() => (
23+
!isOwner
24+
? 'You\'re not the author of this content. You can view but not edit.'
25+
: 'This content has been published and cannot be edited.'
26+
), [isOwner]);
27+
28+
useEffect(() => {
29+
document.body.dataset.tinaReadOnly = isReadOnly ? 'true' : 'false';
30+
}, [isReadOnly]);
31+
32+
if (!isReadOnly) return null;
33+
34+
return (
35+
<div
36+
style={{
37+
background: '#fef3c7',
38+
border: '1px solid #f59e0b',
39+
borderRadius: '6px',
40+
padding: '10px 14px',
41+
marginBottom: '8px',
42+
display: 'flex',
43+
alignItems: 'center',
44+
gap: '8px',
45+
fontSize: '13px',
46+
color: '#92400e',
47+
boxSizing: 'border-box',
48+
maxWidth: '100%',
49+
}}
50+
>
51+
<span>🔒</span>
52+
<span style={{ wordBreak: 'break-word', minWidth: 0 }}>
53+
{ msg }
54+
</span>
55+
</div>
56+
);
57+
};
58+
59+
export default NotEditableNotice;

tina/components/PublishToggle.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import { Switch } from "@headlessui/react";
22
import { useCMS } from "tinacms";
3-
import _ from "underscore";
3+
import { getUserRole } from '../utils/getUserRole';
44

55

66
const PublishToggle = (props) => {
7-
const tina = useCMS();
8-
const user = tina?.api?.tina?.authProvider?.clerk?.user; // TODO: this is very dependent on the exact clerk schema; should probably be factored out somehow?
9-
const userRole = _.find(user?.organizationMemberships, (org) => (org.organization.id === process.env.TINA_PUBLIC_CLERK_ORG_ID))?.role;
7+
const cms = useCMS();
8+
const { isAdmin } = getUserRole(cms);
109

1110
return (
1211
<div>
1312
{
14-
userRole === 'org:admin' && (
13+
isAdmin && (
1514
<div className='flex flex-col gap-3 mb-5 p-2'>
1615
<p className='font-semibold text-sm'>Published</p>
1716
<Switch
@@ -32,7 +31,7 @@ const PublishToggle = (props) => {
3231
}
3332

3433
{
35-
userRole !== 'org:admin' && (
34+
!isAdmin && (
3635
<div className='flex flex-col gap-3 p-2 mb-5 text-sm'>
3736
<p className='font-bold'>
3837
{

tina/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export default defineConfig({
3131
outputFolder: 'admin',
3232
publicFolder: 'public',
3333
},
34+
cmsCallback: (cms) => {
35+
if (useSSO) {
36+
import('./role-ui').then(({ applyRoleRestrictions }) => {
37+
applyRoleRestrictions(cms);
38+
});
39+
}
40+
return cms;
41+
},
3442
contentApiUrlOverride: '/api/tina/gql',
3543
localContentPath,
3644
media: {

tina/content/paths.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import _ from 'underscore';
22
import Creator from '../components/Creator';
3+
import NotEditableNotice from '../components/NotEditableNotice';
34
import PublishToggle from '../components/PublishToggle';
45
import TinaMediaPicker from '../components/TinaMediaPicker';
56
import TinaPlacePicker from '../components/TinaPlacePicker';
67
import { Collection, TinaField } from '@tinacms/schema-tools';
78
import config from '@config';
9+
import { getUserRole } from '../utils/getUserRole';
810

911
export const pathMetadata: TinaField<false>[] = _.compact([
1012
{
@@ -87,7 +89,15 @@ const Paths: Collection = {
8789
return `/en/paths/${hashHex}/preview/${document._sys.filename}`;
8890
},
8991
beforeSubmit: (arg: { values, form, cms }) => {
90-
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user; // update this to also work for native tina auth?
92+
const { isAdmin, userId } = getUserRole(arg.cms);
93+
94+
// Block saves for non-owners
95+
if (!isAdmin && arg.values.creator?.id && arg.values.creator.id !== userId) {
96+
throw new Error('You can only edit content you created.');
97+
}
98+
99+
// Auto-populate creator on first save
100+
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user;
91101
if (!arg.values.creator && user) {
92102
arg.values.creator = {
93103
id: user.id,
@@ -98,7 +108,14 @@ const Paths: Collection = {
98108
}
99109
},
100110
fields: [
101-
...pathMetadata,
111+
{
112+
name: '_notEditableNotice',
113+
type: 'string',
114+
ui: {
115+
component: NotEditableNotice
116+
}
117+
},
118+
...pathMetadata,
102119
{
103120
name: 'description',
104121
label: 'Description',

tina/content/posts.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import TinaPlacePicker from '../components/TinaPlacePicker';
22
import { Collection, TinaField } from '@tinacms/schema-tools';
33
import Visualizations from '@root/tina/content/visualizations';
44
import Creator from '../components/Creator';
5+
import NotEditableNotice from '../components/NotEditableNotice';
56
import _ from 'underscore';
67
import config from '@config';
78
import { media } from './common';
89
import PublishToggle from '../components/PublishToggle';
10+
import { getUserRole } from '../utils/getUserRole';
911

1012
export const postMetadata: TinaField<false>[] = _.compact([
1113
{
@@ -77,9 +79,16 @@ const Posts: Collection = {
7779
.join('');
7880
return `/en/posts/${hashHex}/preview/${document._sys.filename}`;
7981
},
80-
// Automatically set authorEmail on create
8182
beforeSubmit: (arg: { values, form, cms }) => {
82-
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user; // update this to also work for native tina auth?
83+
const { isAdmin, userId } = getUserRole(arg.cms);
84+
85+
// Block saves for non-owners
86+
if (!isAdmin && arg.values.creator?.id && arg.values.creator.id !== userId) {
87+
throw new Error('You can only edit content you created.');
88+
}
89+
90+
// Auto-populate creator on first save
91+
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user;
8392
if (!arg.values.creator && user) {
8493
arg.values.creator = {
8594
id: user.id,
@@ -90,6 +99,13 @@ const Posts: Collection = {
9099
}
91100
},
92101
fields: _.compact([
102+
{
103+
name: '_notEditableNotice',
104+
type: 'string',
105+
ui: {
106+
component: NotEditableNotice
107+
}
108+
},
93109
...postMetadata,
94110
{
95111
name: 'cardImage',

tina/role-ui.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { getUserRoleAsync } from './utils/getUserRole';
2+
3+
const ADMIN_ONLY_COLLECTIONS = ['Settings', 'Branding', 'Internationalization', 'Navbar', 'Pages'];
4+
5+
/**
6+
* Apply role-based UI restrictions to the TinaCMS admin.
7+
* Cosmetic only — backend enforcement is the security layer.
8+
*/
9+
export const applyRoleRestrictions = async (cms: any) => {
10+
const { isAdmin, userId } = await getUserRoleAsync(cms);
11+
12+
if (typeof document === 'undefined') return;
13+
14+
document.body.dataset.tinaRole = isAdmin ? 'admin' : 'editor';
15+
if (userId) {
16+
document.body.dataset.tinaUserId = userId;
17+
}
18+
19+
if (isAdmin) return;
20+
21+
// Form-level read-only behavior is CSS-only to avoid MutationObserver loops.
22+
// The data-tina-read-only attribute is set by OwnershipNotice.tsx.
23+
const styleId = 'tina-role-restrictions';
24+
if (!document.getElementById(styleId)) {
25+
const style = document.createElement('style');
26+
style.id = styleId;
27+
style.textContent = `
28+
/* Disable all interactive elements inside the form scroll area */
29+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden button,
30+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden input,
31+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden select,
32+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden textarea,
33+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [contenteditable="true"],
34+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="textbox"],
35+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="combobox"],
36+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="radio"],
37+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="toolbar"],
38+
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [class*="cursor-pointer"] {
39+
pointer-events: none !important;
40+
opacity: 0.5;
41+
}
42+
43+
/* Hide the status dot SVG in the form header bar */
44+
body[data-tina-read-only="true"] .border-b.border-gray-100 > svg {
45+
display: none;
46+
}
47+
/* Show lock icon in its place */
48+
body[data-tina-read-only="true"] .border-b.border-gray-100:has(nav[aria-label="breadcrumb"])::after {
49+
content: "\\1F512";
50+
font-size: 14px;
51+
}
52+
`;
53+
document.head.appendChild(style);
54+
}
55+
56+
// MutationObserver only for sidebar link locking — no form manipulation.
57+
if (typeof MutationObserver === 'undefined') return;
58+
59+
const lockSidebarItems = () => {
60+
const links = document.querySelectorAll('a[href*="#/collections/"], a[href*="/admin"]');
61+
62+
links.forEach((link: Element) => {
63+
const el = link as HTMLAnchorElement;
64+
const text = el.textContent?.trim();
65+
if (!text || !ADMIN_ONLY_COLLECTIONS.includes(text)) return;
66+
if (el.getAttribute('data-role-locked')) return;
67+
68+
el.setAttribute('data-role-locked', 'true');
69+
el.style.color = '#9ca3af';
70+
el.style.opacity = '0.6';
71+
el.style.cursor = 'default';
72+
el.style.pointerEvents = 'none';
73+
el.setAttribute('aria-disabled', 'true');
74+
el.removeAttribute('href');
75+
76+
if (!el.textContent?.startsWith('\u{1F512}')) {
77+
el.textContent = `\u{1F512} ${text}`;
78+
}
79+
});
80+
};
81+
82+
lockSidebarItems();
83+
const observer = new MutationObserver(lockSidebarItems);
84+
observer.observe(document.body, { childList: true, subtree: true });
85+
};

tina/tina-lock.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

tina/utils/getUserRole.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import _ from 'underscore';
2+
3+
interface RoleInfo {
4+
role: string;
5+
userId: string | undefined;
6+
isAdmin: boolean;
7+
}
8+
9+
/**
10+
* Loads the current Clerk instance and then calls `getUserRole`
11+
* @param cms
12+
* @returns Promise<RoleInfo>
13+
*/
14+
15+
export const getUserRoleAsync = async (cms: any): Promise<RoleInfo> => {
16+
await cms?.api?.tina?.authProvider?.clerk?.load();
17+
return getUserRole(cms);
18+
}
19+
20+
/**
21+
* Detect the current user's role from Clerk org membership,
22+
* with a dev-mode override for local testing.
23+
*/
24+
export const getUserRole = (cms: any): RoleInfo => {
25+
// Dev-mode override for local testing without Clerk
26+
if (
27+
process.env.TINA_PUBLIC_IS_LOCAL === 'true' &&
28+
process.env.TINA_PUBLIC_DEV_ROLE
29+
) {
30+
return {
31+
role: process.env.TINA_PUBLIC_DEV_ROLE,
32+
userId: process.env.TINA_PUBLIC_DEV_USER_ID || 'dev-user',
33+
isAdmin: process.env.TINA_PUBLIC_DEV_ROLE === 'org:admin',
34+
};
35+
}
36+
37+
const user = cms?.api?.tina?.authProvider?.clerk?.user;
38+
39+
// No Clerk user — site may not use Clerk. Default to admin (no restrictions).
40+
if (!user) {
41+
return { role: 'org:admin', userId: undefined, isAdmin: true };
42+
}
43+
44+
const membership = _.find(
45+
user?.organizationMemberships,
46+
(org: any) => org.organization.id === process.env.TINA_PUBLIC_CLERK_ORG_ID
47+
);
48+
49+
return {
50+
role: membership?.role || 'org:member',
51+
userId: user?.id,
52+
isAdmin: membership?.role === 'org:admin',
53+
};
54+
};

0 commit comments

Comments
 (0)