From d70b15e70549fedd220aa5d04add2fe81e7a9db3 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Thu, 28 May 2026 09:43:13 +0000
Subject: [PATCH 1/6] Unify invitation flow: explicit accept/decline via
profile-menu notifications
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/app/api/auth/[...nextauth]/authOptions.ts | 53 +----
.../[portfolioId]/collaborators/route.ts | 71 ++----
src/app/api/user/invitations/route.ts | 184 +++++++++++++++
src/app/components/ProfileDropDown.tsx | 215 +++++++++++++++++-
.../email_templates/portfolio_invitation.ts | 28 +--
5 files changed, 422 insertions(+), 129 deletions(-)
create mode 100644 src/app/api/user/invitations/route.ts
diff --git a/src/app/api/auth/[...nextauth]/authOptions.ts b/src/app/api/auth/[...nextauth]/authOptions.ts
index 4adeaae..7ca0154 100644
--- a/src/app/api/auth/[...nextauth]/authOptions.ts
+++ b/src/app/api/auth/[...nextauth]/authOptions.ts
@@ -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();
diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
index 53bdb44..41c82c2 100644
--- a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
+++ b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
@@ -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", {
diff --git a/src/app/api/user/invitations/route.ts b/src/app/api/user/invitations/route.ts
new file mode 100644
index 0000000..f82ee0b
--- /dev/null
+++ b/src/app/api/user/invitations/route.ts
@@ -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;
+ 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 },
+ );
+ }
+}
diff --git a/src/app/components/ProfileDropDown.tsx b/src/app/components/ProfileDropDown.tsx
index d4c9b6f..79fc9e8 100644
--- a/src/app/components/ProfileDropDown.tsx
+++ b/src/app/components/ProfileDropDown.tsx
@@ -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 {
+ 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(INVITATIONS_KEY);
+ queryClient.setQueryData(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 (
-
+
{userImage ? (
)}
+ {pendingCount > 0 && (
+
+ {pendingCount > 9 ? "9+" : pendingCount}
+
+ )}
-
+
+
+ {/* Signed-in identity */}
+ {email && (
+
+
+ Signed in as
+
+
+ {email}
+
+
+ )}
+
+ {/* Pending invitations */}
+ {isAuthenticated && (
+
+
+ Pending invitations
+
+ {isLoading ? (
+
Loading…
+ ) : invitations.length === 0 ? (
+
+ No pending invitations.
+
+ ) : (
+
+ {invitations.map((inv) => (
+
+
+ {inv.portfolioName}
+
+
+ {inv.invitedByName
+ ? `Invited by ${inv.invitedByName}`
+ : "Invited"}{" "}
+ · {inv.role}
+
+
+
+ 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
+
+
+ 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
+
+
+
+ ))}
+
+ )}
+
+ )}
+
-
- Help
-
+ {({ active }) => (
+
+ Help
+
+ )}
-
+ {({ active }) => (
signOut()}
+ className={`w-full text-left flex px-4 py-2 text-sm text-gray-700 ${active ? "bg-gray-50" : ""}`}
>
- Sign Out
+ Sign out
-
+ )}
diff --git a/src/app/email_templates/portfolio_invitation.ts b/src/app/email_templates/portfolio_invitation.ts
index 738e7d8..62b3c75 100644
--- a/src/app/email_templates/portfolio_invitation.ts
+++ b/src/app/email_templates/portfolio_invitation.ts
@@ -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 ${portfolioName} portfolio on Ara. Open it below to start collaborating.`
- : `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email address to accept the invitation.`;
+ ? `${inviterName} invited you to the ${portfolioName} portfolio on Ara. Sign in and accept the invitation from your profile menu to start collaborating.`
+ : `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email address to create your account, then accept the invitation from your profile menu.`;
return `
@@ -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}
From 676ad5b10722e22beb2ec29faf9fe6debbdfa73c Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Thu, 28 May 2026 09:49:32 +0000
Subject: [PATCH 2/6] Fix lint errors blocking the Vercel build
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Escape apostrophe in the revoke-invitation ConfirmDialog description
(react/no-unescaped-entities)
- Add role="tab" to the two tab-button arrays in PropertyDetailDrawer
and DealPage so aria-selected is valid for that element
(jsx-a11y/role-supports-aria-props)
The aria-selected warnings were pre-existing in those files but the
build now blocks on warnings as well as errors. The fix is the correct
ARIA pattern — these buttons are real tabs, role="tab" is what
aria-selected expects.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../[slug]/(portfolio)/settings/UsersPermissionsCard.tsx | 2 +-
.../(portfolio)/your-projects/live/PropertyDetailDrawer.tsx | 1 +
.../[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx | 1 +
3 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
index c6a07a5..bf049b6 100644
--- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
@@ -589,7 +589,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
description={
pendingRevoke ? (
<>
- {pendingRevoke.email} won't
+ {pendingRevoke.email} won't
be able to accept this invitation. You can invite them again
later.
>
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx
index 9295690..789ee4d 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx
@@ -122,6 +122,7 @@ export default function PropertyDetailDrawer({
{TABS.map((tab) => (
setActiveTab(tab)}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx
index 7d55cf7..95c72fa 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx
@@ -220,6 +220,7 @@ export default function DealPage({
{VALID_TABS.map((tab) => (
switchTab(tab)}
From 3b63c5ea1a1fbf802c9f829d223802e66fb94d3f Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Thu, 28 May 2026 10:55:34 +0000
Subject: [PATCH 3/6] Confirm invitation accept with a dialog + refresh
portfolios list
Replaces the auto-dismiss toast on accept with a shadcn Dialog that
names the portfolio and offers a "Go to {portfolioName}" CTA. Decline
keeps the existing toast. router.refresh() updates /home in place, and
revalidatePath("/home") in the API handler guarantees the server-rendered
portfolio list is fresh on any later navigation.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/app/api/user/invitations/route.ts | 6 ++
src/app/components/ProfileDropDown.tsx | 83 +++++++++++++++++++++++---
2 files changed, 80 insertions(+), 9 deletions(-)
diff --git a/src/app/api/user/invitations/route.ts b/src/app/api/user/invitations/route.ts
index f82ee0b..146ccc2 100644
--- a/src/app/api/user/invitations/route.ts
+++ b/src/app/api/user/invitations/route.ts
@@ -1,5 +1,6 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
+import { revalidatePath } from "next/cache";
import {
portfolio,
portfolioInvitations,
@@ -166,6 +167,11 @@ export async function POST(req: NextRequest) {
newMembership: plan.memberships.length > 0,
});
+ // /home renders the user's portfolio list from the DB in a server
+ // component; invalidate so the next navigation there picks up the new
+ // membership. router.refresh() handles the in-place case client-side.
+ revalidatePath("/home");
+
return NextResponse.json(
{
success: true,
diff --git a/src/app/components/ProfileDropDown.tsx b/src/app/components/ProfileDropDown.tsx
index 79fc9e8..24764ad 100644
--- a/src/app/components/ProfileDropDown.tsx
+++ b/src/app/components/ProfileDropDown.tsx
@@ -1,11 +1,22 @@
"use client";
+import { useState } from "react";
import { Menu } from "@headlessui/react";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import Image from "next/image";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/app/hooks/use-toast";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/app/shadcn_components/ui/dialog";
+import { Button } from "@/app/shadcn_components/ui/button";
type PendingInvitation = {
invitationId: string;
@@ -49,6 +60,11 @@ function ProfileDropDown({ userImage }: { userImage: string }) {
const queryClient = useQueryClient();
const { toast } = useToast();
+ const router = useRouter();
+ const [acceptedInfo, setAcceptedInfo] = useState<{
+ portfolioId: string;
+ portfolioName: string;
+ } | null>(null);
const { data: invitations = [], isLoading } = useQuery({
queryKey: INVITATIONS_KEY,
@@ -87,16 +103,30 @@ function ProfileDropDown({ userImage }: { userImage: string }) {
variant: "destructive",
});
},
- onSuccess: (_data, vars) => {
+ 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}.`,
- });
+ if (vars.action === "accept") {
+ const portfolioId = data?.portfolioId ?? inv?.portfolioId;
+ const portfolioName = inv?.portfolioName ?? "the portfolio";
+ // /home's server-rendered portfolio list won't pick up the new
+ // membership without an explicit refresh; the API handler also calls
+ // revalidatePath("/home") so any later navigation is fresh too.
+ router.refresh();
+ if (portfolioId) {
+ setAcceptedInfo({ portfolioId, portfolioName });
+ } else {
+ toast({
+ title: "Joined portfolio",
+ description: `You now have access to ${portfolioName}.`,
+ });
+ }
+ } else {
+ const portfolioLabel = inv ? `the ${inv.portfolioName} portfolio` : "the portfolio";
+ toast({
+ title: "Invitation declined",
+ description: `You've declined the invitation to ${portfolioLabel}.`,
+ });
+ }
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: INVITATIONS_KEY });
@@ -104,6 +134,7 @@ function ProfileDropDown({ userImage }: { userImage: string }) {
});
return (
+ <>
{userImage ? (
@@ -242,6 +273,40 @@ function ProfileDropDown({ userImage }: { userImage: string }) {
+
+ {
+ if (!open) setAcceptedInfo(null);
+ }}
+ >
+
+
+
+ You've joined {acceptedInfo?.portfolioName}
+
+
+ You now have access. Head over when you're ready.
+
+
+
+ setAcceptedInfo(null)}>
+ Close
+
+ {
+ if (acceptedInfo) {
+ router.push(`/portfolio/${acceptedInfo.portfolioId}`);
+ }
+ setAcceptedInfo(null);
+ }}
+ >
+ Go to {acceptedInfo?.portfolioName ?? "portfolio"}
+
+
+
+
+ >
);
}
From 618a92a06d8dda176e50fa5ba9afe4ac85b0f681 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Thu, 28 May 2026 11:13:59 +0000
Subject: [PATCH 4/6] Drop the unused invitee name field from the invite flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Full name input on the user-access card was never persisted (no
column on portfolio_invitations), never used in the invitation email
(only the inviter's name appears there), and never displayed in the
pending-invitations table — purely dead weight on the form. Removing
the input, the request-body field, and the response-payload reference.
Extracts the POST body schema to a named inviteRequestSchema export so
the contract change is locked in by a unit test rather than left implicit.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../[portfolioId]/collaborators/route.test.ts | 46 +++++++++++++++++++
.../[portfolioId]/collaborators/route.ts | 21 +++++----
.../settings/UsersPermissionsCard.tsx | 22 ++-------
3 files changed, 60 insertions(+), 29 deletions(-)
create mode 100644 src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts
diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts b/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts
new file mode 100644
index 0000000..7b855f3
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+
+import { inviteRequestSchema } from "./route";
+
+describe("inviteRequestSchema", () => {
+ it("accepts an invite request with just email and role (no name)", () => {
+ const result = inviteRequestSchema.parse({
+ email: "alice@example.com",
+ role: "read",
+ });
+
+ expect(result).toEqual({
+ email: "alice@example.com",
+ role: "read",
+ });
+ });
+
+ it("silently drops an unknown name field (in-flight clients still parse)", () => {
+ const result = inviteRequestSchema.parse({
+ email: "alice@example.com",
+ role: "write",
+ name: "Alice",
+ });
+
+ expect(result).toEqual({
+ email: "alice@example.com",
+ role: "write",
+ });
+ expect(result).not.toHaveProperty("name");
+ });
+
+ it("rejects an invite request with a malformed email", () => {
+ expect(() =>
+ inviteRequestSchema.parse({ email: "not-an-email", role: "read" }),
+ ).toThrow();
+ });
+
+ it("rejects an invite request with a role outside the enum", () => {
+ expect(() =>
+ inviteRequestSchema.parse({
+ email: "alice@example.com",
+ role: "superuser",
+ }),
+ ).toThrow();
+ });
+});
diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
index 41c82c2..8fa8ca8 100644
--- a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
+++ b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
@@ -192,6 +192,13 @@ export async function PUT(
}
}
+// Body schema for the invite endpoint. Exported so the contract can be
+// unit-tested directly — see route.test.ts.
+export const inviteRequestSchema = z.object({
+ email: z.string().email(),
+ role: z.enum(ROLE_OPTIONS),
+});
+
// POST: invite a user by email.
//
// Unified flow: in nearly every case we write a pending portfolio_invitations
@@ -204,15 +211,9 @@ export async function POST(
) {
const { portfolioId } = await props.params;
- const bodySchema = z.object({
- email: z.string().email(),
- role: z.enum(ROLE_OPTIONS),
- name: z.string(),
- });
-
- let body: z.infer;
+ let body: z.infer;
try {
- body = bodySchema.parse(await req.json());
+ body = inviteRequestSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
@@ -283,7 +284,7 @@ export async function POST(
portfolioUserId: existingMembership.id.toString(),
userId: existingUser.id.toString(),
role: body.role,
- name: existingUser.firstName ?? body.name ?? null,
+ name: existingUser.firstName ?? null,
email,
kind: "member" as const,
},
@@ -337,7 +338,7 @@ export async function POST(
userId: null,
invitationId: invitation.id.toString(),
role: invitation.role,
- name: body.name ?? null,
+ name: null,
email,
kind: "invitation" as const,
},
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
index bf049b6..d397370 100644
--- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
@@ -77,12 +77,11 @@ async function invitePortfolioUser(
portfolioId: string,
email: string,
role: Role,
- name: string,
): Promise {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, role, name }),
+ body: JSON.stringify({ email, role }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
@@ -123,7 +122,6 @@ async function revokePortfolioInvitation(
export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("read");
- const [inviteName, setInviteName] = useState("");
const [pendingRemoval, setPendingRemoval] =
useState<{ portfolioUserId: string; email: string } | null>(null);
const [pendingRevoke, setPendingRevoke] =
@@ -210,16 +208,13 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
mutationFn: ({
email,
role,
- name,
}: {
email: string;
role: Role;
- name: string;
- }) => invitePortfolioUser(portfolioId, email, role, name),
+ }) => invitePortfolioUser(portfolioId, email, role),
onSuccess: (_data, vars) => {
invalidateBoth();
setInviteEmail("");
- setInviteName("");
toast({
title: "Invitation sent",
description: `We've emailed ${vars.email} an invitation to this portfolio.`,
@@ -324,7 +319,6 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
inviteUserMutation.mutate({
email: inviteEmail,
role: inviteRole,
- name: inviteName,
});
}
@@ -380,12 +374,6 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
- setInviteName(e.target.value)}
- />
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
From f26156ddb5501a3ba32d9bbcf02c799ae4845c8b Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Thu, 28 May 2026 11:17:21 +0000
Subject: [PATCH 5/6] added x margin
---
.../[slug]/components/PropertyTable.tsx | 22 +++++++++++++------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx
index 5e34583..e12550e 100644
--- a/src/app/portfolio/[slug]/components/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx
@@ -146,8 +146,8 @@ function EmptyPropertyState() {
- Hover over “New Property” to start adding properties
- to your portfolio.
+ Hover over “New Property” to start adding
+ properties to your portfolio.
@@ -388,7 +388,10 @@ export default function PropertyTable({
filterGroups: allFilterGroups,
});
- const queryData = useMemo(() => filteredResponse?.data ?? [], [filteredResponse?.data]);
+ const queryData = useMemo(
+ () => filteredResponse?.data ?? [],
+ [filteredResponse?.data],
+ );
const filteredTotal = filteredResponse?.total ?? 0;
// Second query for total (no filters) — React Query dedupes when filters are empty
@@ -500,7 +503,7 @@ export default function PropertyTable({
const [previewError] = useState(null);
return (
-
+
{/* Action bar */}
{/* Left: results count */}
@@ -594,14 +597,18 @@ export default function PropertyTable({
{/* Export */}
{filteredTotal > EXPORT_LIMIT ? (
-
+
Export
- !
+
+ !
+
) : (
@@ -680,7 +687,8 @@ export default function PropertyTable({
{filteredTotal.toLocaleString()}
{" "}
- properties — more load automatically as you navigate to the last page.
+ properties — more load automatically as you navigate to the last
+ page.
)}
From 7ced6c08188455ab26fe4629d084d40b0d0f69ca Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Thu, 28 May 2026 11:29:54 +0000
Subject: [PATCH 6/6] Move inviteRequestSchema out of route.ts to satisfy Next
15 build
Next.js 15 enforces a strict allowlist of named exports from route.ts
files; "inviteRequestSchema" was rejected by the route-validator with
"is not a valid Route export field" during npm run build. Splitting the
schema into a sibling module (which is exempt from the allowlist) and
importing it from route.ts and the test. Renames the test file to
match its target module.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../{route.test.ts => inviteRequestSchema.test.ts} | 2 +-
.../[portfolioId]/collaborators/inviteRequestSchema.ts | 10 ++++++++++
.../api/portfolio/[portfolioId]/collaborators/route.ts | 8 +-------
3 files changed, 12 insertions(+), 8 deletions(-)
rename src/app/api/portfolio/[portfolioId]/collaborators/{route.test.ts => inviteRequestSchema.test.ts} (94%)
create mode 100644 src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.ts
diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts b/src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.test.ts
similarity index 94%
rename from src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts
rename to src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.test.ts
index 7b855f3..e332c55 100644
--- a/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts
+++ b/src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { inviteRequestSchema } from "./route";
+import { inviteRequestSchema } from "./inviteRequestSchema";
describe("inviteRequestSchema", () => {
it("accepts an invite request with just email and role (no name)", () => {
diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.ts b/src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.ts
new file mode 100644
index 0000000..12526c6
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
+
+// Body schema for POST /api/portfolio/[portfolioId]/collaborators. Lives in
+// its own file because Next.js 15 rejects non-handler named exports from
+// route.ts. See route.test.ts for the contract tests.
+export const inviteRequestSchema = z.object({
+ email: z.string().email(),
+ role: z.enum(ROLE_OPTIONS),
+});
diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
index 8fa8ca8..4db4432 100644
--- a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
+++ b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts
@@ -29,6 +29,7 @@ import {
denyIfNotAdmin,
resolvePortfolioPrivilege,
} from "@/app/lib/resolvePortfolioPrivilege";
+import { inviteRequestSchema } from "./inviteRequestSchema";
// Get collaborators (users) that have access to the portfolio, plus the
// effective privilege of the requesting user (so the UI knows which actions
@@ -192,13 +193,6 @@ export async function PUT(
}
}
-// Body schema for the invite endpoint. Exported so the contract can be
-// unit-tested directly — see route.test.ts.
-export const inviteRequestSchema = z.object({
- email: z.string().email(),
- role: z.enum(ROLE_OPTIONS),
-});
-
// POST: invite a user by email.
//
// Unified flow: in nearly every case we write a pending portfolio_invitations