From d0844ecadd492e252ecd87d418136479a4bbfe50 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 17 Apr 2026 12:48:59 -0500 Subject: [PATCH] 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 --- src/app/(dashboard)/settings/users/page.tsx | 372 +++++++++++++++----- src/app/api/org/invitations/[id]/route.ts | 100 ++++++ src/app/api/org/invitations/route.ts | 2 +- src/app/api/org/members/[id]/route.ts | 129 +++++++ 4 files changed, 517 insertions(+), 86 deletions(-) create mode 100644 src/app/api/org/invitations/[id]/route.ts create mode 100644 src/app/api/org/members/[id]/route.ts diff --git a/src/app/(dashboard)/settings/users/page.tsx b/src/app/(dashboard)/settings/users/page.tsx index 764b9db..27040de 100644 --- a/src/app/(dashboard)/settings/users/page.tsx +++ b/src/app/(dashboard)/settings/users/page.tsx @@ -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(null); + const [removingId, setRemovingId] = React.useState(null); + const [resendingId, setResendingId] = React.useState(null); + const [revokingId, setRevokingId] = React.useState(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 (
@@ -108,54 +209,58 @@ export default function UsersSettingsPage() { ); } + const pendingInvitations = invitations.filter((i) => !i.acceptedAt); + return (
{/* Invite */} - - - - - Invite Team Member - - - -
-
- - setInviteEmail(e.target.value)} - placeholder="colleague@church.org" - required - /> -
-
- - -
- -
-
-
+ {isAdmin && ( + + + + + Invite Team Member + + + +
+
+ + setInviteEmail(e.target.value)} + placeholder="colleague@church.org" + required + /> +
+
+ + +
+ +
+
+
+ )} {/* Current Members */} @@ -170,31 +275,75 @@ export default function UsersSettingsPage() {
- {members.map((m) => ( -
-
-

- {m.user.displayName || m.user.email} -

-

- {m.user.email} -

+ {members.map((m) => { + const isMe = m.user.id === session?.user?.id; + const isOwner = m.role === "owner"; + const canEdit = isAdmin && !isMe && !isOwner; + + return ( +
+
+

+ {m.user.displayName || m.user.email} +

+

+ {m.user.email} +

+
+ +
+ {canEdit ? ( + + ) : ( + + {m.role} + + )} + + {isMe && ( + + You + + )} + + {canEdit && ( + + )} +
-
- - {m.role} - - {m.user.id === session?.user?.id && ( - - You - - )} -
-
- ))} + ); + })} {members.length === 0 && (

No team members yet — send an invitation above. @@ -205,35 +354,88 @@ export default function UsersSettingsPage() { {/* Pending Invitations */} - {invitations.length > 0 && ( + {pendingInvitations.length > 0 && ( Pending Invitations + + {pendingInvitations.length} pending +

- {invitations - .filter((i) => !i.acceptedAt) - .map((i) => ( + {pendingInvitations.map((i) => { + const isExpired = new Date(i.expiresAt) < new Date(); + + return (
-
-

{i.email}

-

- Expires{" "} - {new Date(i.expiresAt).toLocaleDateString()} -

+
+

{i.email}

+
+ {isExpired ? ( + <> + + Expired + + ) : ( + <> + + + Expires{" "} + {new Date(i.expiresAt).toLocaleDateString()} + + + )} +
+
+ +
+ + {i.role} + + + {isAdmin && ( + <> + + + + )}
- - {i.role} -
- ))} + ); + })}
diff --git a/src/app/api/org/invitations/[id]/route.ts b/src/app/api/org/invitations/[id]/route.ts new file mode 100644 index 0000000..2da84c7 --- /dev/null +++ b/src/app/api/org/invitations/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/org/invitations/route.ts b/src/app/api/org/invitations/route.ts index b18888c..310911f 100644 --- a/src/app/api/org/invitations/route.ts +++ b/src/app/api/org/invitations/route.ts @@ -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, diff --git a/src/app/api/org/members/[id]/route.ts b/src/app/api/org/members/[id]/route.ts new file mode 100644 index 0000000..7c0925c --- /dev/null +++ b/src/app/api/org/members/[id]/route.ts @@ -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 }); + } +}