From 07acf4d93d18891a5b481d49d58cf5857a7ec92e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 16:27:41 +0000 Subject: [PATCH] Add pending-invitations admin UI and wire member removal Adds two pieces of user-access management on the portfolio settings page that were previously stubbed or missing: - The existing "Remove" button was wired to console.log only. It now calls a DELETE on /colloborators with optimistic cache update and rollback on failure. The route refuses to remove the portfolio creator and 404s if the membership isn't in the URL's portfolio. - A new "Pending invitations" section in UsersPermissionsCard lists invitees who haven't signed in yet, backed by a new /api/portfolio/[id]/invitations endpoint (GET + DELETE). Admins can revoke a pending invitation; revoking deletes the row so the invitee no longer auto-joins on sign-in. Inviting a new email shows up here immediately because the invite mutation invalidates both query keys. Both new mutations use optimistic updates with rollback, and disable only the in-flight row (mutation.variables === currentId) so the rest of the table stays interactive. No useEffect, TanStack Query throughout. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portfolioId]/invitations/route.ts | 105 ++++++ .../settings/UsersPermissionsCard.tsx | 343 ++++++++++++++---- 2 files changed, 382 insertions(+), 66 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/invitations/route.ts diff --git a/src/app/api/portfolio/[portfolioId]/invitations/route.ts b/src/app/api/portfolio/[portfolioId]/invitations/route.ts new file mode 100644 index 0000000..33ccdba --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/invitations/route.ts @@ -0,0 +1,105 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { portfolioInvitations } from "@/app/db/schema/portfolio"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +// GET: list pending invitations for a portfolio. Invitations are consumed +// (deleted) when the invitee signs in, so anything returned here is still +// pending. +export async function GET( + _req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + const pId = BigInt(portfolioId); + const rows = await db + .select({ + id: portfolioInvitations.id, + email: portfolioInvitations.email, + role: portfolioInvitations.role, + createdAt: portfolioInvitations.createdAt, + }) + .from(portfolioInvitations) + .where(eq(portfolioInvitations.portfolioId, pId)); + + const invitations = rows.map((r) => ({ + invitationId: r.id.toString(), + email: r.email, + role: r.role, + createdAt: r.createdAt.toISOString(), + })); + + return NextResponse.json({ invitations }, { status: 200 }); + } catch (err) { + console.error("GET /invitations error:", err); + return NextResponse.json( + { error: "Failed to fetch invitations" }, + { status: 500 }, + ); + } +} + +// DELETE: revoke a pending invitation. Idempotent — 404 if it's already +// been consumed or revoked. +export async function DELETE( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const bodySchema = z.object({ invitationId: z.string() }); + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + const pId = BigInt(portfolioId); + const invId = BigInt(body.invitationId); + + const result = await db + .delete(portfolioInvitations) + .where( + and( + eq(portfolioInvitations.id, invId), + eq(portfolioInvitations.portfolioId, pId), + ), + ) + .returning({ id: portfolioInvitations.id }); + + if (result.length === 0) { + return NextResponse.json( + { error: "Invitation not found in this portfolio" }, + { status: 404 }, + ); + } + + return NextResponse.json( + { success: true, invitationId: body.invitationId }, + { status: 200 }, + ); + } catch (err) { + console.error("DELETE /invitations error:", err); + return NextResponse.json( + { error: "Failed to revoke invitation" }, + { status: 500 }, + ); + } +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index c0e97ea..323ffef 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -12,10 +12,19 @@ import { Input } from "@/app/shadcn_components/ui/input"; import { Button } from "@/app/shadcn_components/ui/button"; import { useState } from "react"; -import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; +import { + Role, + RoleDropdown, + Collaborator, +} from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; - +type PendingInvitation = { + invitationId: string; + email: string; + role: Role | "creator" | "admin"; + createdAt: string; +}; async function getPortfolioUsers(portfolioId: string): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { @@ -25,22 +34,41 @@ async function getPortfolioUsers(portfolioId: string): Promise { if (!res.ok) throw new Error("Failed to fetch users"); const json = await res.json(); const users = Array.isArray(json) ? json : json.users; // support both shapes - // Guard + shape to Collaborator[] return Array.isArray(users) ? users.map((u: any) => ({ - portfolioUserId: String(u.portfolioUserId), - userId: String(u.userId), - name: u.name ?? null, - email: u.email ?? "", - role: u.role, - })) + portfolioUserId: String(u.portfolioUserId), + userId: String(u.userId), + name: u.name ?? null, + email: u.email ?? "", + role: u.role, + })) + : []; +} + +async function getPortfolioInvitations( + portfolioId: string, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/invitations`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error("Failed to fetch invitations"); + const json = await res.json(); + const invitations = json?.invitations ?? []; + return Array.isArray(invitations) + ? invitations.map((i: any) => ({ + invitationId: String(i.invitationId), + email: i.email ?? "", + role: i.role, + createdAt: i.createdAt, + })) : []; } async function updatePortfolioUserRole( portfolioId: string, portfolioUserId: string, - role: Role + role: Role, ): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { method: "PUT", @@ -57,7 +85,7 @@ async function invitePortfolioUser( portfolioId: string, email: string, role: Role, - name: string + name: string, ): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { method: "POST", @@ -70,6 +98,35 @@ async function invitePortfolioUser( } } +async function removePortfolioUser( + portfolioId: string, + portfolioUserId: string, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ portfolioUserId }), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || "Failed to remove user"); + } +} + +async function revokePortfolioInvitation( + portfolioId: string, + invitationId: string, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/invitations`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ invitationId }), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || "Failed to revoke invitation"); + } +} export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const [inviteEmail, setInviteEmail] = useState(""); @@ -78,89 +135,154 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const queryClient = useQueryClient(); + const usersKey = ["portfolioUsers", portfolioId]; + const invitationsKey = ["portfolioInvitations", portfolioId]; + const { data: collaborators = [], isLoading, isFetching, refetch, } = useQuery({ - queryKey: ["portfolioUsers", portfolioId], + queryKey: usersKey, queryFn: () => getPortfolioUsers(portfolioId), - enabled: !!portfolioId, // only run when id is present - refetchOnWindowFocus: false, // optional: avoid surprise refetch logs - onSuccess: (data) => { - console.log("Fetched users for portfolio:", data); - }, - onError: (err) => { - console.error("Error fetching users:", err); - }, + enabled: !!portfolioId, + refetchOnWindowFocus: false, }); + const { + data: invitations = [], + isLoading: invitationsLoading, + isFetching: invitationsFetching, + } = useQuery({ + queryKey: invitationsKey, + queryFn: () => getPortfolioInvitations(portfolioId), + enabled: !!portfolioId, + refetchOnWindowFocus: false, + }); + + const invalidateBoth = () => { + queryClient.invalidateQueries({ queryKey: usersKey }); + queryClient.invalidateQueries({ queryKey: invitationsKey }); + }; + const changeRoleMutation = useMutation({ - mutationFn: ({ portfolioUserId, role }: { portfolioUserId: string; role: Role }) => - updatePortfolioUserRole(portfolioId, portfolioUserId, role), + mutationFn: ({ + portfolioUserId, + role, + }: { + portfolioUserId: string; + role: Role; + }) => updatePortfolioUserRole(portfolioId, portfolioUserId, role), - // Optimistic update onMutate: async ({ portfolioUserId, role }) => { - await queryClient.cancelQueries({ queryKey: ["portfolioUsers", portfolioId] }); - const previous = queryClient.getQueryData(["portfolioUsers", portfolioId]); - - // Optimistically update cache - queryClient.setQueryData( - ["portfolioUsers", portfolioId], - (old) => - (old ?? []).map((c) => - c.portfolioUserId === portfolioUserId ? { ...c, role } : c - ) + await queryClient.cancelQueries({ queryKey: usersKey }); + const previous = queryClient.getQueryData(usersKey); + queryClient.setQueryData(usersKey, (old) => + (old ?? []).map((c) => + c.portfolioUserId === portfolioUserId ? { ...c, role } : c, + ), ); - - // Return context to rollback on error return { previous }; }, - - // Rollback on error onError: (err, _vars, context) => { if (context?.previous) { - queryClient.setQueryData(["portfolioUsers", portfolioId], context.previous); + queryClient.setQueryData(usersKey, context.previous); } console.error("Failed to update role:", err); }, - - // Always revalidate after success/error onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] }); + queryClient.invalidateQueries({ queryKey: usersKey }); }, }); - // ADD: mutation for inviting a user const inviteUserMutation = useMutation({ - mutationFn: ({ email, role, name }: { email: string; role: Role; name: string }) => - invitePortfolioUser(portfolioId, email, role, name), + mutationFn: ({ + email, + role, + name, + }: { + email: string; + role: Role; + name: string; + }) => invitePortfolioUser(portfolioId, email, role, name), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] }); + invalidateBoth(); setInviteEmail(""); - setInviteName(""); // clear name after success - // setInviteRole("read"); + setInviteName(""); }, onError: (err) => { console.error("Invite failed:", err); }, }); + const removeUserMutation = useMutation({ + mutationFn: (portfolioUserId: string) => + removePortfolioUser(portfolioId, portfolioUserId), + + onMutate: async (portfolioUserId) => { + await queryClient.cancelQueries({ queryKey: usersKey }); + const previous = queryClient.getQueryData(usersKey); + queryClient.setQueryData(usersKey, (old) => + (old ?? []).filter((c) => c.portfolioUserId !== portfolioUserId), + ); + return { previous }; + }, + onError: (err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(usersKey, context.previous); + } + console.error("Failed to remove user:", err); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: usersKey }); + }, + }); + + const revokeInvitationMutation = useMutation({ + mutationFn: (invitationId: string) => + revokePortfolioInvitation(portfolioId, invitationId), + + onMutate: async (invitationId) => { + await queryClient.cancelQueries({ queryKey: invitationsKey }); + const previous = + queryClient.getQueryData(invitationsKey); + queryClient.setQueryData(invitationsKey, (old) => + (old ?? []).filter((i) => i.invitationId !== invitationId), + ); + return { previous }; + }, + onError: (err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(invitationsKey, context.previous); + } + console.error("Failed to revoke invitation:", err); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: invitationsKey }); + }, + }); function handleInvite() { - inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName }); + inviteUserMutation.mutate({ + email: inviteEmail, + role: inviteRole, + name: inviteName, + }); } function onChangeRole(portfolioUserId: string, role: Role) { - console.log(`Change portfolioUserId ${portfolioUserId} to ${role}`); changeRoleMutation.mutate({ portfolioUserId, role }); } function onRemove(portfolioUserId: string) { - console.log(`This button will delete the row portoflioUserId ${portfolioUserId}`); - console.log("This was not implemented as Jun-te wanted to avoid Delete via drizzle before Database integrirty") - // TODO: DELETE user -> then refetch() + if (!confirm("Remove this user from the portfolio?")) return; + removeUserMutation.mutate(portfolioUserId); + } + + function onRevokeInvitation(invitationId: string) { + if (!confirm("Revoke this pending invitation?")) return; + revokeInvitationMutation.mutate(invitationId); } return ( @@ -173,7 +295,11 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {

Add users and manage roles

- @@ -205,13 +331,15 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { - + @@ -219,7 +347,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { Current users -

Update roles or remove access

+

+ Update roles or remove access +

@@ -235,7 +365,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {isLoading ? ( - Loading… + + Loading… + ) : collaborators.length === 0 ? ( @@ -254,13 +386,27 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {c.role} ) : ( - onChangeRole(c.portfolioUserId, r)} /> + + onChangeRole(c.portfolioUserId, r) + } + /> )} {c.role !== "creator" && ( - )} @@ -272,8 +418,73 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
+ + {/* Pending invitations list */} + + + Pending invitations +

+ Emails invited but not yet signed in +

+
+ +
+ + + + Email + Role + Invited + Actions + + + + {invitationsLoading || invitationsFetching ? ( + + + Loading… + + + ) : invitations.length === 0 ? ( + + + No pending invitations. + + + ) : ( + invitations.map((i) => ( + + {i.email} + {i.role} + + {new Date(i.createdAt).toLocaleDateString()} + + + + + + )) + )} + +
+
+
+
); -} \ No newline at end of file +}