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/inviteRequestSchema.test.ts b/src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.test.ts
new file mode 100644
index 0000000..e332c55
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/collaborators/inviteRequestSchema.test.ts
@@ -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();
+ });
+});
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 53bdb44..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
@@ -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;
+ 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 });
}
@@ -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,
},
diff --git a/src/app/api/user/invitations/route.ts b/src/app/api/user/invitations/route.ts
new file mode 100644
index 0000000..146ccc2
--- /dev/null
+++ b/src/app/api/user/invitations/route.ts
@@ -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;
+ 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 },
+ );
+ }
+}
diff --git a/src/app/components/ProfileDropDown.tsx b/src/app/components/ProfileDropDown.tsx
index d4c9b6f..24764ad 100644
--- a/src/app/components/ProfileDropDown.tsx
+++ b/src/app/components/ProfileDropDown.tsx
@@ -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 {
+ 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(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);
+ 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 (
+ <>
+
+
+ >
);
}
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}
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
index c6a07a5..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"}
@@ -589,7 +573,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) => (