Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
87bd949
initial pass at adding clerk login flow to tina
ajolipa Mar 18, 2026
617ac64
improving error message
ajolipa Mar 19, 2026
0f81aca
adding some console logs to netlify function
ajolipa Mar 19, 2026
84a9b71
more logging
ajolipa Mar 19, 2026
1209620
updating org membership check on backend
ajolipa Mar 19, 2026
58e02fa
add 100 member org limit in backend auth
ajolipa Mar 19, 2026
97775a1
hide published field from non-admins; fix some permissions
ajolipa Mar 19, 2026
9c0ccf7
update publish field styling
ajolipa Mar 20, 2026
3ab3faf
improve published toggle; add checks for doc deletion/renaming
ajolipa Mar 20, 2026
48d2b02
update paths schema to match posts
ajolipa Mar 20, 2026
1a71a82
updating paths config stuff
ajolipa Mar 20, 2026
9977f8f
use signOut() instead of session.remove() for Clerk logout
jamiefolsom Mar 23, 2026
4b41517
return 404 for unpublished posts and paths in SSR mode
jamiefolsom Mar 23, 2026
bbe2150
add role-based UI restrictions for TinaCMS editors
jamiefolsom Mar 24, 2026
2fb2397
Merge pull request #583 from performant-software/fix/clerk-logout
ajolipa Mar 24, 2026
7dfa8d5
improve read-only UX for non-owned content
jamiefolsom Mar 24, 2026
5e98e1c
gate role-based UI behind SSO flag for non-Clerk site compatibility
jamiefolsom Mar 24, 2026
2bbc29a
admin redirect
camdendotlol Mar 24, 2026
9f6859f
reset config.json
camdendotlol Mar 24, 2026
fca5e15
one more newline
camdendotlol Mar 25, 2026
16bbe92
Merge pull request #593 from performant-software/cm/admin-redirect
ajolipa Mar 25, 2026
9d14906
moving 404 logic to child components
ajolipa Mar 25, 2026
6e6f4cb
Merge pull request #584 from performant-software/fix/unpublished-post…
ajolipa Mar 25, 2026
0c8f87b
fixing getUserRole function; disabling editing of published content
ajolipa Mar 25, 2026
d4637df
Merge pull request #586 from performant-software/fix/role-based-ui
ajolipa Mar 25, 2026
c130a42
fix custom netlify.toml script
ajolipa Mar 25, 2026
e0a7938
initial commit
camdendotlol Mar 25, 2026
06374e0
add handling for null document to role-ui
ajolipa Mar 25, 2026
8b84b7d
more troubleshooting
ajolipa Mar 25, 2026
3208903
WIP
camdendotlol Mar 26, 2026
e7b9e16
check in edge function too
camdendotlol Mar 26, 2026
51309ec
fixes
camdendotlol Mar 26, 2026
78846fc
Merge pull request #595 from performant-software/cm/rebuild-function
ajolipa Mar 26, 2026
6bf03fe
loading indicator (#596)
camdendotlol Mar 26, 2026
3cdb27d
Admin redirect (#597)
camdendotlol Mar 27, 2026
bef3444
Redirect edge function fixes (#598)
camdendotlol Mar 27, 2026
04598a0
add _astro to routes ignored by redirect (#599)
camdendotlol Mar 27, 2026
8540b3a
fix pathname (#600)
camdendotlol Mar 27, 2026
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
3 changes: 1 addition & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, envField } from 'astro/config';
import auth from 'auth-astro';
import { loadEnv } from 'vite';
import config from './public/config.json';

Expand All @@ -22,7 +21,7 @@ export default defineConfig({
},
output: STATIC_BUILD === 'true' ? 'static' : 'server',
adapter: netlify(),
integrations: [mdx(), sitemap(), react(), auth()],
integrations: [mdx(), sitemap(), react()],
vite: {
optimizeDeps: {
esbuildOptions: {
Expand Down
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.
123 changes: 123 additions & 0 deletions netlify/edge-functions/rebuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Context } from '@netlify/edge-functions';
import { Clerk } from '@clerk/backend';

const BASE_URL = 'https://api.netlify.com/api/v1';
const NETLIFY_TOKEN = Netlify.env.get('NETLIFY_TOKEN')!;

const clerkClient = Clerk({
secretKey: Netlify.env.get('CLERK_SECRET')!,
});

function buildResponse(statusCode: number, body: any | null): Response {
return new Response(JSON.stringify(body), {
status: statusCode,
headers: { 'Content-Type': 'application/json' },
});
}

async function authenticate(req: Request): Promise<boolean> {
const token = req.headers.get('authorization');
const tokenWithoutBearer = token?.replace('Bearer ', '').trim();

const { toAuth } = await clerkClient.authenticateRequest({
headerToken: tokenWithoutBearer
});

const { sessionClaims } = toAuth();

const isMember = sessionClaims.o.id === Netlify.env.get('TINA_PUBLIC_CLERK_ORG_ID')
const isAdmin = sessionClaims.o.rol === 'admin'

return isMember && isAdmin;
}

async function netlifyFetch(path: string, options: RequestInit = {}): Promise<globalThis.Response> {
return fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${NETLIFY_TOKEN}`,
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
},
});
}

async function getActiveBuild(siteId: string) {
const res = await netlifyFetch(`/sites/${siteId}/deploys?per_page=5`);
const deploys = await res.json();

const activeStates = [
'building',
'enqueued',
'preparing',
'processing',
'uploading',
'uploaded',
];

return deploys.find((d) => activeStates.includes(d.state)) ?? null;
}

async function triggerBuild(siteId: string) {
const res = await netlifyFetch(`/sites/${siteId}/builds`, {
method: 'POST',
body: JSON.stringify({}),
});

if (!res.ok) {
const text = await res.text();
throw new Error(`Netlify API error (${res.status}): ${text}`);
}

return res.json();
}

const handler = async (req: Request, context: Context): Promise<Response> => {
if (req.method !== 'POST') {
return buildResponse(405, { error: 'Method not allowed' });
}

let isAdmin = false;
try {
isAdmin = await authenticate(req);
} catch (e) {
return buildResponse(401, { message: e.message || 'Authentication failed' });
}

if (!isAdmin) {
return buildResponse(401, { message: 'Unauthorized' });
}

let activeBuild;
try {
activeBuild = await getActiveBuild(context.site.id);
} catch (err) {
return buildResponse(502, {
message: 'Failed to check build status'
});
}

if (activeBuild) {
return buildResponse(409, {
message: 'A build is already in progress'
});
}

try {
await triggerBuild(context.site.id);
return buildResponse(200, {
message: 'Build triggered successfully'
});
} catch (err) {
return buildResponse(502, {
message: 'Failed to trigger build'
});
}
};

export default handler;

export const config = {
path: '/api/rebuild',
method: 'POST',
};
48 changes: 48 additions & 0 deletions netlify/edge-functions/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Config, Context } from '@netlify/edge-functions';

const publicDomain = Netlify.env.get('PUBLIC_DOMAIN');
const adminDomain = Netlify.env.get('ADMIN_DOMAIN');

const secFetchHeaders = [
'iframe',
'empty'
]

const ignorePaths = [
'/api/tina',
'/api/s3',
'/_astro'
]

export default async (request: Request, context: Context) => {
if (!publicDomain || !adminDomain) {
return context.next();
}

if (secFetchHeaders.includes(request.headers.get('sec-fetch-dest'))) {
return context.next();
}

const url = new URL(request.url);

if (ignorePaths.some(path => url.pathname.startsWith(path))) {
return context.next();
}

if (url.hostname === publicDomain && url.pathname.startsWith('/admin')) {
url.hostname = adminDomain;
url.port = '';
return Response.redirect(url.toString(), 301);
}

if (url.hostname === adminDomain && !url.pathname.startsWith('/admin')) {
url.hostname = publicDomain;
return Response.redirect(url.toString(), 301);
}

return context.next();
};

export const config: Config = {
path: '/*',
};
Loading