Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/clerk-rbac-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Clerk RBAC Fixes

Tracking fixes and improvements for PR #577 (Clerk-based RBAC for TinaCMS).

## Quick Wins

- [ ] **Clerk logout doesn't fully sign out** — `session.remove()` should be `signOut()` in `tina/auth-provider.ts` (PR #583)
- [ ] **Unpublished posts/paths visible in SSR** — detail pages don't check published status; should return 404 (PR #584)
- [ ] **Dev script timeout** — `scripts/dev.sh` needs pre-build step to avoid Netlify CLI timeout (local only, not PRed)

## Known Limitations (PR #586)

- [ ] **`_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.
- [ ] **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.
- [ ] **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).

## Compatibility & Maintenance Questions

- [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.
- [ ] **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.
- [ ] **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).
- [ ] **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.
- [ ] **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.

## Pre-existing Issues (not caused by RBAC work)

- [ ] **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.

## Visual Editing

- [ ] **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.

## Deferred (needs Rebecca's review)

- [ ] **#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.
- [ ] **#581 Hide admin-only sidebar links** — Addressed in PR #586 with MutationObserver approach. Rebecca should review the implementation.
- [ ] **#580 (partial) Read-only fields for editors** — Addressed in PR #586 with CSS-disable approach. Rebecca should review.
9 changes: 9 additions & 0 deletions scripts/build.content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ export const fetchContent = async () => {
// Copy the "content" folder to the current directory
fs.cpSync(`${TEMP_DIR}/content`, './content', { recursive: true });

// Append any custom Netlify config to the main one
// (mainly used for redirecting the admin site to a Performant Studio subdomain)
if (fs.existsSync('./content/netlify.toml')) {
const customConfig = fs.readFileSync('./content/netlify.toml', 'utf8');
const existingConfig = fs.readFileSync('./netlify.toml', 'utf8');
const newConfig = `${existingConfig}\n\n${customConfig}`;
fs.writeFileSync('./netlify.toml', newConfig);
}

// Remove the temporary directory.
fs.rmSync(TEMP_DIR, { recursive: true });
};
59 changes: 59 additions & 0 deletions tina/components/NotEditableNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect, useMemo } from 'react';
import { useCMS } from 'tinacms';
import { getUserRole } from '../utils/getUserRole';

/**
* A pseudo-field component that renders a read-only banner when
* the current user doesn't own the content, or if the content is
* published and can't be edited further. Placed at the top
* of the fields list in posts/paths collections.
*
* Also injects a data attribute on the form for CSS-based
* disabling of other fields.
*/
const NotEditableNotice = (props: any) => {
const cms = useCMS();
const { isAdmin, userId } = getUserRole(cms);

const creatorId = props.tinaForm?.values?.creator?.id;
const isOwner = !creatorId || creatorId === userId;
const isReadOnly = !isAdmin && (!isOwner || props.tinaForm?.values?.published);

const msg = useMemo(() => (
!isOwner
? 'You\'re not the author of this content. You can view but not edit.'
: 'This content has been published and cannot be edited.'
), [isOwner]);

useEffect(() => {
document.body.dataset.tinaReadOnly = isReadOnly ? 'true' : 'false';
}, [isReadOnly]);

if (!isReadOnly) return null;

return (
<div
style={{
background: '#fef3c7',
border: '1px solid #f59e0b',
borderRadius: '6px',
padding: '10px 14px',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
color: '#92400e',
boxSizing: 'border-box',
maxWidth: '100%',
}}
>
<span>🔒</span>
<span style={{ wordBreak: 'break-word', minWidth: 0 }}>
{ msg }
</span>
</div>
);
};

export default NotEditableNotice;
11 changes: 5 additions & 6 deletions tina/components/PublishToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { Switch } from "@headlessui/react";
import { useCMS } from "tinacms";
import _ from "underscore";
import { getUserRole } from '../utils/getUserRole';


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

return (
<div>
{
userRole === 'org:admin' && (
isAdmin && (
<div className='flex flex-col gap-3 mb-5 p-2'>
<p className='font-semibold text-sm'>Published</p>
<Switch
Expand All @@ -32,7 +31,7 @@ const PublishToggle = (props) => {
}

{
userRole !== 'org:admin' && (
!isAdmin && (
<div className='flex flex-col gap-3 p-2 mb-5 text-sm'>
<p className='font-bold'>
{
Expand Down
8 changes: 8 additions & 0 deletions tina/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export default defineConfig({
outputFolder: 'admin',
publicFolder: 'public',
},
cmsCallback: (cms) => {
if (useSSO) {
import('./role-ui').then(({ applyRoleRestrictions }) => {
applyRoleRestrictions(cms);
});
}
return cms;
},
contentApiUrlOverride: '/api/tina/gql',
localContentPath,
media: {
Expand Down
21 changes: 19 additions & 2 deletions tina/content/paths.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import _ from 'underscore';
import Creator from '../components/Creator';
import NotEditableNotice from '../components/NotEditableNotice';
import PublishToggle from '../components/PublishToggle';
import TinaMediaPicker from '../components/TinaMediaPicker';
import TinaPlacePicker from '../components/TinaPlacePicker';
import { Collection, TinaField } from '@tinacms/schema-tools';
import config from '@config';
import { getUserRole } from '../utils/getUserRole';

export const pathMetadata: TinaField<false>[] = _.compact([
{
Expand Down Expand Up @@ -87,7 +89,15 @@ const Paths: Collection = {
return `/en/paths/${hashHex}/preview/${document._sys.filename}`;
},
beforeSubmit: (arg: { values, form, cms }) => {
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user; // update this to also work for native tina auth?
const { isAdmin, userId } = getUserRole(arg.cms);

// Block saves for non-owners
if (!isAdmin && arg.values.creator?.id && arg.values.creator.id !== userId) {
throw new Error('You can only edit content you created.');
}

// Auto-populate creator on first save
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user;
if (!arg.values.creator && user) {
arg.values.creator = {
id: user.id,
Expand All @@ -98,7 +108,14 @@ const Paths: Collection = {
}
},
fields: [
...pathMetadata,
{
name: '_notEditableNotice',
type: 'string',
ui: {
component: NotEditableNotice
}
},
...pathMetadata,
{
name: 'description',
label: 'Description',
Expand Down
20 changes: 18 additions & 2 deletions tina/content/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import TinaPlacePicker from '../components/TinaPlacePicker';
import { Collection, TinaField } from '@tinacms/schema-tools';
import Visualizations from '@root/tina/content/visualizations';
import Creator from '../components/Creator';
import NotEditableNotice from '../components/NotEditableNotice';
import _ from 'underscore';
import config from '@config';
import { media } from './common';
import PublishToggle from '../components/PublishToggle';
import { getUserRole } from '../utils/getUserRole';

export const postMetadata: TinaField<false>[] = _.compact([
{
Expand Down Expand Up @@ -77,9 +79,16 @@ const Posts: Collection = {
.join('');
return `/en/posts/${hashHex}/preview/${document._sys.filename}`;
},
// Automatically set authorEmail on create
beforeSubmit: (arg: { values, form, cms }) => {
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user; // update this to also work for native tina auth?
const { isAdmin, userId } = getUserRole(arg.cms);

// Block saves for non-owners
if (!isAdmin && arg.values.creator?.id && arg.values.creator.id !== userId) {
throw new Error('You can only edit content you created.');
}

// Auto-populate creator on first save
const user = arg.cms?.api?.tina?.authProvider?.clerk?.user;
if (!arg.values.creator && user) {
arg.values.creator = {
id: user.id,
Expand All @@ -90,6 +99,13 @@ const Posts: Collection = {
}
},
fields: _.compact([
{
name: '_notEditableNotice',
type: 'string',
ui: {
component: NotEditableNotice
}
},
...postMetadata,
{
name: 'cardImage',
Expand Down
85 changes: 85 additions & 0 deletions tina/role-ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { getUserRoleAsync } from './utils/getUserRole';

const ADMIN_ONLY_COLLECTIONS = ['Settings', 'Branding', 'Internationalization', 'Navbar', 'Pages'];

/**
* Apply role-based UI restrictions to the TinaCMS admin.
* Cosmetic only — backend enforcement is the security layer.
*/
export const applyRoleRestrictions = async (cms: any) => {
const { isAdmin, userId } = await getUserRoleAsync(cms);

if (typeof document === 'undefined') return;

document.body.dataset.tinaRole = isAdmin ? 'admin' : 'editor';
if (userId) {
document.body.dataset.tinaUserId = userId;
}

if (isAdmin) return;

// Form-level read-only behavior is CSS-only to avoid MutationObserver loops.
// The data-tina-read-only attribute is set by OwnershipNotice.tsx.
const styleId = 'tina-role-restrictions';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
/* Disable all interactive elements inside the form scroll area */
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden button,
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden input,
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden select,
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden textarea,
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [contenteditable="true"],
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="textbox"],
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="combobox"],
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="radio"],
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [role="toolbar"],
body[data-tina-read-only="true"] .relative.w-full.flex-1.overflow-hidden [class*="cursor-pointer"] {
pointer-events: none !important;
opacity: 0.5;
}

/* Hide the status dot SVG in the form header bar */
body[data-tina-read-only="true"] .border-b.border-gray-100 > svg {
display: none;
}
/* Show lock icon in its place */
body[data-tina-read-only="true"] .border-b.border-gray-100:has(nav[aria-label="breadcrumb"])::after {
content: "\\1F512";
font-size: 14px;
}
`;
document.head.appendChild(style);
}

// MutationObserver only for sidebar link locking — no form manipulation.
if (typeof MutationObserver === 'undefined') return;

const lockSidebarItems = () => {
const links = document.querySelectorAll('a[href*="#/collections/"], a[href*="/admin"]');

links.forEach((link: Element) => {
const el = link as HTMLAnchorElement;
const text = el.textContent?.trim();
if (!text || !ADMIN_ONLY_COLLECTIONS.includes(text)) return;
if (el.getAttribute('data-role-locked')) return;

el.setAttribute('data-role-locked', 'true');
el.style.color = '#9ca3af';
el.style.opacity = '0.6';
el.style.cursor = 'default';
el.style.pointerEvents = 'none';
el.setAttribute('aria-disabled', 'true');
el.removeAttribute('href');

if (!el.textContent?.startsWith('\u{1F512}')) {
el.textContent = `\u{1F512} ${text}`;
}
});
};

lockSidebarItems();
const observer = new MutationObserver(lockSidebarItems);
observer.observe(document.body, { childList: true, subtree: true });
};
2 changes: 1 addition & 1 deletion tina/tina-lock.json

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions tina/utils/getUserRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import _ from 'underscore';

interface RoleInfo {
role: string;
userId: string | undefined;
isAdmin: boolean;
}

/**
* Loads the current Clerk instance and then calls `getUserRole`
* @param cms
* @returns Promise<RoleInfo>
*/

export const getUserRoleAsync = async (cms: any): Promise<RoleInfo> => {
await cms?.api?.tina?.authProvider?.clerk?.load();
return getUserRole(cms);
}

/**
* Detect the current user's role from Clerk org membership,
* with a dev-mode override for local testing.
*/
export const getUserRole = (cms: any): RoleInfo => {
// Dev-mode override for local testing without Clerk
if (
process.env.TINA_PUBLIC_IS_LOCAL === 'true' &&
process.env.TINA_PUBLIC_DEV_ROLE
) {
return {
role: process.env.TINA_PUBLIC_DEV_ROLE,
userId: process.env.TINA_PUBLIC_DEV_USER_ID || 'dev-user',
isAdmin: process.env.TINA_PUBLIC_DEV_ROLE === 'org:admin',
};
}

const user = cms?.api?.tina?.authProvider?.clerk?.user;

// No Clerk user — site may not use Clerk. Default to admin (no restrictions).
if (!user) {
return { role: 'org:admin', userId: undefined, isAdmin: true };
}

const membership = _.find(
user?.organizationMemberships,
(org: any) => org.organization.id === process.env.TINA_PUBLIC_CLERK_ORG_ID
);

return {
role: membership?.role || 'org:member',
userId: user?.id,
isAdmin: membership?.role === 'org:admin',
};
};