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:
parent
11773a2ad2
commit
be7e3dc502
30 changed files with 292 additions and 329 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
|
|
|
|||
|
|
@ -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!))) {
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]),
|
||||
|
|
|
|||
|
|
@ -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([])}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{canManageSettings && (
|
||||
<DropdownMenuItem render={<Link href="/settings" />}>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.open("mailto:support@echoocr.app", "_blank")}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue