Merge pull request #287 from Hestia-Homes/bug/portfolio-invitations

Bug/portfolio invitations
This commit is contained in:
KhalimCK 2026-05-28 14:01:55 +01:00 committed by GitHub
commit ec10a53aeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 574 additions and 165 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

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { inviteRequestSchema } from "./inviteRequestSchema";
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();
});
});

View file

@ -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),
});

View file

@ -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
@ -194,24 +195,19 @@ 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 }> }
) {
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<typeof bodySchema>;
let body: z.infer<typeof inviteRequestSchema>;
try {
body = bodySchema.parse(await req.json());
body = inviteRequestSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
@ -255,6 +251,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 +265,6 @@ export async function POST(
)
.limit(1);
let portfolioUserId: bigint;
if (existingMembership) {
if (existingMembership.role !== body.role) {
await db
@ -275,53 +272,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 ?? 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 +316,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", {
@ -364,7 +332,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,
},

View file

@ -0,0 +1,190 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
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,
});
// /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,
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,142 @@
"use client";
import { useState } from "react";
import { Menu } from "@headlessui/react";
import { signOut } from "next-auth/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;
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 router = useRouter();
const [acceptedInfo, setAcceptedInfo] = useState<{
portfolioId: string;
portfolioName: string;
} | null>(null);
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);
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 });
},
});
return (
<>
<Menu as="div" className="relative">
<Menu.Button className="rounded-full">
<Menu.Button className="rounded-full relative">
{userImage ? (
<Image
src={userImage}
@ -28,25 +156,157 @@ 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>
<Dialog
open={!!acceptedInfo}
onOpenChange={(open) => {
if (!open) setAcceptedInfo(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
You&apos;ve joined {acceptedInfo?.portfolioName}
</DialogTitle>
<DialogDescription>
You now have access. Head over when you&apos;re ready.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setAcceptedInfo(null)}>
Close
</Button>
<Button
onClick={() => {
if (acceptedInfo) {
router.push(`/portfolio/${acceptedInfo.portfolioId}`);
}
setAcceptedInfo(null);
}}
>
Go to {acceptedInfo?.portfolioName ?? "portfolio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

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}

View file

@ -77,12 +77,11 @@ async function invitePortfolioUser(
portfolioId: string,
email: string,
role: Role,
name: string,
): Promise<void> {
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<Role>("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 }) {
</p>
</TableHead>
<TableCell className="flex gap-2 items-center">
<Input
type="text"
placeholder="Full name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<Input
type="email"
placeholder="email@example.com"
@ -404,11 +392,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<Button
className="w-28"
onClick={handleInvite}
disabled={
!inviteEmail ||
!inviteName ||
inviteUserMutation.isPending
}
disabled={!inviteEmail || inviteUserMutation.isPending}
>
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
</Button>
@ -589,7 +573,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
description={
pendingRevoke ? (
<>
<span className="font-medium">{pendingRevoke.email}</span> won't
<span className="font-medium">{pendingRevoke.email}</span> won&apos;t
be able to accept this invitation. You can invite them again
later.
</>

View file

@ -122,6 +122,7 @@ export default function PropertyDetailDrawer({
{TABS.map((tab) => (
<button
key={tab}
role="tab"
data-testid={`drawer-tab-${tab}`}
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}

View file

@ -220,6 +220,7 @@ export default function DealPage({
{VALID_TABS.map((tab) => (
<button
key={tab}
role="tab"
data-testid={`deal-page-tab-${tab}`}
aria-selected={activeTab === tab}
onClick={() => switchTab(tab)}

View file

@ -146,8 +146,8 @@ function EmptyPropertyState() {
<div className="text-center text-gray-400">
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
<p>
Hover over <strong>&ldquo;New Property&rdquo;</strong> to start adding properties
to your portfolio.
Hover over <strong>&ldquo;New Property&rdquo;</strong> to start adding
properties to your portfolio.
</p>
</div>
</div>
@ -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<string | null>(null);
return (
<div className="py-4">
<div className="py-4 mx-4">
{/* Action bar */}
<div className="flex items-center justify-between mb-3">
{/* Left: results count */}
@ -594,14 +597,18 @@ export default function PropertyTable({
{/* Export */}
{filteredTotal > EXPORT_LIMIT ? (
<Tooltip content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
<Tooltip
content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}
>
<button
disabled
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">!</span>
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">
!
</span>
</button>
</Tooltip>
) : (
@ -680,7 +687,8 @@ export default function PropertyTable({
<span className="font-semibold text-primary">
{filteredTotal.toLocaleString()}
</span>{" "}
properties more load automatically as you navigate to the last page.
properties more load automatically as you navigate to the last
page.
</span>
</div>
)}