Add team member management: role changes, member removal, invitation resend/revoke
- Create PATCH/DELETE /api/org/members/[id] for role updates and member removal with owner/admin guards, self-action prevention, and activeOrgId cleanup - Create POST/DELETE /api/org/invitations/[id] for resending and revoking invites with automatic expiry extension on resend - Filter accepted invitations from GET /api/org/invitations response - Overhaul Team Members UI with inline role dropdowns, remove buttons, resend/revoke controls, expired invitation indicators, and admin-only guards Made-with: Cursor
This commit is contained in:
parent
3e5449cf4f
commit
d0844ecadd
4 changed files with 517 additions and 86 deletions
|
|
@ -3,7 +3,17 @@
|
|||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Loader2, Plus, UserPlus, Shield, Trash2, Mail } from "lucide-react";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
UserPlus,
|
||||
Shield,
|
||||
Trash2,
|
||||
Mail,
|
||||
RotateCw,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -52,6 +62,10 @@ export default function UsersSettingsPage() {
|
|||
const [inviteEmail, setInviteEmail] = React.useState("");
|
||||
const [inviteRole, setInviteRole] = React.useState("viewer");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [updatingRole, setUpdatingRole] = React.useState<string | null>(null);
|
||||
const [removingId, setRemovingId] = React.useState<string | null>(null);
|
||||
const [resendingId, setResendingId] = React.useState<string | null>(null);
|
||||
const [revokingId, setRevokingId] = React.useState<string | null>(null);
|
||||
|
||||
const fetchData = React.useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -76,6 +90,9 @@ export default function UsersSettingsPage() {
|
|||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const myMembership = members.find((m) => m.user.id === session?.user?.id);
|
||||
const isAdmin = myMembership && ["owner", "admin"].includes(myMembership.role);
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSending(true);
|
||||
|
|
@ -100,6 +117,90 @@ export default function UsersSettingsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (memberId: string, newRole: string) => {
|
||||
setUpdatingRole(memberId);
|
||||
try {
|
||||
const res = await fetch(`/api/org/members/${memberId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, role: newRole } : m))
|
||||
);
|
||||
toast.success("Role updated");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || "Failed to update role");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update role");
|
||||
} finally {
|
||||
setUpdatingRole(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (member: Member) => {
|
||||
setRemovingId(member.id);
|
||||
try {
|
||||
const res = await fetch(`/api/org/members/${member.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
setMembers((prev) => prev.filter((m) => m.id !== member.id));
|
||||
toast.success(`${member.user.displayName || member.user.email} removed`);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || "Failed to remove member");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to remove member");
|
||||
} finally {
|
||||
setRemovingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendInvite = async (invite: Invitation) => {
|
||||
setResendingId(invite.id);
|
||||
try {
|
||||
const res = await fetch(`/api/org/invitations/${invite.id}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success(`Invitation resent to ${invite.email}`);
|
||||
fetchData();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || "Failed to resend invitation");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to resend invitation");
|
||||
} finally {
|
||||
setResendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeInvite = async (invite: Invitation) => {
|
||||
setRevokingId(invite.id);
|
||||
try {
|
||||
const res = await fetch(`/api/org/invitations/${invite.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
setInvitations((prev) => prev.filter((i) => i.id !== invite.id));
|
||||
toast.success("Invitation revoked");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || "Failed to revoke invitation");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to revoke invitation");
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
|
|
@ -108,54 +209,58 @@ export default function UsersSettingsPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const pendingInvitations = invitations.filter((i) => !i.acceptedAt);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Invite */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<UserPlus className="size-4" />
|
||||
Invite Team Member
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1 space-y-1.5">
|
||||
<Label className="text-xs">Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="colleague@church.org"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 space-y-1.5">
|
||||
<Label className="text-xs">Role</Label>
|
||||
<Select value={inviteRole} onValueChange={(v) => v && setInviteRole(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={sending}>
|
||||
{sending ? (
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
Send Invite
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isAdmin && (
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<UserPlus className="size-4" />
|
||||
Invite Team Member
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1 space-y-1.5">
|
||||
<Label className="text-xs">Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="colleague@church.org"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 space-y-1.5">
|
||||
<Label className="text-xs">Role</Label>
|
||||
<Select value={inviteRole} onValueChange={(v) => v && setInviteRole(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={sending}>
|
||||
{sending ? (
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
Send Invite
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current Members */}
|
||||
<Card className="glass-card">
|
||||
|
|
@ -170,31 +275,75 @@ export default function UsersSettingsPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{members.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{m.user.displayName || m.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.user.email}
|
||||
</p>
|
||||
{members.map((m) => {
|
||||
const isMe = m.user.id === session?.user?.id;
|
||||
const isOwner = m.role === "owner";
|
||||
const canEdit = isAdmin && !isMe && !isOwner;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/50 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{m.user.displayName || m.user.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{m.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canEdit ? (
|
||||
<Select
|
||||
value={m.role}
|
||||
onValueChange={(v) => v && handleRoleChange(m.id, v)}
|
||||
disabled={updatingRole === m.id}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{m.role}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{isMe && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveMember(m)}
|
||||
disabled={removingId === m.id}
|
||||
aria-label={`Remove ${m.user.displayName || m.user.email}`}
|
||||
>
|
||||
{removingId === m.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{m.role}
|
||||
</Badge>
|
||||
{m.user.id === session?.user?.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{members.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No team members yet — send an invitation above.
|
||||
|
|
@ -205,35 +354,88 @@ export default function UsersSettingsPage() {
|
|||
</Card>
|
||||
|
||||
{/* Pending Invitations */}
|
||||
{invitations.length > 0 && (
|
||||
{pendingInvitations.length > 0 && (
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Mail className="size-4" />
|
||||
Pending Invitations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{pendingInvitations.length} pending
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{invitations
|
||||
.filter((i) => !i.acceptedAt)
|
||||
.map((i) => (
|
||||
{pendingInvitations.map((i) => {
|
||||
const isExpired = new Date(i.expiresAt) < new Date();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 px-4 py-3"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/50 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{i.email}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expires{" "}
|
||||
{new Date(i.expiresAt).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{i.email}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isExpired ? (
|
||||
<>
|
||||
<AlertTriangle className="size-3 text-amber-500" />
|
||||
<span className="text-amber-600 dark:text-amber-400">Expired</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
Expires{" "}
|
||||
{new Date(i.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{i.role}
|
||||
</Badge>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => handleResendInvite(i)}
|
||||
disabled={resendingId === i.id}
|
||||
aria-label={`Resend invitation to ${i.email}`}
|
||||
>
|
||||
{resendingId === i.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRevokeInvite(i)}
|
||||
disabled={revokingId === i.id}
|
||||
aria-label={`Revoke invitation for ${i.email}`}
|
||||
>
|
||||
{revokingId === i.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{i.role}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
100
src/app/api/org/invitations/[id]/route.ts
Normal file
100
src/app/api/org/invitations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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 { id } = await params;
|
||||
|
||||
const invitation = await prisma.invitation.findFirst({
|
||||
where: { id, organizationId: session.user.orgId },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: "Invitation not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (invitation.acceptedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: "This invitation has already been accepted" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const newExpiry = new Date();
|
||||
newExpiry.setDate(newExpiry.getDate() + 7);
|
||||
|
||||
await prisma.invitation.update({
|
||||
where: { id },
|
||||
data: { expiresAt: newExpiry },
|
||||
});
|
||||
|
||||
const baseUrl = new URL(req.url).origin;
|
||||
const orgName = session.user.orgName || "the organization";
|
||||
const inviterName = session.user.displayName || session.user.name || undefined;
|
||||
await sendInvitationEmail(invitation.email, baseUrl, invitation.token, orgName, inviterName);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_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 { id } = await params;
|
||||
|
||||
const invitation = await prisma.invitation.findFirst({
|
||||
where: { id, organizationId: session.user.orgId },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: "Invitation not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.invitation.delete({ where: { id } });
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ export async function GET() {
|
|||
}
|
||||
|
||||
const invitations = await prisma.invitation.findMany({
|
||||
where: { organizationId: session.user.orgId },
|
||||
where: { organizationId: session.user.orgId, acceptedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
|
|
|
|||
129
src/app/api/org/members/[id]/route.ts
Normal file
129
src/app/api/org/members/[id]/route.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
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 { id } = await params;
|
||||
const { role } = await req.json();
|
||||
|
||||
if (!role || !VALID_ROLES.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Role must be one of: ${VALID_ROLES.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const target = await prisma.orgMember.findFirst({
|
||||
where: { id, organizationId: session.user.orgId },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Member not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (target.role === "owner") {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot change the owner's role" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (target.userId === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You cannot change your own role" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await prisma.orgMember.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
include: { user: { select: { id: true, email: true, displayName: true } } },
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_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 { id } = await params;
|
||||
|
||||
const target = await prisma.orgMember.findFirst({
|
||||
where: { id, organizationId: session.user.orgId },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Member not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (target.role === "owner") {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot remove the organization owner" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (target.userId === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You cannot remove yourself" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.orgMember.delete({ where: { id } }),
|
||||
prisma.user.updateMany({
|
||||
where: { id: target.userId, activeOrgId: session.user.orgId },
|
||||
data: { activeOrgId: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue