From be7e3dc50265ca65a4b2bea8466515ae98ff6906 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Thu, 23 Apr 2026 12:17:35 -0500 Subject: [PATCH] Normalize role-based permissions across API and UI Centralizes role/permission enforcement so each role (owner, admin, editor, reviewer, viewer) behaves consistently in the API and UI. - Extend src/lib/permissions.ts with an expanded action map (cards.reprocess, cards.assign, uploads.create, integrations.manage, etc.) plus helper predicates (isAdminRole, canEditContent). - Add requireApiAuthWithPermission(action) to src/lib/api-auth.ts with a narrowed OrgSession return type and PermissionError -> 403 handling. - Replace hand-rolled role checks in card, org, integration, form-template, settings, upload, and location routes with the shared helpers so 403s are uniform and derived from one permission map. - Close the editor UI gap: the dashboard upload button, row-level mark reviewed/reprocess/delete, and card detail edit/reprocess/export/assign now flow from can(role, action) instead of ad-hoc isAdmin checks. - Gate /settings/* at the middleware layer for non-admins and hide the Settings entry in the sidebar and top-bar menu when the role cannot access it. - Use isAdminRole() in the team members settings page for consistency. Made-with: Cursor --- src/app/(dashboard)/cards/[id]/page.tsx | 19 ++-- src/app/(dashboard)/settings/users/page.tsx | 3 +- src/app/api/cards/[id]/export/route.ts | 2 +- src/app/api/cards/[id]/reprocess/route.ts | 2 +- src/app/api/cards/[id]/route.ts | 29 +++--- src/app/api/cards/assign/route.ts | 6 +- src/app/api/cards/reprocess-batch/route.ts | 2 +- .../[id]/fields/[fieldId]/route.ts | 9 +- .../api/form-templates/[id]/fields/route.ts | 10 +- src/app/api/form-templates/[id]/route.ts | 10 +- src/app/api/form-templates/route.ts | 8 +- src/app/api/integrations/[id]/fields/route.ts | 16 ++-- src/app/api/integrations/[id]/route.ts | 39 +++----- src/app/api/integrations/[id]/sync/route.ts | 16 ++-- src/app/api/integrations/[id]/test/route.ts | 16 ++-- src/app/api/integrations/route.ts | 28 ++---- src/app/api/locations/route.ts | 2 +- src/app/api/org/invitations/[id]/route.ts | 40 +------- src/app/api/org/invitations/route.ts | 39 ++------ src/app/api/org/locations/route.ts | 39 ++------ src/app/api/org/members/[id]/route.ts | 40 +------- src/app/api/org/route.ts | 39 ++------ src/app/api/settings/route.ts | 8 +- src/app/api/upload/route.ts | 2 +- src/components/cards/dashboard-content.tsx | 35 ++++--- src/components/layout/sidebar.tsx | 9 +- src/components/layout/top-bar.tsx | 14 ++- src/lib/api-auth.ts | 34 ++++++- src/lib/permissions.ts | 93 ++++++++++++++----- src/middleware.ts | 12 +++ 30 files changed, 292 insertions(+), 329 deletions(-) diff --git a/src/app/(dashboard)/cards/[id]/page.tsx b/src/app/(dashboard)/cards/[id]/page.tsx index 04c4540..6b07189 100644 --- a/src/app/(dashboard)/cards/[id]/page.tsx +++ b/src/app/(dashboard)/cards/[id]/page.tsx @@ -47,6 +47,7 @@ import { import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { useUserProfile } from "@/lib/user-profile"; +import { can } from "@/lib/permissions"; import { DynamicField, type FormFieldDef } from "@/components/cards/dynamic-field"; const SECTION_LABELS: Record = { @@ -167,6 +168,10 @@ export default function CardDetailPage() { const isAdmin = role === "admin" || role === "owner"; const isReviewer = role === "reviewer"; const isViewer = role === "viewer"; + const canEditAnyCard = can(role, "cards.edit"); + const canReprocess = can(role, "cards.reprocess"); + const canAssign = can(role, "cards.assign"); + const canExport = can(role, "cards.edit"); const [card, setCard] = React.useState(null); const [loadError, setLoadError] = React.useState(false); @@ -186,8 +191,8 @@ export default function CardDetailPage() { const [fieldDataEdits, setFieldDataEdits] = React.useState>({}); const isAssignedToMe = card?.assignedToId && userId === card.assignedToId; - const canEdit = isAdmin || (isReviewer && isAssignedToMe); - const canMarkComplete = isAdmin || (isReviewer && isAssignedToMe); + const canEdit = canEditAnyCard || (isReviewer && !!isAssignedToMe); + const canMarkComplete = canEditAnyCard || (isReviewer && !!isAssignedToMe); const fetchCard = React.useCallback(async () => { setLoading(true); @@ -223,12 +228,12 @@ export default function CardDetailPage() { }, [fetchCard]); React.useEffect(() => { - if (!isAdmin) return; + if (!canAssign) return; fetch("/api/users") .then((r) => r.json()) .then((data) => setUsers(data.users || [])) .catch(() => {}); - }, [isAdmin]); + }, [canAssign]); React.useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -589,7 +594,7 @@ export default function CardDetailPage() {
- {isAdmin && ocrStatus !== "processing" && ( + {canReprocess && ocrStatus !== "processing" && ( )} - {isAdmin && users.length > 0 && ( + {canAssign && users.length > 0 && (