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 ( + <> - + {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} +

    +
    + + +
    +
  • + ))} +
+ )} +
+ )} + - - Help - + {({ active }) => ( + + Help + + )} - + {({ active }) => ( - + )}
+ + { + if (!open) setAcceptedInfo(null); + }} + > + + + + You've joined {acceptedInfo?.portfolioName} + + + You now have access. Head over when you're ready. + + + + + + + + + ); } 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) => ( ) : ( @@ -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. )}