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:
Randall Stillwell 2026-04-17 12:48:59 -05:00
parent 3e5449cf4f
commit d0844ecadd
4 changed files with 517 additions and 86 deletions

View file

@ -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>

View 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 });
}
}

View file

@ -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,

View 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 });
}
}