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
This commit is contained in:
Randall Stillwell 2026-04-23 12:17:35 -05:00
parent 11773a2ad2
commit be7e3dc502
30 changed files with 292 additions and 329 deletions

View file

@ -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<string, string> = {
@ -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<CardData | null>(null);
const [loadError, setLoadError] = React.useState(false);
@ -186,8 +191,8 @@ export default function CardDetailPage() {
const [fieldDataEdits, setFieldDataEdits] = React.useState<Record<string, unknown>>({});
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() {
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
<div className="flex flex-wrap gap-2">
{isAdmin && ocrStatus !== "processing" && (
{canReprocess && ocrStatus !== "processing" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>
{reprocessing ? (
<><Loader2 className="mr-1 size-4 animate-spin" /> Reprocessing...</>
@ -618,7 +623,7 @@ export default function CardDetailPage() {
)}
</Button>
)}
{isAdmin && users.length > 0 && (
{canAssign && users.length > 0 && (
<Select onValueChange={(v: string | null) => { if (v) handleReassign(v); }}>
<SelectTrigger className="w-[160px] rounded-xl h-8 text-sm">
<SelectValue placeholder="Reassign..." />
@ -635,7 +640,7 @@ export default function CardDetailPage() {
<Check className="mr-1 size-4" /> Mark Complete
</Button>
)}
{isAdmin && reviewStatus !== "exported" && (
{canExport && reviewStatus !== "exported" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
<Download className="mr-1 size-4" /> Export
</Button>

View file

@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { isAdminRole } from "@/lib/permissions";
import {
Card,
CardContent,
@ -91,7 +92,7 @@ export default function UsersSettingsPage() {
}, [fetchData]);
const myMembership = members.find((m) => m.user.id === session?.user?.id);
const isAdmin = myMembership && ["owner", "admin"].includes(myMembership.role);
const isAdmin = isAdminRole(myMembership?.role);
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();

View file

@ -8,7 +8,7 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithOrg("cards.edit");
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },

View file

@ -8,7 +8,7 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithOrg("cards.reprocess");
const { id } = await params;
const card = await prisma.responseCard.findUnique({ where: { id } });

View file

@ -3,8 +3,8 @@ import { prisma } from "@/lib/db";
import { deleteObject } from "@/lib/storage";
import { fireIntegrationEvent } from "@/lib/integrations";
import { logActivity, diffCardFields } from "@/lib/activity-log";
import { RoleError } from "@/lib/auth";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import { can } from "@/lib/permissions";
export async function GET(
_request: NextRequest,
@ -60,11 +60,20 @@ export async function PUT(
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
if (user.role === "viewer") {
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
}
if (user.role === "reviewer" && card.assignedToId !== user.id) {
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
// Editors and above can edit any card; reviewers can edit cards assigned
// to them; viewers cannot edit at all.
const canEditAnyCard = can(user.role, "cards.edit");
const canReviewAssigned =
can(user.role, "cards.review") && card.assignedToId === user.id;
if (!canEditAnyCard && !canReviewAssigned) {
return NextResponse.json(
{
error: canEditAnyCard === false && user.role === "reviewer"
? "You can only edit cards assigned to you"
: "You don't have permission to edit cards",
},
{ status: 403 }
);
}
const body = await request.json().catch(() => ({}));
@ -171,9 +180,6 @@ export async function PUT(
return NextResponse.json(updated);
} catch (error) {
if (error instanceof RoleError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return handleApiError(error);
}
}
@ -183,10 +189,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
if (session.user.role !== "admin" && session.user.role !== "owner") {
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
}
const session = await requireApiAuthWithOrg("cards.delete");
const { id } = await params;
const card = await prisma.responseCard.findUnique({

View file

@ -6,11 +6,7 @@ import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(req: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const role = session.user.role;
if (!role || !["admin", "owner"].includes(role)) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
const session = await requireApiAuthWithOrg("cards.assign");
const user = await prisma.user.findUniqueOrThrow({ where: { id: session.user.id } });
const body = await req.json();

View file

@ -7,7 +7,7 @@ const DELAY_BETWEEN_CARDS_MS = 3_000;
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithOrg("cards.reprocess");
const body = await request.json().catch(() => ({}));
const ids = body.ids as string[] | undefined;

View file

@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import {
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
type RouteContext = { params: Promise<{ id: string; fieldId: string }> };
@ -21,7 +24,7 @@ async function getOwnedField(templateId: string, fieldId: string, orgId: string)
export async function PUT(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithPermission("settings.edit");
const { id, fieldId } = await ctx.params;
const field = await getOwnedField(id, fieldId, session.user.orgId!);
@ -76,7 +79,7 @@ export async function PUT(request: NextRequest, ctx: RouteContext) {
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithPermission("settings.edit");
const { id, fieldId } = await ctx.params;
const field = await getOwnedField(id, fieldId, session.user.orgId!);

View file

@ -1,7 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
type RouteContext = { params: Promise<{ id: string }> };
@ -39,7 +43,7 @@ export async function GET(_request: NextRequest, ctx: RouteContext) {
export async function POST(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithPermission("settings.edit");
const { id } = await ctx.params;
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
@ -103,7 +107,7 @@ export async function POST(request: NextRequest, ctx: RouteContext) {
export async function PUT(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithPermission("settings.edit");
const { id } = await ctx.params;
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {

View file

@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
type RouteContext = { params: Promise<{ id: string }> };
@ -37,7 +41,7 @@ export async function GET(_request: NextRequest, ctx: RouteContext) {
export async function PUT(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithPermission("settings.edit");
const { id } = await ctx.params;
const existing = await getOwnedTemplate(id, session.user.orgId!);
@ -81,7 +85,7 @@ export async function PUT(request: NextRequest, ctx: RouteContext) {
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithPermission("settings.edit");
const { id } = await ctx.params;
const existing = await getOwnedTemplate(id, session.user.orgId!);

View file

@ -1,7 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
@ -44,7 +48,7 @@ function slugify(name: string): string {
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithPermission("settings.edit");
const body = await request.json();
const { name, description, duplicateFrom } = body as {

View file

@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getProvider } from "@/lib/integrations/registry";
import {
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithPermission("integrations.manage");
const { id } = await params;
const integration = await prisma.integration.findFirst({
@ -37,10 +37,6 @@ export async function GET(
const fields = await provider.getExternalFields(configWithId);
return NextResponse.json({ fields });
} catch (error) {
console.error("[integrations/[id]/fields] error:", error);
return NextResponse.json(
{ error: "Failed to fetch fields" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,16 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithOrg();
const { id } = await params;
const integration = await prisma.integration.findFirst({
@ -26,11 +27,7 @@ export async function GET(
return NextResponse.json({ integration });
} catch (error) {
console.error("[integrations/[id]] GET error:", error);
return NextResponse.json(
{ error: "Failed to fetch integration" },
{ status: 500 }
);
return handleApiError(error);
}
}
@ -39,10 +36,7 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithPermission("integrations.manage");
const { id } = await params;
const existing = await prisma.integration.findFirst({
@ -73,11 +67,7 @@ export async function PUT(
return NextResponse.json({ integration });
} catch (error) {
console.error("[integrations/[id]] PUT error:", error);
return NextResponse.json(
{ error: "Failed to update integration" },
{ status: 500 }
);
return handleApiError(error);
}
}
@ -86,10 +76,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithPermission("integrations.manage");
const { id } = await params;
const existing = await prisma.integration.findFirst({
@ -106,10 +93,6 @@ export async function DELETE(
return NextResponse.json({ success: true });
} catch (error) {
console.error("[integrations/[id]] DELETE error:", error);
return NextResponse.json(
{ error: "Failed to delete integration" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,19 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getProvider } from "@/lib/integrations/registry";
import { flattenCardFieldData } from "@/lib/integrations";
import type { CardData } from "@/lib/integrations/types";
import {
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithPermission("integrations.manage");
const { id } = await params;
const integration = await prisma.integration.findFirst({
@ -88,10 +88,6 @@ export async function POST(
total: cards.length,
});
} catch (error) {
console.error("[integrations/[id]/sync] error:", error);
return NextResponse.json(
{ error: "Sync failed" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getProvider } from "@/lib/integrations/registry";
import {
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithPermission("integrations.manage");
const { id } = await params;
const integration = await prisma.integration.findFirst({
@ -49,10 +49,6 @@ export async function POST(
return NextResponse.json(result);
} catch (error) {
console.error("[integrations/[id]/test] error:", error);
return NextResponse.json(
{ error: "Test failed" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,14 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getAllProviders } from "@/lib/integrations/registry";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithOrg();
const integrations = await prisma.integration.findMany({
where: { organizationId: session.user.orgId },
@ -29,20 +30,13 @@ export async function GET() {
return NextResponse.json({ integrations, providers });
} catch (error) {
console.error("[integrations] GET error:", error);
return NextResponse.json(
{ error: "Failed to fetch integrations" },
{ status: 500 }
);
return handleApiError(error);
}
}
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithPermission("integrations.manage");
const body = await req.json();
const { provider, name, config, fieldMapping, triggerEvents } = body;
@ -68,10 +62,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ integration });
} catch (error) {
console.error("[integrations] POST error:", error);
return NextResponse.json(
{ error: "Failed to create integration" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -27,7 +27,7 @@ export async function GET() {
export async function POST(req: NextRequest) {
try {
const user = await requireAuth("events.manage");
const user = await requireAuth("settings.edit");
if (!user.orgId) {
return NextResponse.json({ error: "No organization" }, { status: 400 });
}

View file

@ -1,33 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { sendInvitationEmail } from "@/lib/email-sender";
async function requireAdmin(orgId: string, userId: string) {
const membership = await prisma.orgMember.findFirst({
where: { userId, organizationId: orgId },
});
if (!membership || !["owner", "admin"].includes(membership.role)) {
return null;
}
return membership;
}
import { requireApiAuthWithPermission, handleApiError } from "@/lib/api-auth";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId || !session.user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const callerMembership = await requireAdmin(session.user.orgId, session.user.id);
if (!callerMembership) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const session = await requireApiAuthWithPermission("users.invite");
const { id } = await params;
const invitation = await prisma.invitation.findFirst({
@ -60,8 +41,7 @@ export async function POST(
return NextResponse.json({ success: true, message: "Invitation resent" });
} catch (error) {
console.error("[org/invitations/[id]] POST error:", error);
return NextResponse.json({ error: "Failed to resend invitation" }, { status: 500 });
return handleApiError(error);
}
}
@ -70,16 +50,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId || !session.user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const callerMembership = await requireAdmin(session.user.orgId, session.user.id);
if (!callerMembership) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const session = await requireApiAuthWithPermission("users.invite");
const { id } = await params;
const invitation = await prisma.invitation.findFirst({
@ -94,7 +65,6 @@ export async function DELETE(
return NextResponse.json({ success: true });
} catch (error) {
console.error("[org/invitations/[id]] DELETE error:", error);
return NextResponse.json({ error: "Failed to revoke invitation" }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,14 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { sendInvitationEmail } from "@/lib/email-sender";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithOrg("users.view");
const invitations = await prisma.invitation.findMany({
where: { organizationId: session.user.orgId, acceptedAt: null },
@ -24,31 +25,13 @@ export async function GET() {
return NextResponse.json({ invitations });
} catch (error) {
console.error("[org/invitations] GET error:", error);
return NextResponse.json(
{ error: "Failed to fetch invitations" },
{ status: 500 }
);
return handleApiError(error);
}
}
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const membership = await prisma.orgMember.findFirst({
where: {
userId: session.user.id,
organizationId: session.user.orgId,
},
});
if (!membership || !["owner", "admin"].includes(membership.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const session = await requireApiAuthWithPermission("users.invite");
const { email, role } = await req.json();
@ -113,10 +96,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
} catch (error) {
console.error("[org/invitations] POST error:", error);
return NextResponse.json(
{ error: "Failed to create invitation" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,13 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithOrg();
const locations = await prisma.location.findMany({
where: { organizationId: session.user.orgId },
@ -29,31 +30,13 @@ export async function GET() {
return NextResponse.json({ locations });
} catch (error) {
console.error("[org/locations] GET error:", error);
return NextResponse.json(
{ error: "Failed to fetch locations" },
{ status: 500 }
);
return handleApiError(error);
}
}
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const membership = await prisma.orgMember.findFirst({
where: {
userId: session.user.id,
organizationId: session.user.orgId,
},
});
if (!membership || !["owner", "admin", "editor"].includes(membership.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const session = await requireApiAuthWithPermission("settings.edit");
const { name, address } = await req.json();
@ -74,10 +57,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ location });
} catch (error) {
console.error("[org/locations] POST error:", error);
return NextResponse.json(
{ error: "Failed to create location" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,34 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { requireApiAuthWithPermission, handleApiError } from "@/lib/api-auth";
const VALID_ROLES = ["viewer", "reviewer", "editor", "admin"];
async function requireAdmin(orgId: string, userId: string) {
const membership = await prisma.orgMember.findFirst({
where: { userId, organizationId: orgId },
});
if (!membership || !["owner", "admin"].includes(membership.role)) {
return null;
}
return membership;
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId || !session.user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const callerMembership = await requireAdmin(session.user.orgId, session.user.id);
if (!callerMembership) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const session = await requireApiAuthWithPermission("users.invite");
const { id } = await params;
const { role } = await req.json();
@ -69,8 +50,7 @@ export async function PATCH(
return NextResponse.json({ member: updated });
} catch (error) {
console.error("[org/members/[id]] PATCH error:", error);
return NextResponse.json({ error: "Failed to update member" }, { status: 500 });
return handleApiError(error);
}
}
@ -79,16 +59,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.orgId || !session.user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const callerMembership = await requireAdmin(session.user.orgId, session.user.id);
if (!callerMembership) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const session = await requireApiAuthWithPermission("users.invite");
const { id } = await params;
const target = await prisma.orgMember.findFirst({
@ -123,7 +94,6 @@ export async function DELETE(
return NextResponse.json({ success: true });
} catch (error) {
console.error("[org/members/[id]] DELETE error:", error);
return NextResponse.json({ error: "Failed to remove member" }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,13 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import {
requireApiAuthWithOrg,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithOrg();
const organization = await prisma.organization.findUnique({
where: { id: session.user.orgId },
@ -30,11 +31,7 @@ export async function GET() {
return NextResponse.json({ organization });
} catch (error) {
console.error("[org] GET error:", error);
return NextResponse.json(
{ error: "Failed to fetch organization" },
{ status: 500 }
);
return handleApiError(error);
}
}
@ -47,21 +44,7 @@ function slugify(name: string): string {
export async function PUT(req: NextRequest) {
try {
const session = await auth();
if (!session?.user?.orgId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const membership = await prisma.orgMember.findFirst({
where: {
userId: session.user.id,
organizationId: session.user.orgId,
},
});
if (!membership || !["owner", "admin"].includes(membership.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const session = await requireApiAuthWithPermission("settings.edit");
const { name, type, timezone, allowedDomains } = await req.json();
@ -85,10 +68,6 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ success: true });
} catch (error) {
console.error("[org] PUT error:", error);
return NextResponse.json(
{ error: "Failed to update organization" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
import {
requireApiAuth,
requireApiAuthWithPermission,
handleApiError,
} from "@/lib/api-auth";
export async function GET() {
try {
@ -29,7 +33,7 @@ export async function GET() {
export async function PUT(request: NextRequest) {
try {
await requireApiAuth();
await requireApiAuthWithPermission("settings.edit");
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};

View file

@ -16,7 +16,7 @@ const ALLOWED_TYPES = [
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const session = await requireApiAuthWithOrg("uploads.create");
const formData = await request.formData();
const files = [
...(formData.getAll("files") as File[]),

View file

@ -11,6 +11,7 @@ import { SelectionToolbar } from "./selection-toolbar";
import { UploadModal, type UploadingFile } from "./upload-modal";
import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns";
import { useUserProfile } from "@/lib/user-profile";
import { can } from "@/lib/permissions";
const VISIT_TYPE_OPTIONS = [
"First/Second Time Guest",
@ -40,7 +41,12 @@ export function DashboardContent() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { role } = useUserProfile();
const isAdmin = role === "admin" || role === "owner";
const canUpload = can(role, "uploads.create");
const canEditCards = can(role, "cards.edit");
const canReview = can(role, "cards.review");
const canReprocess = can(role, "cards.reprocess");
const canDelete = can(role, "cards.delete");
const canAssign = can(role, "cards.assign");
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
@ -285,7 +291,7 @@ export function DashboardContent() {
() =>
createColumns({
onViewDetails: (card) => router.push(`/cards/${card.id}`),
onMarkReviewed: isAdmin
onMarkReviewed: canReview
? async (card) => {
await fetch(`/api/cards/${card.id}`, {
method: "PUT",
@ -296,7 +302,7 @@ export function DashboardContent() {
fetchCards();
}
: undefined,
onReprocess: isAdmin
onReprocess: canReprocess
? async (card) => {
try {
const res = await fetch(`/api/cards/${card.id}/reprocess`, {
@ -315,7 +321,7 @@ export function DashboardContent() {
}
}
: undefined,
onDelete: isAdmin
onDelete: canDelete
? async (card) => {
await fetch(`/api/cards/${card.id}`, { method: "DELETE" });
toast.success("Card deleted");
@ -323,7 +329,7 @@ export function DashboardContent() {
}
: undefined,
}),
[router, fetchCards, isAdmin]
[router, fetchCards, canReview, canReprocess, canDelete]
);
const handleUploadStart = (files: UploadingFile[]) => {
@ -397,6 +403,7 @@ export function DashboardContent() {
}, []);
React.useEffect(() => {
if (!canUpload) return;
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer?.types.includes("Files")) {
@ -426,7 +433,7 @@ export function DashboardContent() {
document.removeEventListener("dragleave", handleDragLeave);
document.removeEventListener("drop", handleDrop);
};
}, []);
}, [canUpload]);
const openUpload = React.useCallback(() => {
setUploadModalOpen(true);
@ -474,7 +481,7 @@ export function DashboardContent() {
attendanceDurationOptions={ATTENDANCE_OPTIONS}
serviceAttendedOptions={SERVICE_OPTIONS}
onExportCsv={handleExportCsv}
onUploadClick={isAdmin ? openUpload : undefined}
onUploadClick={canUpload ? openUpload : undefined}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
showAssignedToFilter
@ -499,7 +506,7 @@ export function DashboardContent() {
onSelectionChange={setSelectedIds}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
onUploadClick={isAdmin ? openUpload : undefined}
onUploadClick={canUpload ? openUpload : undefined}
/>
</div>
@ -507,12 +514,12 @@ export function DashboardContent() {
<SelectionToolbar
selectedIds={selectedIds}
selectedRows={selectedRows}
onMarkReviewed={isAdmin ? (ids) => handleBulkAction(ids, "reviewed") : undefined}
onMarkExported={isAdmin ? (ids) => handleBulkAction(ids, "exported") : undefined}
onReprocess={isAdmin ? handleBulkReprocess : undefined}
onSyncMonday={isAdmin ? handleBulkSyncMonday : undefined}
onAssign={isAdmin ? handleAssign : undefined}
onDelete={isAdmin ? (ids) => handleBulkAction(ids, "delete") : undefined}
onMarkReviewed={canReview ? (ids) => handleBulkAction(ids, "reviewed") : undefined}
onMarkExported={canEditCards ? (ids) => handleBulkAction(ids, "exported") : undefined}
onReprocess={canReprocess ? handleBulkReprocess : undefined}
onSyncMonday={canReprocess ? handleBulkSyncMonday : undefined}
onAssign={canAssign ? handleAssign : undefined}
onDelete={canDelete ? (ids) => handleBulkAction(ids, "delete") : undefined}
onClear={() => setSelectedIds([])}
/>

View file

@ -22,6 +22,8 @@ import {
} from "@/components/ui/tooltip";
import { ScrollArea } from "@/components/ui/scroll-area";
import { OrgSwitcher } from "@/components/layout/org-switcher";
import { useUserProfile } from "@/lib/user-profile";
import { isAdminRole } from "@/lib/permissions";
const STORAGE_KEY = "echo-sidebar-collapsed";
@ -139,6 +141,11 @@ function NavItem({
export function Sidebar() {
const pathname = usePathname();
const { collapsed, toggle } = useSidebar();
const { role } = useUserProfile();
const canManageSettings = isAdminRole(role);
const visibleBottomItems = bottomItems.filter(
(item) => item.href !== "/settings" || canManageSettings
);
return (
<aside
@ -176,7 +183,7 @@ export function Sidebar() {
collapsed && "px-2"
)}
>
{bottomItems.map((item) => (
{visibleBottomItems.map((item) => (
<NavItem
key={item.href}
{...item}

View file

@ -32,11 +32,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useUserProfile } from "@/lib/user-profile";
import { isAdminRole } from "@/lib/permissions";
import { NotificationCenter } from "@/components/notifications/notification-center";
export function TopBar() {
const { theme, setTheme } = useTheme();
const { profile, initials } = useUserProfile();
const { profile, initials, role } = useUserProfile();
const canManageSettings = isAdminRole(role);
const handleUploadClick = () => {
window.dispatchEvent(new CustomEvent("open-upload-modal"));
@ -145,10 +147,12 @@ export function TopBar() {
<UserCircle className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings" />}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
{canManageSettings && (
<DropdownMenuItem render={<Link href="/settings" />}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => window.open("mailto:support@echoocr.app", "_blank")}

View file

@ -1,5 +1,7 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import type { Session } from "next-auth";
import { type Action, PermissionError, can } from "@/lib/permissions";
export class ApiAuthError extends Error {
constructor(message = "Unauthorized") {
@ -8,7 +10,11 @@ export class ApiAuthError extends Error {
}
}
export async function requireApiAuth() {
export type OrgSession = Session & {
user: Session["user"] & { id: string; orgId: string };
};
export async function requireApiAuth(): Promise<Session> {
const session = await auth();
if (!session?.user?.id) {
throw new ApiAuthError();
@ -16,18 +22,40 @@ export async function requireApiAuth() {
return session;
}
export async function requireApiAuthWithOrg() {
export async function requireApiAuthWithOrg(
action?: Action
): Promise<OrgSession> {
const session = await auth();
if (!session?.user?.id || !session.user.orgId) {
throw new ApiAuthError();
}
return session;
if (action && !can(session.user.role, action)) {
throw new PermissionError(`Missing permission: ${action}`, action);
}
return session as OrgSession;
}
/**
* Shorthand for routes that require an authenticated org member with a
* specific permission. Always throws; callers should wrap with
* handleApiError so the correct HTTP status is returned.
*/
export async function requireApiAuthWithPermission(
action: Action
): Promise<OrgSession> {
return requireApiAuthWithOrg(action);
}
export function handleApiError(error: unknown) {
if (error instanceof ApiAuthError) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (error instanceof PermissionError) {
return NextResponse.json(
{ error: error.message, action: error.action },
{ status: 403 }
);
}
console.error("[API Error]", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}

View file

@ -1,25 +1,39 @@
export type Role = "owner" | "admin" | "editor" | "reviewer" | "viewer";
export const ROLES: readonly Role[] = [
"owner",
"admin",
"editor",
"reviewer",
"viewer",
] as const;
export type Action =
// Read
| "cards.view"
| "cards.create"
| "cards.edit"
| "cards.delete"
| "cards.assign"
| "cards.review"
| "cards.export"
| "events.view"
| "events.manage"
| "people.view"
| "reports.view"
| "settings.view"
// Review (reviewer+)
| "cards.review"
// Edit (editor+)
| "cards.create"
| "cards.edit"
| "uploads.create"
| "events.manage"
// Admin (admin+)
| "cards.delete"
| "cards.assign"
| "cards.reprocess"
| "users.view"
| "users.invite"
| "users.manage"
| "settings.view"
| "settings.edit"
| "org.manage"
| "integrations.manage";
| "integrations.manage"
// Owner
| "users.manage"
| "org.manage";
const ROLE_HIERARCHY: Record<Role, number> = {
owner: 5,
@ -31,46 +45,75 @@ const ROLE_HIERARCHY: Record<Role, number> = {
const PERMISSION_MAP: Record<Action, Role> = {
"cards.view": "viewer",
"cards.create": "editor",
"cards.edit": "editor",
"cards.delete": "admin",
"cards.assign": "admin",
"cards.review": "reviewer",
"cards.export": "viewer",
"events.view": "viewer",
"events.manage": "editor",
"people.view": "viewer",
"reports.view": "viewer",
"settings.view": "viewer",
"cards.review": "reviewer",
"cards.create": "editor",
"cards.edit": "editor",
"uploads.create": "editor",
"events.manage": "editor",
"cards.delete": "admin",
"cards.assign": "admin",
"cards.reprocess": "admin",
"users.view": "admin",
"users.invite": "admin",
"users.manage": "owner",
"settings.view": "viewer",
"settings.edit": "admin",
"org.manage": "owner",
"integrations.manage": "admin",
"users.manage": "owner",
"org.manage": "owner",
};
export function can(role: string, action: Action): boolean {
export function isRole(value: unknown): value is Role {
return typeof value === "string" && value in ROLE_HIERARCHY;
}
export function roleLevel(role: string | null | undefined): number {
if (!role) return 0;
return ROLE_HIERARCHY[role as Role] ?? 0;
}
export function can(role: string | null | undefined, action: Action): boolean {
if (!role) return false;
const minRole = PERMISSION_MAP[action];
if (!minRole) return false;
const userLevel = ROLE_HIERARCHY[role as Role] ?? 0;
const requiredLevel = ROLE_HIERARCHY[minRole] ?? 999;
return userLevel >= requiredLevel;
return roleLevel(role) >= ROLE_HIERARCHY[minRole];
}
/**
* True if role is admin or owner. Shorthand for admin-only UI gates.
*/
export function isAdminRole(role: string | null | undefined): boolean {
return roleLevel(role) >= ROLE_HIERARCHY.admin;
}
/**
* True if role can edit content (editor, admin, owner).
*/
export function canEditContent(role: string | null | undefined): boolean {
return roleLevel(role) >= ROLE_HIERARCHY.editor;
}
export function requirePermission(
role: string | undefined | null,
action: Action
): void {
if (!role || !can(role, action)) {
if (!can(role, action)) {
throw new PermissionError(`Missing permission: ${action}`);
}
}
export class PermissionError extends Error {
constructor(message: string) {
public readonly action?: Action;
constructor(message: string, action?: Action) {
super(message);
this.name = "PermissionError";
this.action = action;
}
}

View file

@ -100,6 +100,18 @@ export async function middleware(req: NextRequest) {
return NextResponse.redirect(new URL("/onboarding", req.url));
}
const role =
(typeof token.orgRole === "string" && token.orgRole) ||
(typeof token.role === "string" && token.role) ||
"viewer";
const isAdminOrOwner = role === "admin" || role === "owner";
if (pathname.startsWith("/settings") && !isAdminOrOwner) {
const url = new URL("/", req.url);
url.searchParams.set("error", "forbidden");
return NextResponse.redirect(url);
}
return NextResponse.next();
}