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 { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useUserProfile } from "@/lib/user-profile";
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
|
import { can } from "@/lib/permissions";
|
||||||
import { DynamicField, type FormFieldDef } from "@/components/cards/dynamic-field";
|
import { DynamicField, type FormFieldDef } from "@/components/cards/dynamic-field";
|
||||||
|
|
||||||
const SECTION_LABELS: Record<string, string> = {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
|
|
@ -167,6 +168,10 @@ export default function CardDetailPage() {
|
||||||
const isAdmin = role === "admin" || role === "owner";
|
const isAdmin = role === "admin" || role === "owner";
|
||||||
const isReviewer = role === "reviewer";
|
const isReviewer = role === "reviewer";
|
||||||
const isViewer = role === "viewer";
|
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 [card, setCard] = React.useState<CardData | null>(null);
|
||||||
const [loadError, setLoadError] = React.useState(false);
|
const [loadError, setLoadError] = React.useState(false);
|
||||||
|
|
@ -186,8 +191,8 @@ export default function CardDetailPage() {
|
||||||
const [fieldDataEdits, setFieldDataEdits] = React.useState<Record<string, unknown>>({});
|
const [fieldDataEdits, setFieldDataEdits] = React.useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
const isAssignedToMe = card?.assignedToId && userId === card.assignedToId;
|
const isAssignedToMe = card?.assignedToId && userId === card.assignedToId;
|
||||||
const canEdit = isAdmin || (isReviewer && isAssignedToMe);
|
const canEdit = canEditAnyCard || (isReviewer && !!isAssignedToMe);
|
||||||
const canMarkComplete = isAdmin || (isReviewer && isAssignedToMe);
|
const canMarkComplete = canEditAnyCard || (isReviewer && !!isAssignedToMe);
|
||||||
|
|
||||||
const fetchCard = React.useCallback(async () => {
|
const fetchCard = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -223,12 +228,12 @@ export default function CardDetailPage() {
|
||||||
}, [fetchCard]);
|
}, [fetchCard]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAdmin) return;
|
if (!canAssign) return;
|
||||||
fetch("/api/users")
|
fetch("/api/users")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setUsers(data.users || []))
|
.then((data) => setUsers(data.users || []))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [isAdmin]);
|
}, [canAssign]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -589,7 +594,7 @@ export default function CardDetailPage() {
|
||||||
|
|
||||||
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
|
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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}>
|
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>
|
||||||
{reprocessing ? (
|
{reprocessing ? (
|
||||||
<><Loader2 className="mr-1 size-4 animate-spin" /> Reprocessing...</>
|
<><Loader2 className="mr-1 size-4 animate-spin" /> Reprocessing...</>
|
||||||
|
|
@ -618,7 +623,7 @@ export default function CardDetailPage() {
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAdmin && users.length > 0 && (
|
{canAssign && users.length > 0 && (
|
||||||
<Select onValueChange={(v: string | null) => { if (v) handleReassign(v); }}>
|
<Select onValueChange={(v: string | null) => { if (v) handleReassign(v); }}>
|
||||||
<SelectTrigger className="w-[160px] rounded-xl h-8 text-sm">
|
<SelectTrigger className="w-[160px] rounded-xl h-8 text-sm">
|
||||||
<SelectValue placeholder="Reassign..." />
|
<SelectValue placeholder="Reassign..." />
|
||||||
|
|
@ -635,7 +640,7 @@ export default function CardDetailPage() {
|
||||||
<Check className="mr-1 size-4" /> Mark Complete
|
<Check className="mr-1 size-4" /> Mark Complete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAdmin && reviewStatus !== "exported" && (
|
{canExport && reviewStatus !== "exported" && (
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
|
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
|
||||||
<Download className="mr-1 size-4" /> Export
|
<Download className="mr-1 size-4" /> Export
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { isAdminRole } from "@/lib/permissions";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -91,7 +92,7 @@ export default function UsersSettingsPage() {
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const myMembership = members.find((m) => m.user.id === session?.user?.id);
|
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) => {
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export async function POST(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithOrg("cards.edit");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({
|
const card = await prisma.responseCard.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export async function POST(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithOrg("cards.reprocess");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({ where: { id } });
|
const card = await prisma.responseCard.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { prisma } from "@/lib/db";
|
||||||
import { deleteObject } from "@/lib/storage";
|
import { deleteObject } from "@/lib/storage";
|
||||||
import { fireIntegrationEvent } from "@/lib/integrations";
|
import { fireIntegrationEvent } from "@/lib/integrations";
|
||||||
import { logActivity, diffCardFields } from "@/lib/activity-log";
|
import { logActivity, diffCardFields } from "@/lib/activity-log";
|
||||||
import { RoleError } from "@/lib/auth";
|
|
||||||
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
import { can } from "@/lib/permissions";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -60,11 +60,20 @@ export async function PUT(
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
// Editors and above can edit any card; reviewers can edit cards assigned
|
||||||
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
|
// to them; viewers cannot edit at all.
|
||||||
}
|
const canEditAnyCard = can(user.role, "cards.edit");
|
||||||
if (user.role === "reviewer" && card.assignedToId !== user.id) {
|
const canReviewAssigned =
|
||||||
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
|
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(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
|
|
@ -171,9 +180,6 @@ export async function PUT(
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RoleError) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
|
||||||
}
|
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -183,10 +189,7 @@ export async function DELETE(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithOrg("cards.delete");
|
||||||
if (session.user.role !== "admin" && session.user.role !== "owner") {
|
|
||||||
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({
|
const card = await prisma.responseCard.findUnique({
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,7 @@ import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithOrg("cards.assign");
|
||||||
const role = session.user.role;
|
|
||||||
if (!role || !["admin", "owner"].includes(role)) {
|
|
||||||
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
|
|
||||||
}
|
|
||||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: session.user.id } });
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: session.user.id } });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const DELAY_BETWEEN_CARDS_MS = 3_000;
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithOrg("cards.reprocess");
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const ids = body.ids as string[] | undefined;
|
const ids = body.ids as string[] | undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
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 }> };
|
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) {
|
export async function PUT(request: NextRequest, ctx: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
const { id, fieldId } = await ctx.params;
|
const { id, fieldId } = await ctx.params;
|
||||||
|
|
||||||
const field = await getOwnedField(id, fieldId, session.user.orgId!);
|
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) {
|
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
const { id, fieldId } = await ctx.params;
|
const { id, fieldId } = await ctx.params;
|
||||||
|
|
||||||
const field = await getOwnedField(id, fieldId, session.user.orgId!);
|
const field = await getOwnedField(id, fieldId, session.user.orgId!);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
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 }> };
|
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) {
|
export async function POST(request: NextRequest, ctx: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
|
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) {
|
export async function PUT(request: NextRequest, ctx: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
|
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
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 }> };
|
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) {
|
export async function PUT(request: NextRequest, ctx: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
const existing = await getOwnedTemplate(id, session.user.orgId!);
|
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) {
|
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
const existing = await getOwnedTemplate(id, session.user.orgId!);
|
const existing = await getOwnedTemplate(id, session.user.orgId!);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -44,7 +48,7 @@ function slugify(name: string): string {
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const { name, description, duplicateFrom } = body as {
|
const { name, description, duplicateFrom } = body as {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getProvider } from "@/lib/integrations/registry";
|
import { getProvider } from "@/lib/integrations/registry";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("integrations.manage");
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const integration = await prisma.integration.findFirst({
|
const integration = await prisma.integration.findFirst({
|
||||||
|
|
@ -37,10 +37,6 @@ export async function GET(
|
||||||
const fields = await provider.getExternalFields(configWithId);
|
const fields = await provider.getExternalFields(configWithId);
|
||||||
return NextResponse.json({ fields });
|
return NextResponse.json({ fields });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations/[id]/fields] error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch fields" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithOrg,
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithOrg();
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const integration = await prisma.integration.findFirst({
|
const integration = await prisma.integration.findFirst({
|
||||||
|
|
@ -26,11 +27,7 @@ export async function GET(
|
||||||
|
|
||||||
return NextResponse.json({ integration });
|
return NextResponse.json({ integration });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations/[id]] GET error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch integration" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,10 +36,7 @@ export async function PUT(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("integrations.manage");
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const existing = await prisma.integration.findFirst({
|
const existing = await prisma.integration.findFirst({
|
||||||
|
|
@ -73,11 +67,7 @@ export async function PUT(
|
||||||
|
|
||||||
return NextResponse.json({ integration });
|
return NextResponse.json({ integration });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations/[id]] PUT error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to update integration" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,10 +76,7 @@ export async function DELETE(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("integrations.manage");
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const existing = await prisma.integration.findFirst({
|
const existing = await prisma.integration.findFirst({
|
||||||
|
|
@ -106,10 +93,6 @@ export async function DELETE(
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations/[id]] DELETE error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to delete integration" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getProvider } from "@/lib/integrations/registry";
|
import { getProvider } from "@/lib/integrations/registry";
|
||||||
import { flattenCardFieldData } from "@/lib/integrations";
|
import { flattenCardFieldData } from "@/lib/integrations";
|
||||||
import type { CardData } from "@/lib/integrations/types";
|
import type { CardData } from "@/lib/integrations/types";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("integrations.manage");
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const integration = await prisma.integration.findFirst({
|
const integration = await prisma.integration.findFirst({
|
||||||
|
|
@ -88,10 +88,6 @@ export async function POST(
|
||||||
total: cards.length,
|
total: cards.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations/[id]/sync] error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Sync failed" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getProvider } from "@/lib/integrations/registry";
|
import { getProvider } from "@/lib/integrations/registry";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("integrations.manage");
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const integration = await prisma.integration.findFirst({
|
const integration = await prisma.integration.findFirst({
|
||||||
|
|
@ -49,10 +49,6 @@ export async function POST(
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations/[id]/test] error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Test failed" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getAllProviders } from "@/lib/integrations/registry";
|
import { getAllProviders } from "@/lib/integrations/registry";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithOrg,
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithOrg();
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const integrations = await prisma.integration.findMany({
|
const integrations = await prisma.integration.findMany({
|
||||||
where: { organizationId: session.user.orgId },
|
where: { organizationId: session.user.orgId },
|
||||||
|
|
@ -29,20 +30,13 @@ export async function GET() {
|
||||||
|
|
||||||
return NextResponse.json({ integrations, providers });
|
return NextResponse.json({ integrations, providers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations] GET error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch integrations" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("integrations.manage");
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { provider, name, config, fieldMapping, triggerEvents } = body;
|
const { provider, name, config, fieldMapping, triggerEvents } = body;
|
||||||
|
|
@ -68,10 +62,6 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ integration });
|
return NextResponse.json({ integration });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[integrations] POST error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to create integration" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET() {
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth("events.manage");
|
const user = await requireAuth("settings.edit");
|
||||||
if (!user.orgId) {
|
if (!user.orgId) {
|
||||||
return NextResponse.json({ error: "No organization" }, { status: 400 });
|
return NextResponse.json({ error: "No organization" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,14 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { sendInvitationEmail } from "@/lib/email-sender";
|
import { sendInvitationEmail } from "@/lib/email-sender";
|
||||||
|
import { requireApiAuthWithPermission, handleApiError } from "@/lib/api-auth";
|
||||||
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 POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("users.invite");
|
||||||
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 { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const invitation = await prisma.invitation.findFirst({
|
const invitation = await prisma.invitation.findFirst({
|
||||||
|
|
@ -60,8 +41,7 @@ export async function POST(
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: "Invitation resent" });
|
return NextResponse.json({ success: true, message: "Invitation resent" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/invitations/[id]] POST error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: "Failed to resend invitation" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,16 +50,7 @@ export async function DELETE(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("users.invite");
|
||||||
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 { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const invitation = await prisma.invitation.findFirst({
|
const invitation = await prisma.invitation.findFirst({
|
||||||
|
|
@ -94,7 +65,6 @@ export async function DELETE(
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/invitations/[id]] DELETE error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: "Failed to revoke invitation" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { sendInvitationEmail } from "@/lib/email-sender";
|
import { sendInvitationEmail } from "@/lib/email-sender";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithOrg,
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithOrg("users.view");
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const invitations = await prisma.invitation.findMany({
|
const invitations = await prisma.invitation.findMany({
|
||||||
where: { organizationId: session.user.orgId, acceptedAt: null },
|
where: { organizationId: session.user.orgId, acceptedAt: null },
|
||||||
|
|
@ -24,31 +25,13 @@ export async function GET() {
|
||||||
|
|
||||||
return NextResponse.json({ invitations });
|
return NextResponse.json({ invitations });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/invitations] GET error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch invitations" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("users.invite");
|
||||||
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 { email, role } = await req.json();
|
const { email, role } = await req.json();
|
||||||
|
|
||||||
|
|
@ -113,10 +96,6 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/invitations] POST error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to create invitation" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithOrg,
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithOrg();
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const locations = await prisma.location.findMany({
|
const locations = await prisma.location.findMany({
|
||||||
where: { organizationId: session.user.orgId },
|
where: { organizationId: session.user.orgId },
|
||||||
|
|
@ -29,31 +30,13 @@ export async function GET() {
|
||||||
|
|
||||||
return NextResponse.json({ locations });
|
return NextResponse.json({ locations });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/locations] GET error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch locations" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
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 { name, address } = await req.json();
|
const { name, address } = await req.json();
|
||||||
|
|
||||||
|
|
@ -74,10 +57,6 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ location });
|
return NextResponse.json({ location });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/locations] POST error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to create location" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,15 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithPermission, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
const VALID_ROLES = ["viewer", "reviewer", "editor", "admin"];
|
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(
|
export async function PATCH(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("users.invite");
|
||||||
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 { id } = await params;
|
const { id } = await params;
|
||||||
const { role } = await req.json();
|
const { role } = await req.json();
|
||||||
|
|
||||||
|
|
@ -69,8 +50,7 @@ export async function PATCH(
|
||||||
|
|
||||||
return NextResponse.json({ member: updated });
|
return NextResponse.json({ member: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/members/[id]] PATCH error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: "Failed to update member" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,16 +59,7 @@ export async function DELETE(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("users.invite");
|
||||||
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 { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const target = await prisma.orgMember.findFirst({
|
const target = await prisma.orgMember.findFirst({
|
||||||
|
|
@ -123,7 +94,6 @@ export async function DELETE(
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org/members/[id]] DELETE error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: "Failed to remove member" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
requireApiAuthWithOrg,
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithOrg();
|
||||||
if (!session?.user?.orgId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const organization = await prisma.organization.findUnique({
|
const organization = await prisma.organization.findUnique({
|
||||||
where: { id: session.user.orgId },
|
where: { id: session.user.orgId },
|
||||||
|
|
@ -30,11 +31,7 @@ export async function GET() {
|
||||||
|
|
||||||
return NextResponse.json({ organization });
|
return NextResponse.json({ organization });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org] GET error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch organization" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,21 +44,7 @@ function slugify(name: string): string {
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireApiAuthWithPermission("settings.edit");
|
||||||
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 { name, type, timezone, allowedDomains } = await req.json();
|
const { name, type, timezone, allowedDomains } = await req.json();
|
||||||
|
|
||||||
|
|
@ -85,10 +68,6 @@ export async function PUT(req: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[org] PUT error:", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to update organization" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
import {
|
||||||
|
requireApiAuth,
|
||||||
|
requireApiAuthWithPermission,
|
||||||
|
handleApiError,
|
||||||
|
} from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -29,7 +33,7 @@ export async function GET() {
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
await requireApiAuth();
|
await requireApiAuthWithPermission("settings.edit");
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const ALLOWED_TYPES = [
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await requireApiAuthWithOrg();
|
const session = await requireApiAuthWithOrg("uploads.create");
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const files = [
|
const files = [
|
||||||
...(formData.getAll("files") as File[]),
|
...(formData.getAll("files") as File[]),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { SelectionToolbar } from "./selection-toolbar";
|
||||||
import { UploadModal, type UploadingFile } from "./upload-modal";
|
import { UploadModal, type UploadingFile } from "./upload-modal";
|
||||||
import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
||||||
import { useUserProfile } from "@/lib/user-profile";
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
|
import { can } from "@/lib/permissions";
|
||||||
|
|
||||||
const VISIT_TYPE_OPTIONS = [
|
const VISIT_TYPE_OPTIONS = [
|
||||||
"First/Second Time Guest",
|
"First/Second Time Guest",
|
||||||
|
|
@ -40,7 +41,12 @@ export function DashboardContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { role } = useUserProfile();
|
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 page = parseInt(searchParams.get("page") || "1");
|
||||||
const limit = parseInt(searchParams.get("limit") || "20");
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
|
|
@ -285,7 +291,7 @@ export function DashboardContent() {
|
||||||
() =>
|
() =>
|
||||||
createColumns({
|
createColumns({
|
||||||
onViewDetails: (card) => router.push(`/cards/${card.id}`),
|
onViewDetails: (card) => router.push(`/cards/${card.id}`),
|
||||||
onMarkReviewed: isAdmin
|
onMarkReviewed: canReview
|
||||||
? async (card) => {
|
? async (card) => {
|
||||||
await fetch(`/api/cards/${card.id}`, {
|
await fetch(`/api/cards/${card.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
@ -296,7 +302,7 @@ export function DashboardContent() {
|
||||||
fetchCards();
|
fetchCards();
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onReprocess: isAdmin
|
onReprocess: canReprocess
|
||||||
? async (card) => {
|
? async (card) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/cards/${card.id}/reprocess`, {
|
const res = await fetch(`/api/cards/${card.id}/reprocess`, {
|
||||||
|
|
@ -315,7 +321,7 @@ export function DashboardContent() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onDelete: isAdmin
|
onDelete: canDelete
|
||||||
? async (card) => {
|
? async (card) => {
|
||||||
await fetch(`/api/cards/${card.id}`, { method: "DELETE" });
|
await fetch(`/api/cards/${card.id}`, { method: "DELETE" });
|
||||||
toast.success("Card deleted");
|
toast.success("Card deleted");
|
||||||
|
|
@ -323,7 +329,7 @@ export function DashboardContent() {
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
}),
|
}),
|
||||||
[router, fetchCards, isAdmin]
|
[router, fetchCards, canReview, canReprocess, canDelete]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUploadStart = (files: UploadingFile[]) => {
|
const handleUploadStart = (files: UploadingFile[]) => {
|
||||||
|
|
@ -397,6 +403,7 @@ export function DashboardContent() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!canUpload) return;
|
||||||
const handleDragOver = (e: DragEvent) => {
|
const handleDragOver = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer?.types.includes("Files")) {
|
if (e.dataTransfer?.types.includes("Files")) {
|
||||||
|
|
@ -426,7 +433,7 @@ export function DashboardContent() {
|
||||||
document.removeEventListener("dragleave", handleDragLeave);
|
document.removeEventListener("dragleave", handleDragLeave);
|
||||||
document.removeEventListener("drop", handleDrop);
|
document.removeEventListener("drop", handleDrop);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [canUpload]);
|
||||||
|
|
||||||
const openUpload = React.useCallback(() => {
|
const openUpload = React.useCallback(() => {
|
||||||
setUploadModalOpen(true);
|
setUploadModalOpen(true);
|
||||||
|
|
@ -474,7 +481,7 @@ export function DashboardContent() {
|
||||||
attendanceDurationOptions={ATTENDANCE_OPTIONS}
|
attendanceDurationOptions={ATTENDANCE_OPTIONS}
|
||||||
serviceAttendedOptions={SERVICE_OPTIONS}
|
serviceAttendedOptions={SERVICE_OPTIONS}
|
||||||
onExportCsv={handleExportCsv}
|
onExportCsv={handleExportCsv}
|
||||||
onUploadClick={isAdmin ? openUpload : undefined}
|
onUploadClick={canUpload ? openUpload : undefined}
|
||||||
columnVisibility={columnVisibility}
|
columnVisibility={columnVisibility}
|
||||||
onColumnVisibilityChange={setColumnVisibility}
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
showAssignedToFilter
|
showAssignedToFilter
|
||||||
|
|
@ -499,7 +506,7 @@ export function DashboardContent() {
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
hasActiveFilters={hasActiveFilters}
|
hasActiveFilters={hasActiveFilters}
|
||||||
onClearFilters={clearFilters}
|
onClearFilters={clearFilters}
|
||||||
onUploadClick={isAdmin ? openUpload : undefined}
|
onUploadClick={canUpload ? openUpload : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -507,12 +514,12 @@ export function DashboardContent() {
|
||||||
<SelectionToolbar
|
<SelectionToolbar
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onMarkReviewed={isAdmin ? (ids) => handleBulkAction(ids, "reviewed") : undefined}
|
onMarkReviewed={canReview ? (ids) => handleBulkAction(ids, "reviewed") : undefined}
|
||||||
onMarkExported={isAdmin ? (ids) => handleBulkAction(ids, "exported") : undefined}
|
onMarkExported={canEditCards ? (ids) => handleBulkAction(ids, "exported") : undefined}
|
||||||
onReprocess={isAdmin ? handleBulkReprocess : undefined}
|
onReprocess={canReprocess ? handleBulkReprocess : undefined}
|
||||||
onSyncMonday={isAdmin ? handleBulkSyncMonday : undefined}
|
onSyncMonday={canReprocess ? handleBulkSyncMonday : undefined}
|
||||||
onAssign={isAdmin ? handleAssign : undefined}
|
onAssign={canAssign ? handleAssign : undefined}
|
||||||
onDelete={isAdmin ? (ids) => handleBulkAction(ids, "delete") : undefined}
|
onDelete={canDelete ? (ids) => handleBulkAction(ids, "delete") : undefined}
|
||||||
onClear={() => setSelectedIds([])}
|
onClear={() => setSelectedIds([])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { OrgSwitcher } from "@/components/layout/org-switcher";
|
import { OrgSwitcher } from "@/components/layout/org-switcher";
|
||||||
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
|
import { isAdminRole } from "@/lib/permissions";
|
||||||
|
|
||||||
const STORAGE_KEY = "echo-sidebar-collapsed";
|
const STORAGE_KEY = "echo-sidebar-collapsed";
|
||||||
|
|
||||||
|
|
@ -139,6 +141,11 @@ function NavItem({
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { collapsed, toggle } = useSidebar();
|
const { collapsed, toggle } = useSidebar();
|
||||||
|
const { role } = useUserProfile();
|
||||||
|
const canManageSettings = isAdminRole(role);
|
||||||
|
const visibleBottomItems = bottomItems.filter(
|
||||||
|
(item) => item.href !== "/settings" || canManageSettings
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|
@ -176,7 +183,7 @@ export function Sidebar() {
|
||||||
collapsed && "px-2"
|
collapsed && "px-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{bottomItems.map((item) => (
|
{visibleBottomItems.map((item) => (
|
||||||
<NavItem
|
<NavItem
|
||||||
key={item.href}
|
key={item.href}
|
||||||
{...item}
|
{...item}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,13 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useUserProfile } from "@/lib/user-profile";
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
|
import { isAdminRole } from "@/lib/permissions";
|
||||||
import { NotificationCenter } from "@/components/notifications/notification-center";
|
import { NotificationCenter } from "@/components/notifications/notification-center";
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { profile, initials } = useUserProfile();
|
const { profile, initials, role } = useUserProfile();
|
||||||
|
const canManageSettings = isAdminRole(role);
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
const handleUploadClick = () => {
|
||||||
window.dispatchEvent(new CustomEvent("open-upload-modal"));
|
window.dispatchEvent(new CustomEvent("open-upload-modal"));
|
||||||
|
|
@ -145,10 +147,12 @@ export function TopBar() {
|
||||||
<UserCircle className="size-4" />
|
<UserCircle className="size-4" />
|
||||||
Profile
|
Profile
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem render={<Link href="/settings" />}>
|
{canManageSettings && (
|
||||||
<Settings className="size-4" />
|
<DropdownMenuItem render={<Link href="/settings" />}>
|
||||||
Settings
|
<Settings className="size-4" />
|
||||||
</DropdownMenuItem>
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => window.open("mailto:support@echoocr.app", "_blank")}
|
onClick={() => window.open("mailto:support@echoocr.app", "_blank")}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { type Action, PermissionError, can } from "@/lib/permissions";
|
||||||
|
|
||||||
export class ApiAuthError extends Error {
|
export class ApiAuthError extends Error {
|
||||||
constructor(message = "Unauthorized") {
|
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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new ApiAuthError();
|
throw new ApiAuthError();
|
||||||
|
|
@ -16,18 +22,40 @@ export async function requireApiAuth() {
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireApiAuthWithOrg() {
|
export async function requireApiAuthWithOrg(
|
||||||
|
action?: Action
|
||||||
|
): Promise<OrgSession> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id || !session.user.orgId) {
|
if (!session?.user?.id || !session.user.orgId) {
|
||||||
throw new ApiAuthError();
|
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) {
|
export function handleApiError(error: unknown) {
|
||||||
if (error instanceof ApiAuthError) {
|
if (error instanceof ApiAuthError) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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);
|
console.error("[API Error]", error);
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,39 @@
|
||||||
export type Role = "owner" | "admin" | "editor" | "reviewer" | "viewer";
|
export type Role = "owner" | "admin" | "editor" | "reviewer" | "viewer";
|
||||||
|
|
||||||
|
export const ROLES: readonly Role[] = [
|
||||||
|
"owner",
|
||||||
|
"admin",
|
||||||
|
"editor",
|
||||||
|
"reviewer",
|
||||||
|
"viewer",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
|
// Read
|
||||||
| "cards.view"
|
| "cards.view"
|
||||||
| "cards.create"
|
|
||||||
| "cards.edit"
|
|
||||||
| "cards.delete"
|
|
||||||
| "cards.assign"
|
|
||||||
| "cards.review"
|
|
||||||
| "cards.export"
|
| "cards.export"
|
||||||
| "events.view"
|
| "events.view"
|
||||||
| "events.manage"
|
|
||||||
| "people.view"
|
| "people.view"
|
||||||
| "reports.view"
|
| "reports.view"
|
||||||
|
| "settings.view"
|
||||||
|
// Review (reviewer+)
|
||||||
|
| "cards.review"
|
||||||
|
// Edit (editor+)
|
||||||
|
| "cards.create"
|
||||||
|
| "cards.edit"
|
||||||
| "uploads.create"
|
| "uploads.create"
|
||||||
|
| "events.manage"
|
||||||
|
// Admin (admin+)
|
||||||
|
| "cards.delete"
|
||||||
|
| "cards.assign"
|
||||||
|
| "cards.reprocess"
|
||||||
| "users.view"
|
| "users.view"
|
||||||
| "users.invite"
|
| "users.invite"
|
||||||
| "users.manage"
|
|
||||||
| "settings.view"
|
|
||||||
| "settings.edit"
|
| "settings.edit"
|
||||||
| "org.manage"
|
| "integrations.manage"
|
||||||
| "integrations.manage";
|
// Owner
|
||||||
|
| "users.manage"
|
||||||
|
| "org.manage";
|
||||||
|
|
||||||
const ROLE_HIERARCHY: Record<Role, number> = {
|
const ROLE_HIERARCHY: Record<Role, number> = {
|
||||||
owner: 5,
|
owner: 5,
|
||||||
|
|
@ -31,46 +45,75 @@ const ROLE_HIERARCHY: Record<Role, number> = {
|
||||||
|
|
||||||
const PERMISSION_MAP: Record<Action, Role> = {
|
const PERMISSION_MAP: Record<Action, Role> = {
|
||||||
"cards.view": "viewer",
|
"cards.view": "viewer",
|
||||||
"cards.create": "editor",
|
|
||||||
"cards.edit": "editor",
|
|
||||||
"cards.delete": "admin",
|
|
||||||
"cards.assign": "admin",
|
|
||||||
"cards.review": "reviewer",
|
|
||||||
"cards.export": "viewer",
|
"cards.export": "viewer",
|
||||||
"events.view": "viewer",
|
"events.view": "viewer",
|
||||||
"events.manage": "editor",
|
|
||||||
"people.view": "viewer",
|
"people.view": "viewer",
|
||||||
"reports.view": "viewer",
|
"reports.view": "viewer",
|
||||||
|
"settings.view": "viewer",
|
||||||
|
|
||||||
|
"cards.review": "reviewer",
|
||||||
|
|
||||||
|
"cards.create": "editor",
|
||||||
|
"cards.edit": "editor",
|
||||||
"uploads.create": "editor",
|
"uploads.create": "editor",
|
||||||
|
"events.manage": "editor",
|
||||||
|
|
||||||
|
"cards.delete": "admin",
|
||||||
|
"cards.assign": "admin",
|
||||||
|
"cards.reprocess": "admin",
|
||||||
"users.view": "admin",
|
"users.view": "admin",
|
||||||
"users.invite": "admin",
|
"users.invite": "admin",
|
||||||
"users.manage": "owner",
|
|
||||||
"settings.view": "viewer",
|
|
||||||
"settings.edit": "admin",
|
"settings.edit": "admin",
|
||||||
"org.manage": "owner",
|
|
||||||
"integrations.manage": "admin",
|
"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];
|
const minRole = PERMISSION_MAP[action];
|
||||||
if (!minRole) return false;
|
if (!minRole) return false;
|
||||||
const userLevel = ROLE_HIERARCHY[role as Role] ?? 0;
|
return roleLevel(role) >= ROLE_HIERARCHY[minRole];
|
||||||
const requiredLevel = ROLE_HIERARCHY[minRole] ?? 999;
|
}
|
||||||
return userLevel >= requiredLevel;
|
|
||||||
|
/**
|
||||||
|
* 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(
|
export function requirePermission(
|
||||||
role: string | undefined | null,
|
role: string | undefined | null,
|
||||||
action: Action
|
action: Action
|
||||||
): void {
|
): void {
|
||||||
if (!role || !can(role, action)) {
|
if (!can(role, action)) {
|
||||||
throw new PermissionError(`Missing permission: ${action}`);
|
throw new PermissionError(`Missing permission: ${action}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PermissionError extends Error {
|
export class PermissionError extends Error {
|
||||||
constructor(message: string) {
|
public readonly action?: Action;
|
||||||
|
constructor(message: string, action?: Action) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "PermissionError";
|
this.name = "PermissionError";
|
||||||
|
this.action = action;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,18 @@ export async function middleware(req: NextRequest) {
|
||||||
return NextResponse.redirect(new URL("/onboarding", req.url));
|
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();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue