Unify invitation flow: explicit accept/decline via profile-menu notifications

Replaces the auto-accept-on-signin behaviour with an explicit accept or
decline step. Every invitee (existing user or brand new) is now treated
the same way: an invitation lands in portfolio_invitations, the email
is a deep-link "Sign in to Ara", and the invitee accepts (or declines)
explicitly from a notifications panel hanging off their profile avatar.

Why: the previous flow silently added existing users to portfolios with
no way to refuse, and gave them no in-app confirmation that the invite
landed. Existing users complained they didn't know which account they
were signed in as either — both gaps are closed by the same panel.

Backend:
  - POST /collaborators no longer creates portfolioUsers directly for
    existing users; it writes a portfolio_invitations row in every case
    except the trivial "already a member of THIS portfolio" path
    (silent role update, no email)
  - New /api/user/invitations endpoint: GET lists pending invitations
    addressed to the current user across all portfolios, joined with
    portfolio + inviter context; POST accepts or declines a single
    invitation, scoped to session.email so users can't act on others'
  - Accept reuses the existing planInvitationApplication helper for
    the "already a member" idempotency check
  - Decline is a silent delete (matches GitHub/Linear/Notion convention;
    no email to inviter, no tombstone)
  - signIn callback no longer auto-applies pending invitations — that
    block is removed entirely along with its now-unused imports
  - Email template subject + body unified, no longer suggests the user
    is "added"; both modes say "invited" and direct them to the profile
    menu

Frontend:
  - ProfileDropDown rewritten as a notifications panel: shows the
    signed-in email at the top (closing the "which account?" gap),
    lists pending invitations with Accept/Decline buttons, displays a
    red count badge on the avatar (max "9+"). Uses TanStack Query with
    optimistic update on accept/decline and toast on outcome. Existing
    Help + Sign Out menu items preserved.
  - No useEffect — pending-count derived from query data, mutations
    handle the rest

Vercel preview test plan:
  - Invite a user already in another portfolio → red badge appears on
    their next page load; Accept lands them in the portfolio
  - Invite a new email → sign-up flow finishes; new account lands on
    home with a badge waiting for Accept (no longer auto-accepted)
  - Existing member of THIS portfolio re-invited → silent role update,
    no email

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 09:43:13 +00:00
parent f3887a215c
commit d70b15e705
5 changed files with 422 additions and 129 deletions

View file

@ -19,11 +19,6 @@ import {
authRateLimits,
verificationTokens,
} from "@/app/db/schema/users";
import {
portfolioInvitations,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { planInvitationApplication } from "@/app/lib/portfolioInvitations";
import { normaliseEmail } from "@/app/lib/email";
import { eq, and, ne } from "drizzle-orm";
@ -415,51 +410,9 @@ export const AuthOptions: NextAuthOptions = {
.set({ lastLogin: new Date() })
.where(eq(users.id, dbUser.id));
// Apply any pending portfolio invitations addressed to this email.
// Idempotent: runs every sign-in; no-op when there are no pending rows.
const pending = await db
.select({
id: portfolioInvitations.id,
portfolioId: portfolioInvitations.portfolioId,
role: portfolioInvitations.role,
})
.from(portfolioInvitations)
.where(eq(portfolioInvitations.email, normalisedEmail));
if (pending.length > 0) {
const existing = await db
.select({ portfolioId: portfolioUsers.portfolioId })
.from(portfolioUsers)
.where(eq(portfolioUsers.userId, dbUser.id));
const plan = planInvitationApplication({
userId: dbUser.id,
invitations: pending.map((p) => ({
id: p.id,
portfolioId: p.portfolioId,
role: p.role as "creator" | "admin" | "read" | "write",
})),
existingPortfolioIds: new Set(existing.map((m) => m.portfolioId)),
});
await db.transaction(async (tx) => {
if (plan.memberships.length > 0) {
await tx.insert(portfolioUsers).values(plan.memberships);
}
for (const id of plan.invitationsToDelete) {
await tx
.delete(portfolioInvitations)
.where(eq(portfolioInvitations.id, id));
}
});
console.log("APPLIED_PENDING_INVITATIONS", {
email: normalisedEmail,
userId: dbUser.id.toString(),
count: plan.memberships.length,
staleDeleted: plan.invitationsToDelete.length - plan.memberships.length,
});
}
// Pending portfolio invitations are NOT auto-applied here anymore.
// The invitee accepts/declines explicitly via the profile-menu
// notifications panel (POST /api/user/invitations).
// Pass bigint ID into NextAuth session/jwt
user.dbId = dbUser.id.toString();

View file

@ -194,9 +194,10 @@ export async function PUT(
// POST: invite a user by email.
//
// If the email already corresponds to a user, link them to the portfolio
// directly (existing user case). Otherwise create a pending invitation that
// gets consumed by the signIn callback the first time the invitee signs in.
// Unified flow: in nearly every case we write a pending portfolio_invitations
// row, and the invitee accepts/declines explicitly via the in-app dropdown.
// The only fast-path is when the invitee is *already* a member of this
// portfolio — then it's just a role update with no email or invitation.
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
@ -255,6 +256,8 @@ export async function POST(
.where(eq(user.email, email))
.limit(1);
// Fast path: invitee is already a member of this portfolio. Just adjust
// their role if it changed — no invitation, no email.
if (existingUser) {
const [existingMembership] = await db
.select({ id: portfolioUsers.id, role: portfolioUsers.role })
@ -267,7 +270,6 @@ export async function POST(
)
.limit(1);
let portfolioUserId: bigint;
if (existingMembership) {
if (existingMembership.role !== body.role) {
await db
@ -275,53 +277,24 @@ export async function POST(
.set({ role: body.role })
.where(eq(portfolioUsers.id, existingMembership.id));
}
portfolioUserId = existingMembership.id;
} else {
const [inserted] = await db
.insert(portfolioUsers)
.values({
portfolioId: pId,
userId: existingUser.id,
role: body.role,
})
.returning({ id: portfolioUsers.id });
portfolioUserId = inserted.id;
}
try {
await PortfolioInvitationEmail({
identifier: email,
portfolioName: portfolioRow.name,
inviterName,
linkUrl: `${appOrigin}/portfolio/${pId.toString()}`,
mode: "existing-user",
});
} catch (mailErr) {
console.error("PORTFOLIO_INVITATION_EMAIL_FAILURE", {
email,
error: mailErr instanceof Error ? mailErr.message : String(mailErr),
});
// The membership write succeeded — surface the email failure to the
// client but don't roll back the membership.
}
return NextResponse.json(
{
user: {
portfolioUserId: portfolioUserId.toString(),
userId: existingUser.id.toString(),
role: body.role,
name: existingUser.firstName ?? body.name ?? null,
email,
kind: "member" as const,
return NextResponse.json(
{
user: {
portfolioUserId: existingMembership.id.toString(),
userId: existingUser.id.toString(),
role: body.role,
name: existingUser.firstName ?? body.name ?? null,
email,
kind: "member" as const,
},
},
},
{ status: 200 },
);
{ status: 200 },
);
}
}
// No user with this email yet — record a pending invitation. The signIn
// callback applies it the first time the invitee signs in.
// Standard path (whether or not the user already exists): write a pending
// invitation. The invitee accepts/declines via their in-app dropdown.
const [invitation] = await db
.insert(portfolioInvitations)
.values({
@ -348,7 +321,7 @@ export async function POST(
portfolioName: portfolioRow.name,
inviterName,
linkUrl: appOrigin,
mode: "new-user",
mode: existingUser ? "existing-user" : "new-user",
});
} catch (mailErr) {
console.error("PORTFOLIO_INVITATION_EMAIL_FAILURE", {

View file

@ -0,0 +1,184 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import {
portfolio,
portfolioInvitations,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { normaliseEmail } from "@/app/lib/email";
import { planInvitationApplication } from "@/app/lib/portfolioInvitations";
// GET: list pending portfolio invitations addressed to the current user's
// email, across all portfolios. Used by the profile-menu notifications panel.
export async function GET() {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const email = normaliseEmail(session.user.email);
try {
const rows = await db
.select({
id: portfolioInvitations.id,
portfolioId: portfolioInvitations.portfolioId,
portfolioName: portfolio.name,
role: portfolioInvitations.role,
invitedByName: user.firstName,
invitedByEmail: user.email,
createdAt: portfolioInvitations.createdAt,
})
.from(portfolioInvitations)
.innerJoin(portfolio, eq(portfolio.id, portfolioInvitations.portfolioId))
.leftJoin(user, eq(user.id, portfolioInvitations.invitedByUserId))
.where(eq(portfolioInvitations.email, email));
return NextResponse.json(
{
invitations: rows.map((r) => ({
invitationId: r.id.toString(),
portfolioId: r.portfolioId.toString(),
portfolioName: r.portfolioName,
role: r.role,
invitedByName: r.invitedByName ?? r.invitedByEmail ?? null,
createdAt: r.createdAt.toISOString(),
})),
},
{ status: 200 },
);
} catch (err) {
console.error("GET /user/invitations error:", err);
return NextResponse.json(
{ error: "Failed to fetch invitations" },
{ status: 500 },
);
}
}
// POST: accept or decline an invitation addressed to the current user.
// { invitationId, action: "accept" | "decline" }
//
// Accept: writes the portfolioUsers row (skipped if already a member) and
// deletes the invitation atomically.
// Decline: deletes the invitation. Silent — no inviter notification.
export async function POST(req: NextRequest) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const sessionEmail = normaliseEmail(session.user.email);
const sessionUserId = BigInt(session.user.dbId);
const bodySchema = z.object({
invitationId: z.string(),
action: z.enum(["accept", "decline"]),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const invId = BigInt(body.invitationId);
const [invitation] = await db
.select({
id: portfolioInvitations.id,
portfolioId: portfolioInvitations.portfolioId,
email: portfolioInvitations.email,
role: portfolioInvitations.role,
})
.from(portfolioInvitations)
.where(eq(portfolioInvitations.id, invId))
.limit(1);
if (!invitation) {
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
if (invitation.email !== sessionEmail) {
// Either someone else's invitation or address mismatch — treat as
// not-found so we don't leak existence of other users' invitations.
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
if (body.action === "decline") {
await db
.delete(portfolioInvitations)
.where(eq(portfolioInvitations.id, invId));
console.log("INVITATION_DECLINED", {
email: sessionEmail,
invitationId: body.invitationId,
portfolioId: invitation.portfolioId.toString(),
});
return NextResponse.json(
{ success: true, action: "declined" },
{ status: 200 },
);
}
// Accept: load existing memberships so we don't double-insert, then
// delegate to the shared planning function.
const existing = await db
.select({ portfolioId: portfolioUsers.portfolioId })
.from(portfolioUsers)
.where(eq(portfolioUsers.userId, sessionUserId));
const plan = planInvitationApplication({
userId: sessionUserId,
invitations: [
{
id: invitation.id,
portfolioId: invitation.portfolioId,
role: invitation.role as "creator" | "admin" | "read" | "write",
},
],
existingPortfolioIds: new Set(existing.map((m) => m.portfolioId)),
});
await db.transaction(async (tx) => {
if (plan.memberships.length > 0) {
await tx.insert(portfolioUsers).values(plan.memberships);
}
for (const id of plan.invitationsToDelete) {
await tx
.delete(portfolioInvitations)
.where(eq(portfolioInvitations.id, id));
}
});
console.log("INVITATION_ACCEPTED", {
email: sessionEmail,
invitationId: body.invitationId,
portfolioId: invitation.portfolioId.toString(),
newMembership: plan.memberships.length > 0,
});
return NextResponse.json(
{
success: true,
action: "accepted",
portfolioId: invitation.portfolioId.toString(),
},
{ status: 200 },
);
} catch (err) {
console.error("POST /user/invitations error:", err);
return NextResponse.json(
{ error: "Failed to update invitation" },
{ status: 500 },
);
}
}

View file

@ -1,14 +1,111 @@
"use client";
import { Menu } from "@headlessui/react";
import { signOut } from "next-auth/react";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import Image from "next/image";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/app/hooks/use-toast";
type PendingInvitation = {
invitationId: string;
portfolioId: string;
portfolioName: string;
role: string;
invitedByName: string | null;
createdAt: string;
};
async function fetchPendingInvitations(): Promise<PendingInvitation[]> {
const res = await fetch("/api/user/invitations");
if (!res.ok) throw new Error("Failed to fetch invitations");
const json = await res.json();
const invitations = json?.invitations ?? [];
return Array.isArray(invitations) ? invitations : [];
}
async function respondToInvitation(
invitationId: string,
action: "accept" | "decline",
): Promise<{ portfolioId?: string }> {
const res = await fetch("/api/user/invitations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invitationId, action }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || `Failed to ${action} invitation`);
}
return res.json().catch(() => ({}));
}
const INVITATIONS_KEY = ["userInvitations"] as const;
function ProfileDropDown({ userImage }: { userImage: string }) {
const { data: session } = useSession();
const email = session?.user?.email ?? null;
const isAuthenticated = !!session?.user;
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: invitations = [], isLoading } = useQuery({
queryKey: INVITATIONS_KEY,
queryFn: fetchPendingInvitations,
enabled: isAuthenticated,
refetchOnWindowFocus: false,
});
const pendingCount = invitations.length;
const respondMutation = useMutation({
mutationFn: ({
invitationId,
action,
}: {
invitationId: string;
action: "accept" | "decline";
}) => respondToInvitation(invitationId, action),
onMutate: async ({ invitationId }) => {
await queryClient.cancelQueries({ queryKey: INVITATIONS_KEY });
const previous =
queryClient.getQueryData<PendingInvitation[]>(INVITATIONS_KEY);
queryClient.setQueryData<PendingInvitation[]>(INVITATIONS_KEY, (old) =>
(old ?? []).filter((i) => i.invitationId !== invitationId),
);
return { previous };
},
onError: (err, vars, context) => {
if (context?.previous) {
queryClient.setQueryData(INVITATIONS_KEY, context.previous);
}
toast({
title: `Couldn't ${vars.action} invitation`,
description: err instanceof Error ? err.message : "Please try again.",
variant: "destructive",
});
},
onSuccess: (_data, vars) => {
const inv = invitations.find((i) => i.invitationId === vars.invitationId);
const portfolioLabel = inv ? `the ${inv.portfolioName} portfolio` : "the portfolio";
toast({
title: vars.action === "accept" ? "Joined portfolio" : "Invitation declined",
description:
vars.action === "accept"
? `You now have access to ${portfolioLabel}.`
: `You've declined the invitation to ${portfolioLabel}.`,
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: INVITATIONS_KEY });
},
});
return (
<Menu as="div" className="relative">
<Menu.Button className="rounded-full">
<Menu.Button className="rounded-full relative">
{userImage ? (
<Image
src={userImage}
@ -28,22 +125,120 @@ function ProfileDropDown({ userImage }: { userImage: string }) {
</svg>
</span>
)}
{pendingCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 min-w-[1.25rem] h-5 px-1 inline-flex items-center justify-center rounded-full bg-red-600 text-white text-[10px] font-bold ring-2 ring-brandblue"
aria-label={`${pendingCount} pending invitation${pendingCount === 1 ? "" : "s"}`}
>
{pendingCount > 9 ? "9+" : pendingCount}
</span>
)}
</Menu.Button>
<Menu.Items className="z-[100] absolute right-0 mt-2 w-48 origin-top-right overflow-hidden rounded-md border bg-white shadow-lg focus:outline-none">
<Menu.Items className="z-[100] absolute right-0 mt-2 w-80 origin-top-right overflow-hidden rounded-md border bg-white shadow-lg focus:outline-none">
{/* Signed-in identity */}
{email && (
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-[10px] uppercase tracking-wider text-gray-400 font-medium">
Signed in as
</p>
<p className="text-sm text-gray-800 truncate" title={email}>
{email}
</p>
</div>
)}
{/* Pending invitations */}
{isAuthenticated && (
<div className="border-b border-gray-100">
<p className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-wider text-gray-400 font-medium">
Pending invitations
</p>
{isLoading ? (
<p className="px-4 py-2 text-sm text-gray-500">Loading</p>
) : invitations.length === 0 ? (
<p className="px-4 py-2 text-sm text-gray-400">
No pending invitations.
</p>
) : (
<ul className="max-h-72 overflow-y-auto">
{invitations.map((inv) => (
<li
key={inv.invitationId}
className="px-4 py-3 border-t border-gray-50 first:border-t-0"
>
<p className="text-sm font-medium text-gray-800">
{inv.portfolioName}
</p>
<p className="text-xs text-gray-500 mb-2">
{inv.invitedByName
? `Invited by ${inv.invitedByName}`
: "Invited"}{" "}
· <span className="capitalize">{inv.role}</span>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
respondMutation.mutate({
invitationId: inv.invitationId,
action: "accept",
})
}
disabled={
respondMutation.isPending &&
respondMutation.variables?.invitationId ===
inv.invitationId
}
className="flex-1 px-3 py-1.5 rounded-md bg-brandblue text-white text-xs font-medium hover:bg-hoverblue disabled:opacity-50"
>
Accept
</button>
<button
type="button"
onClick={() =>
respondMutation.mutate({
invitationId: inv.invitationId,
action: "decline",
})
}
disabled={
respondMutation.isPending &&
respondMutation.variables?.invitationId ===
inv.invitationId
}
className="flex-1 px-3 py-1.5 rounded-md border border-gray-200 text-gray-600 text-xs font-medium hover:bg-gray-50 disabled:opacity-50"
>
Decline
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
<Menu.Item>
<Link href="/help" className="flex px-4 py-2 text-sm text-gray-700">
Help
</Link>
{({ active }) => (
<Link
href="/help"
className={`flex px-4 py-2 text-sm text-gray-700 ${active ? "bg-gray-50" : ""}`}
>
Help
</Link>
)}
</Menu.Item>
<Menu.Item>
<a>
{({ active }) => (
<button
className="flex px-4 py-2 text-sm text-gray-700"
type="button"
onClick={() => signOut()}
className={`w-full text-left flex px-4 py-2 text-sm text-gray-700 ${active ? "bg-gray-50" : ""}`}
>
Sign Out
Sign out
</button>
</a>
)}
</Menu.Item>
</Menu.Items>
</Menu>

View file

@ -39,13 +39,8 @@ export async function PortfolioInvitationEmail({
const host = parsed.host;
const logoUrl = `${parsed.origin}/domna-email-logo.png`;
const subject =
mode === "existing-user"
? `You've been added to ${portfolioName} on Ara`
: `${inviterName} invited you to join ${portfolioName} on Ara`;
const ctaLabel =
mode === "existing-user" ? "Open portfolio" : "Sign in to Ara";
const subject = `${inviterName} invited you to join ${portfolioName} on Ara`;
const ctaLabel = "Sign in to Ara";
const result = await transport.sendMail({
to: identifier,
@ -104,15 +99,11 @@ function domnaHtml({
const brown = "#c4a47c";
const background = "#F9F9F9";
const heading =
mode === "existing-user"
? `You've been added to ${portfolioName}`
: `${inviterName} invited you to ${portfolioName}`;
const heading = `${inviterName} invited you to ${portfolioName}`;
const explainer =
mode === "existing-user"
? `${inviterName} added you to the <strong>${portfolioName}</strong> portfolio on Ara. Open it below to start collaborating.`
: `${inviterName} invited you to join the <strong>${portfolioName}</strong> portfolio on Ara. Sign in with this email address to accept the invitation.`;
? `${inviterName} invited you to the <strong>${portfolioName}</strong> portfolio on Ara. Sign in and accept the invitation from your profile menu to start collaborating.`
: `${inviterName} invited you to join the <strong>${portfolioName}</strong> portfolio on Ara. Sign in with this email address to create your account, then accept the invitation from your profile menu.`;
return `
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
@ -159,14 +150,11 @@ function plainText({
ctaLabel: string;
mode: InvitationMode;
}) {
const heading =
mode === "existing-user"
? `You've been added to ${portfolioName}`
: `${inviterName} invited you to ${portfolioName}`;
const heading = `${inviterName} invited you to ${portfolioName}`;
const explainer =
mode === "existing-user"
? `${inviterName} added you to the ${portfolioName} portfolio on Ara.`
: `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email address to accept.`;
? `${inviterName} invited you to the ${portfolioName} portfolio on Ara. Sign in and accept from your profile menu to start collaborating.`
: `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email to create your account, then accept from your profile menu.`;
return `${heading}