Drop the unused invitee name field from the invite flow

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 11:13:59 +00:00
parent 3b63c5ea1a
commit 618a92a06d
3 changed files with 60 additions and 29 deletions

View file

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

View file

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

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>