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 * as React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useSession } from "next-auth/react";
|
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 { 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";
|
||||||
|
|
@ -52,6 +62,10 @@ export default function UsersSettingsPage() {
|
||||||
const [inviteEmail, setInviteEmail] = React.useState("");
|
const [inviteEmail, setInviteEmail] = React.useState("");
|
||||||
const [inviteRole, setInviteRole] = React.useState("viewer");
|
const [inviteRole, setInviteRole] = React.useState("viewer");
|
||||||
const [sending, setSending] = React.useState(false);
|
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 () => {
|
const fetchData = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -76,6 +90,9 @@ export default function UsersSettingsPage() {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [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) => {
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSending(true);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Invite */}
|
{/* Invite */}
|
||||||
<Card className="glass-card">
|
{isAdmin && (
|
||||||
<CardHeader>
|
<Card className="glass-card">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardHeader>
|
||||||
<UserPlus className="size-4" />
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
Invite Team Member
|
<UserPlus className="size-4" />
|
||||||
</CardTitle>
|
Invite Team Member
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<form onSubmit={handleInvite} className="flex flex-wrap items-end gap-3">
|
<CardContent>
|
||||||
<div className="min-w-[200px] flex-1 space-y-1.5">
|
<form onSubmit={handleInvite} className="flex flex-wrap items-end gap-3">
|
||||||
<Label className="text-xs">Email</Label>
|
<div className="min-w-[200px] flex-1 space-y-1.5">
|
||||||
<Input
|
<Label className="text-xs">Email</Label>
|
||||||
type="email"
|
<Input
|
||||||
value={inviteEmail}
|
type="email"
|
||||||
onChange={(e) => setInviteEmail(e.target.value)}
|
value={inviteEmail}
|
||||||
placeholder="colleague@church.org"
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
required
|
placeholder="colleague@church.org"
|
||||||
/>
|
required
|
||||||
</div>
|
/>
|
||||||
<div className="w-36 space-y-1.5">
|
</div>
|
||||||
<Label className="text-xs">Role</Label>
|
<div className="w-36 space-y-1.5">
|
||||||
<Select value={inviteRole} onValueChange={(v) => v && setInviteRole(v)}>
|
<Label className="text-xs">Role</Label>
|
||||||
<SelectTrigger>
|
<Select value={inviteRole} onValueChange={(v) => v && setInviteRole(v)}>
|
||||||
<SelectValue />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{ROLES.map((r) => (
|
<SelectContent>
|
||||||
<SelectItem key={r.value} value={r.value}>
|
{ROLES.map((r) => (
|
||||||
{r.label}
|
<SelectItem key={r.value} value={r.value}>
|
||||||
</SelectItem>
|
{r.label}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
<Button type="submit" disabled={sending}>
|
</div>
|
||||||
{sending ? (
|
<Button type="submit" disabled={sending}>
|
||||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
{sending ? (
|
||||||
) : (
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
) : (
|
||||||
)}
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
Send Invite
|
)}
|
||||||
</Button>
|
Send Invite
|
||||||
</form>
|
</Button>
|
||||||
</CardContent>
|
</form>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Current Members */}
|
{/* Current Members */}
|
||||||
<Card className="glass-card">
|
<Card className="glass-card">
|
||||||
|
|
@ -170,31 +275,75 @@ export default function UsersSettingsPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{members.map((m) => (
|
{members.map((m) => {
|
||||||
<div
|
const isMe = m.user.id === session?.user?.id;
|
||||||
key={m.id}
|
const isOwner = m.role === "owner";
|
||||||
className="flex items-center justify-between rounded-lg border border-border/50 px-4 py-3"
|
const canEdit = isAdmin && !isMe && !isOwner;
|
||||||
>
|
|
||||||
<div>
|
return (
|
||||||
<p className="text-sm font-medium">
|
<div
|
||||||
{m.user.displayName || m.user.email}
|
key={m.id}
|
||||||
</p>
|
className="flex items-center justify-between gap-3 rounded-lg border border-border/50 px-4 py-3"
|
||||||
<p className="text-xs text-muted-foreground">
|
>
|
||||||
{m.user.email}
|
<div className="min-w-0 flex-1">
|
||||||
</p>
|
<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>
|
||||||
<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 && (
|
{members.length === 0 && (
|
||||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
No team members yet — send an invitation above.
|
No team members yet — send an invitation above.
|
||||||
|
|
@ -205,35 +354,88 @@ export default function UsersSettingsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Pending Invitations */}
|
{/* Pending Invitations */}
|
||||||
{invitations.length > 0 && (
|
{pendingInvitations.length > 0 && (
|
||||||
<Card className="glass-card">
|
<Card className="glass-card">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Mail className="size-4" />
|
<Mail className="size-4" />
|
||||||
Pending Invitations
|
Pending Invitations
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{pendingInvitations.length} pending
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{invitations
|
{pendingInvitations.map((i) => {
|
||||||
.filter((i) => !i.acceptedAt)
|
const isExpired = new Date(i.expiresAt) < new Date();
|
||||||
.map((i) => (
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={i.id}
|
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>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium">{i.email}</p>
|
<p className="truncate text-sm font-medium">{i.email}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
Expires{" "}
|
{isExpired ? (
|
||||||
{new Date(i.expiresAt).toLocaleDateString()}
|
<>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<Badge variant="outline" className="text-xs capitalize">
|
|
||||||
{i.role}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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({
|
const invitations = await prisma.invitation.findMany({
|
||||||
where: { organizationId: session.user.orgId },
|
where: { organizationId: session.user.orgId, acceptedAt: null },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
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