diff --git a/app/[locale]/invite/[token]/page.tsx b/app/[locale]/invite/[token]/page.tsx index 0faf76d08..1036efd0b 100644 --- a/app/[locale]/invite/[token]/page.tsx +++ b/app/[locale]/invite/[token]/page.tsx @@ -4,12 +4,18 @@ import { AcceptInviteCard } from "@/components/invite/AcceptInviteCard"; import { getSession } from "@/lib/auth"; import { db } from "@/lib/db"; import { inviteRedirectPath } from "@/lib/organization/invite-redirect"; -import { company, companyInvite } from "@/schema"; +import { inviteLinkState } from "@/lib/organization/invite-state"; +import { asMembershipRole } from "@/lib/organization/membership"; +import { companyInvite } from "@/schema"; +import { invitedRoleOf } from "@/server/trpc/helpers/company-invite"; interface Props { params: Promise<{ token: string }>; } +// Open without a session, and the token in its address is the credential: never a search result. +export const metadata = { robots: { index: false, follow: false } }; + export default async function InvitePage({ params }: Props) { const { token } = await params; const t = await getTranslations("team"); @@ -22,30 +28,34 @@ export default async function InvitePage({ params }: Props) { }, }); - if (!invite) { - return ( - - ); - } - - if (invite.status !== "pending") { - return ; - } - - if (invite.expiresAt < new Date()) { - return ( - - ); + const state = inviteLinkState(invite, new Date()); + if (!invite || state !== "open") { + const [title, description] = + state === "accepted" + ? // No company name: a used link stays readable long after, by whoever holds it. + [t("acceptedTitle"), t("acceptedDescription")] + : state === "revoked" + ? [t("revokedTitle"), t("revokedDescription")] + : state === "expired" + ? [t("expiredTitle"), t("expiredDescription")] + : [t("invalidTitle"), t("invalidDescription")]; + return ; } const session = await getSession(); + // The compliance role the person was invited for names the invite better than the access role, + // which is "member" for every invite sent today. + const complianceRole = invitedRoleOf(invite.assignmentContext); + const roleLabel = complianceRole + ? t(`roles.${complianceRole}`) + : t(`memberRole.${asMembershipRole(invite.role) ?? "member"}`); return ( (() => searchParams.get("mode") === "register" ? "register" : "login", ); - const [email, setEmail] = useState(""); + // An invite link hands on the address it was sent to (AcceptInviteCard): an account under any + // other address could not accept it. Only a starting value; the field stays editable. + const [email, setEmail] = useState(() => searchParams.get("email")?.trim() ?? ""); const [password, setPassword] = useState(""); const [code, setCode] = useState(""); const [error, setError] = useState(() => { diff --git a/components/invite/AcceptInviteCard.tsx b/components/invite/AcceptInviteCard.tsx index b64193a2f..95fa6c140 100644 --- a/components/invite/AcceptInviteCard.tsx +++ b/components/invite/AcceptInviteCard.tsx @@ -21,7 +21,8 @@ interface Props { token: string; inviteEmail: string; companyName: string; - role: string; + /** The role the person is invited as, already in the reader's language. */ + roleLabel: string; /** Already checked to be a same-origin path (lib/organization/invite-redirect.ts). */ redirectPath: string; userEmail: string | null; @@ -32,7 +33,7 @@ export function AcceptInviteCard({ token, inviteEmail, companyName, - role, + roleLabel, redirectPath, userEmail, isSignedIn, @@ -59,28 +60,38 @@ export function AcceptInviteCard({ acceptMutation.mutate({ token }); }; - // Case 1: Not signed in. Route to the standard signin page, which exposes - // both email/password and Google. The callbackUrl returns the user to - // /invite/[token] so the invite is auto-resolved after auth. + // Case 1: Not signed in. Most people opening an invite have no account yet, so creating one + // comes first, with the invited address filled in: an account under any other address cannot + // accept it. The callbackUrl returns the person to /invite/[token] after either way in. if (!isSignedIn) { - const callbackUrl = `/invite/${token}`; + const query = { callbackUrl: `/invite/${token}`, email: inviteEmail }; return ( - +

+ {t("accept.forAddress", { email: inviteEmail })} +

+
+ + +
); } @@ -110,7 +121,7 @@ export function AcceptInviteCard({ return ( + )} + + + + + ); + })} + + + + ); +} + +interface SentInvite { + readonly email: string; + readonly url: string; + readonly emailed: boolean; +} + +export function InviteForm() { + const router = useRouter(); + const t = useTranslations("team"); + const tc = useTranslations("common"); + const [email, setEmail] = useState(""); + const [complianceRole, setComplianceRole] = useState(); + const [sent, setSent] = useState(null); + const [copied, setCopied] = useState(false); + + const inviteMutation = trpc.team.invite.useMutation({ + onSuccess: (data, variables) => { + setSent({ email: variables.email, url: data.inviteUrl, emailed: data.emailed }); + setEmail(""); + setComplianceRole(undefined); + router.refresh(); + }, + onError: (err) => toast.error(userFacingError(err, tc("actionFailed"))), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!email.trim()) return; + setSent(null); + inviteMutation.mutate({ + email: email.trim(), + complianceRole, + }); + }; + + const handleCopy = () => { + if (!sent) return; + navigator.clipboard.writeText(sent.url); + setCopied(true); + toast.success(t("invites.linkCopied")); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + {t("invite.title")} + {t("invite.description")} + + +
+
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + +
+
+ +
+ + {sent && ( +
+

+ {sent.emailed + ? t("invite.sent", { email: sent.email }) + : t("invite.notEmailed", { email: sent.email })} +

+
+ {sent.url} + +
+
+ )} +
+
+ ); +} diff --git a/components/team/TeamPage.tsx b/components/team/TeamPage.tsx index 2238b3cb6..89d96b551 100644 --- a/components/team/TeamPage.tsx +++ b/components/team/TeamPage.tsx @@ -1,9 +1,19 @@ "use client"; -import { Check, Copy, UserMinus, UserPlus, X } from "lucide-react"; +import { UserMinus } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -13,8 +23,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -22,20 +30,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { useRouter } from "@/i18n/navigation"; import { ALL_ROLE_KEYS, type RoleKey } from "@/lib/compliance/role-keys"; import { ROLE_HIERARCHY } from "@/lib/compliance/role-mapping"; import type { MembershipRole } from "@/lib/organization/membership"; import { trpc } from "@/lib/trpc/client"; import { userFacingError } from "@/lib/trpc/error-message"; +import { type Invite, InviteForm, InvitesCard } from "./TeamInvites"; const MEMBERSHIP_ROLES: readonly MembershipRole[] = [ "admin", @@ -60,16 +61,6 @@ interface Member { assignments: CategoryInfo[]; } -interface Invite { - id: string; - email: string; - token: string; - role: string; - status: string; - expiresAt: Date; - createdAt: Date; -} - interface Props { members: Member[]; invites: Invite[]; @@ -87,9 +78,13 @@ export function TeamPage({ members, invites, currentUserId, isAdmin }: Props) { ); } -// --------------------------------------------------------------------------- -// Members -// --------------------------------------------------------------------------- +/* + * Columns only where the card is wide enough for all of them; narrower, each member stacks. The + * list never scrolls sideways, so the remove action is always on screen. Measured on the card, not + * the window, because the portal sidebar takes a varying share of the window. + */ +const ADMIN_COLUMNS = "@3xl:grid-cols-[minmax(0,1fr)_9rem_14rem_7rem]"; +const MEMBER_COLUMNS = "@3xl:grid-cols-[minmax(0,1fr)_9rem]"; function MembersCard({ members, @@ -103,6 +98,9 @@ function MembersCard({ const router = useRouter(); const t = useTranslations("team"); const tc = useTranslations("common"); + // Kept after the dialog closes, so its text does not empty out while it fades. + const [target, setTarget] = useState(null); + const [confirming, setConfirming] = useState(false); // Sort by role hierarchy (users with compliance roles first, then unassigned) const sortedMembers = [...members].sort((a, b) => { @@ -148,8 +146,10 @@ function MembersCard({ onError: (err) => toast.error(userFacingError(err, tc("actionFailed"))), }); + const columns = isAdmin ? ADMIN_COLUMNS : MEMBER_COLUMNS; + return ( - + {t("members.title")} @@ -157,306 +157,180 @@ function MembersCard({ - - - - {t("members.name")} - {t("members.email")} - {t("members.role")} - {t("members.assignedCategories")} - {isAdmin && {t("members.complianceRole")}} - {isAdmin && } - - - - {sortedMembers.map((m) => ( - - -
- {m.name} - {m.jobTitle && ( -

- {t(`roles.${m.jobTitle}`, { defaultMessage: m.jobTitle })} -

- )} -
-
- {m.email} - - {isAdmin ? ( - + setMemberRoleMutation.mutate({ + userId: m.id, + role: role as MembershipRole, + }) + } + disabled={ + setMemberRoleMutation.isPending || + m.isOwner || + (m.role === "admin" && adminCount <= 1) + } + > + - - - - - {MEMBERSHIP_ROLES.map((role) => ( - - {t(`memberRole.${role}`)} - - ))} - - - ) : ( - - {t(`memberRole.${m.role}`)} - - )} - {m.isOwner && ( -

- {t("memberRole.owner")} -

- )} -
- - {m.assignments.length > 0 ? ( -
- {m.assignments.map((a) => ( - - {a.categoryCode} - + + + + {MEMBERSHIP_ROLES.map((role) => ( + + {t(`memberRole.${role}`)} + ))} -
- ) : ( - — - )} -
- {isAdmin && ( - - - - )} - {isAdmin && ( - - {m.id === currentUserId ? ( - - {t("members.you")} - - ) : m.isOwner ? null : ( - - )} - + + + ) : ( + + {t(`memberRole.${m.role}`)} + )} -
- ))} -
-
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Pending Invites -// --------------------------------------------------------------------------- - -function InvitesCard({ invites }: { invites: Invite[] }) { - const router = useRouter(); - const t = useTranslations("team"); - const tc = useTranslations("common"); - - const revokeMutation = trpc.team.revokeInvite.useMutation({ - onSuccess: () => { - toast.success(t("revoke.success")); - router.refresh(); - }, - onError: (err) => toast.error(userFacingError(err, tc("actionFailed"))), - }); + - if (invites.length === 0) return null; + {isAdmin && ( +
+

+ {t("members.complianceRole")} +

+ +
+ )} - return ( - - - {t("invites.title")} - - {t("invites.description", { count: invites.length })} - - - - - - - {t("invites.email")} - {t("invites.expires")} - - - - - {invites.map((inv) => ( - - {inv.email} - - {new Date(inv.expiresAt).toLocaleDateString()} - - -
+ {isAdmin && ( +
+ {m.id === currentUserId ? ( + + {t("members.you")} + + ) : m.isOwner ? null : ( - -
- - - ))} - -
+ )} + + )} + + ))} +
+ + + + + + {t("remove.title", { name: target?.name ?? "" })} + + + {t("remove.description", { name: target?.name ?? "" })} + + + + {tc("cancel")} + { + if (target) removeMutation.mutate({ userId: target.id }); + }} + > + {t("remove.confirm")} + + + +
); } -// --------------------------------------------------------------------------- -// Invite Form -// --------------------------------------------------------------------------- - -function InviteForm() { - const router = useRouter(); +function MemberIdentity({ member }: { member: Member }) { const t = useTranslations("team"); - const tc = useTranslations("common"); - const [email, setEmail] = useState(""); - const [complianceRole, setComplianceRole] = useState(); - const [inviteUrl, setInviteUrl] = useState(null); - const [copied, setCopied] = useState(false); - - const inviteMutation = trpc.team.invite.useMutation({ - onSuccess: (data) => { - setInviteUrl(data.inviteUrl); - setEmail(""); - setComplianceRole(undefined); - router.refresh(); - }, - onError: (err) => toast.error(userFacingError(err, tc("actionFailed"))), - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!email.trim()) return; - setInviteUrl(null); - inviteMutation.mutate({ - email: email.trim(), - complianceRole, - }); - }; - - const handleCopy = () => { - if (!inviteUrl) return; - navigator.clipboard.writeText(inviteUrl); - setCopied(true); - toast.success(t("invites.linkCopied")); - setTimeout(() => setCopied(false), 2000); - }; - return ( - - - {t("invite.title")} - {t("invite.description")} - - -
-
-
- - setEmail(e.target.value)} - required - /> -
-
- - -
-
- -
- - {inviteUrl && ( -
- {inviteUrl} - -
- )} -
-
+
+

{member.name}

+ {member.jobTitle && ( +

+ {t(`roles.${member.jobTitle}`, { defaultMessage: member.jobTitle })} +

+ )} +

{member.email}

+ {member.isOwner && ( +

{t("memberRole.owner")}

+ )} + {member.assignments.length > 0 && ( +
+ {member.assignments.map((a) => ( + + {a.categoryCode} + + ))} +
+ )} +
); } diff --git a/e2e/l3/team-invites.spec.ts b/e2e/l3/team-invites.spec.ts new file mode 100644 index 000000000..5fc3c7dfa --- /dev/null +++ b/e2e/l3/team-invites.spec.ts @@ -0,0 +1,292 @@ +/** + * L3: the team page and the invite flow, as the people using them meet it. + * + * Before 26.10.2026 a colleague clicking the mail met a bare login form (the invite page sat + * behind sign-in), an expired invite was listed as pending with no way to send it again, and + * inviting the same address again killed the link in the first mail. On the team page the members + * table scrolled sideways inside the portal, so the remove button was off screen, and removing + * took one click with no confirmation. + * + * The e2e stack has no mail transport, so every invite here is created but not mailed, and the + * page has to say so. + */ +import { expect, type Page, test } from "@playwright/test"; +import bcrypt from "bcryptjs"; +import { e2eQuery } from "../lib/db"; +import { assertE2eTargets, E2E_USER_EMAIL } from "../lib/env"; + +const RUN = Date.now().toString(36); + +async function companyId(): Promise { + const [row] = await e2eQuery<{ company_id: string }>( + `SELECT company_id FROM "user" WHERE email = $1`, + [E2E_USER_EMAIL], + ); + expect(row?.company_id, "the e2e admin has an open company").toBeTruthy(); + return row.company_id; +} + +async function inviteRow(email: string) { + const [row] = await e2eQuery<{ token: string; status: string; expires_at: Date }>( + `SELECT token, status, expires_at FROM company_invite WHERE email = $1`, + [email], + ); + return row; +} + +async function openTeam(page: Page) { + await page.goto("/en/team"); + await expect(page.getByTestId("team-members")).toBeVisible({ timeout: 30_000 }); +} + +async function inviteViaForm(page: Page, email: string) { + await page.getByLabel("Email", { exact: true }).fill(email); + await page.getByRole("button", { name: "Invite", exact: true }).click(); + await expect(page.getByTestId("invite-result")).toBeVisible(); +} + +test.describe("team page", () => { + test("the member list fits beside the sidebar, and removing asks first", async ({ + page, + }) => { + const company = await companyId(); + const email = `e2e-removable-${RUN}@nis2.local`; + const [member] = await e2eQuery<{ id: string }>( + `INSERT INTO "user" (company_id, email, name, email_verified_at) + VALUES ($1, $2, 'Removable Colleague', NOW()) RETURNING id`, + [company, email], + ); + await e2eQuery( + `INSERT INTO company_membership (user_id, company_id, role, job_title) + VALUES ($1, $2, 'member', 'cto')`, + [member.id, company], + ); + + // A 13-inch laptop with the sidebar open, and a phone. + for (const viewport of [ + { width: 1280, height: 800 }, + { width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await openTeam(page); + const row = page.locator(`[data-testid="team-member"][data-email="${email}"]`); + const remove = row.getByRole("button", { name: "Remove" }); + await remove.scrollIntoViewIfNeeded(); + await expect(remove).toBeInViewport(); + // The page, and everything from the member list up to it, fits its own width. + const sideways = await page.getByTestId("team-members").evaluate((list) => { + const chain: Element[] = []; + for (let el: Element | null = list; el; el = el.parentElement) chain.push(el); + return chain.some((el) => el.scrollWidth > el.clientWidth + 1); + }); + expect(sideways, `nothing scrolls sideways at ${viewport.width}px`).toBe(false); + } + + await page.setViewportSize({ width: 1280, height: 800 }); + await openTeam(page); + const row = page.locator(`[data-testid="team-member"][data-email="${email}"]`); + + await row.getByRole("button", { name: "Remove" }).click(); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toContainText("Remove Removable Colleague from the team?"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toBeHidden(); + const stillThere = await e2eQuery( + `SELECT 1 FROM company_membership WHERE user_id = $1 AND company_id = $2`, + [member.id, company], + ); + expect(stillThere, "cancel removes nobody").toHaveLength(1); + + await row.getByRole("button", { name: "Remove" }).click(); + await dialog.getByRole("button", { name: "Remove" }).click(); + await expect(row).toBeHidden(); + await expect + .poll( + async () => + ( + await e2eQuery( + `SELECT 1 FROM company_membership WHERE user_id = $1 AND company_id = $2`, + [member.id, company], + ) + ).length, + ) + .toBe(0); + }); +}); + +test.describe("invites", () => { + test("says when the mail did not go out, and inviting again keeps the first link", async ({ + page, + }) => { + const email = `e2e-invitee-${RUN}@nis2.local`; + await openTeam(page); + + await inviteViaForm(page, email); + await expect(page.getByTestId("invite-result")).toContainText( + `The email to ${email} did not go out`, + ); + const first = await inviteRow(email); + expect(first?.status).toBe("pending"); + + await inviteViaForm(page, email); + const second = await inviteRow(email); + expect(second?.token, "the link in the first mail still works").toBe(first?.token); + }); + + test("an expired invite says so and is sent again with a new link", async ({ + page, + }) => { + const email = `e2e-expired-${RUN}@nis2.local`; + await openTeam(page); + await inviteViaForm(page, email); + const issued = await inviteRow(email); + await e2eQuery( + `UPDATE company_invite SET expires_at = NOW() - INTERVAL '3 days' WHERE email = $1`, + [email], + ); + + await openTeam(page); + const row = page.locator(`[data-testid="team-invite"][data-email="${email}"]`); + await expect(row).toContainText("The link expired on"); + await expect(row.getByRole("button", { name: "Copy link" })).toHaveCount(0); + + await row.getByRole("button", { name: "Send again" }).click(); + await expect(row).toContainText("The link works until"); + const renewed = await inviteRow(email); + expect(renewed?.token, "an expired link is never revived").not.toBe(issued?.token); + expect(new Date(renewed?.expires_at ?? 0).getTime()).toBeGreaterThan(Date.now()); + + await row.getByRole("button", { name: "Withdraw" }).click(); + await expect(row).toBeHidden(); + expect((await inviteRow(email))?.status).toBe("revoked"); + }); + + test("the invite link tells a visitor without an account whose invite it is", async ({ + page, + browser, + }) => { + const email = `e2e-visitor-${RUN}@nis2.local`; + await openTeam(page); + await inviteViaForm(page, email); + const invite = await inviteRow(email); + + const visitor = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const anon = await visitor.newPage(); + try { + await anon.goto(`/en/invite/${invite?.token}`); + await expect(anon.getByText(`This invite is for ${email}.`)).toBeVisible(); + const create = anon.getByRole("link", { name: "Create account" }); + await expect(create).toHaveAttribute("href", /mode=register/); + await create.click(); + await expect(anon.locator("#email")).toHaveValue(email); + + await e2eQuery(`UPDATE company_invite SET status = 'revoked' WHERE email = $1`, [ + email, + ]); + await anon.goto(`/en/invite/${invite?.token}`); + await expect(anon.getByRole("heading", { name: "Invite withdrawn" })).toBeVisible(); + + await anon.goto(`/en/invite/${"0".repeat(64)}`); + await expect( + anon.getByRole("heading", { name: "Invite link no longer works" }), + ).toBeVisible(); + } finally { + await visitor.close(); + } + }); + + /** + * The step that makes someone a member, end to end: a colleague with no account opens the + * mail's link, creates an account with the invited address, confirms the code, comes back to + * the invite and accepts. The harness has no mail, so the code is planted the way signup.spec.ts + * does it. + */ + test("a colleague without an account joins from the invite link", async ({ + page, + browser, + }) => { + const email = `e2e-joiner-${RUN}@e2e.local`; + await openTeam(page); + await inviteViaForm(page, email); + const invite = await inviteRow(email); + const company = await companyId(); + + const visitor = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const joiner = await visitor.newPage(); + try { + await joiner.goto(`/en/invite/${invite?.token}`); + await joiner.getByRole("link", { name: "Create account" }).click(); + await expect(joiner.locator("#email")).toHaveValue(email); + await joiner.locator("#password").fill("JoinerPassword1!"); + + const codesBefore = await codesFor(email); + await joiner.getByRole("button", { name: "Create account" }).click(); + await expect(joiner.locator("#code")).toBeVisible({ timeout: 30_000 }); + // The code is issued after the response, so wait for it before planting a known one. + await expect + .poll(() => codesFor(email), { timeout: 30_000 }) + .toBeGreaterThan(codesBefore); + await plantCode(email, "246810"); + await joiner.locator("#code").fill("246810"); + await joiner.getByRole("button", { name: "Verify and sign in" }).click(); + + // Signed in, the person is back on the invite and accepts it. + await joiner.waitForURL(/\/invite\//, { timeout: 30_000 }); + await joiner.getByRole("button", { name: "Accept Invite" }).click(); + await joiner.waitForURL((url) => !url.pathname.includes("/invite/"), { + timeout: 30_000, + }); + + const joined = await e2eQuery( + `SELECT 1 FROM company_membership m JOIN "user" u ON u.id = m.user_id + WHERE u.email = $1 AND m.company_id = $2`, + [email, company], + ); + expect(joined, "the colleague is a member of the inviting company").toHaveLength(1); + expect((await inviteRow(email))?.status).toBe("accepted"); + + // The inviting company's activity log says who joined (fire-and-forget, so poll). + await expect + .poll( + async () => + ( + await e2eQuery( + `SELECT 1 FROM audit_log + WHERE company_id = $1 AND action = 'team.invite_accepted' AND description = $2`, + [company, `Invite accepted by ${email}`], + ) + ).length, + ) + .toBe(1); + } finally { + await visitor.close(); + } + }); +}); + +async function codesFor(email: string): Promise { + const [row] = await e2eQuery<{ n: number }>( + `SELECT count(*)::int AS n FROM email_otp WHERE email = $1 AND purpose = 'email_verify'`, + [email], + ); + return row?.n ?? 0; +} + +/** One known live code for `email`, consuming the ones issued before, as a resend does. */ +async function plantCode(email: string, code: string): Promise { + assertE2eTargets(); + await e2eQuery( + `UPDATE email_otp SET consumed_at = now() + WHERE email = $1 AND purpose = 'email_verify' AND consumed_at IS NULL`, + [email], + ); + await e2eQuery( + `INSERT INTO email_otp (email, code_hash, purpose, expires_at) + VALUES ($1, $2, 'email_verify', now() + interval '10 minutes')`, + [email, await bcrypt.hash(code, 10)], + ); +} diff --git a/e2e/select/areas.ts b/e2e/select/areas.ts index 0626977d3..15f7885fb 100644 --- a/e2e/select/areas.ts +++ b/e2e/select/areas.ts @@ -17,6 +17,7 @@ export const AREAS = [ "security", "auth", "onboarding", + "team", ] as const; export type Area = (typeof AREAS)[number]; @@ -76,6 +77,7 @@ export const SPEC_AREAS: Readonly> = { "e2e/l3/sign-off-after-save.spec.ts": ["signoff"], "e2e/l3/signoff.spec.ts": ["signoff"], "e2e/l3/supplier-portal.spec.ts": ["supplier"], + "e2e/l3/team-invites.spec.ts": ["team"], "e2e/l3/team-signoff-invalidation.spec.ts": ["signoff"], "e2e/l4/grand-tour.spec.ts": "full", }; @@ -129,6 +131,11 @@ export const PATH_AREAS: ReadonlyArray< // Onboarding surfaces ["components/onboarding/", ["onboarding"]], ["lib/onboarding/", ["onboarding"]], + // The team page and the invite link. The team router, its helpers and components/team/ stay + // unclaimed: the walk's approval hand-off and the sign-off roster go through them. + ["app/[locale]/(portal)/team/", ["team"]], + ["app/[locale]/invite/", ["team"]], + ["components/invite/", ["team"]], // No browser spec reads these ["docs/", []], ["content/", []], diff --git a/lib/edition/edition.test.ts b/lib/edition/edition.test.ts index 90fb89e67..49a72b43b 100644 --- a/lib/edition/edition.test.ts +++ b/lib/edition/edition.test.ts @@ -15,6 +15,7 @@ const KEPT_PUBLIC = [ "/anmelden", "/auth", "/gap-assessment/share", + "/invite", "/newsletter", "/partner-agreement", ]; diff --git a/lib/mail/czech-mail.test.ts b/lib/mail/czech-mail.test.ts index 200892c05..4f91ef5fd 100644 --- a/lib/mail/czech-mail.test.ts +++ b/lib/mail/czech-mail.test.ts @@ -231,7 +231,17 @@ describe("other readers keep the mail they had", () => { role: "member", }); expect(mail.text).toContain( - "Anna Schmidt has invited you to join Stadtwerke as member on the NIS2 Compliance Platform.", + "Anna Schmidt has invited you to join Stadtwerke as a member on the NIS2 Compliance Platform.", ); }); + + test("the English invitation names a compliance role by its label, not its key", async () => { + const mail = await t.inviteEmail({ + companyName: "Stadtwerke", + inviterName: "Anna Schmidt", + inviteUrl: "https://nisd2.eu/invite/x", + role: "hr_director", + }); + expect(mail.text).toContain("as HR Director on the NIS2 Compliance Platform."); + }); }); diff --git a/lib/mail/emails/invite.tsx b/lib/mail/emails/invite.tsx index 00ee8d569..029a4a5a7 100644 --- a/lib/mail/emails/invite.tsx +++ b/lib/mail/emails/invite.tsx @@ -1,3 +1,4 @@ +import teamEn from "@/messages/team/en.json"; import { EmailFrame } from "../components/frame"; import { CtaButton, Note, Para, Title } from "../components/parts"; import type { WorkflowMailLocale } from "../locale"; @@ -10,6 +11,15 @@ export interface InviteProps { readonly role: string; } +/** + * The labels the English team page shows for each role, read from its messages so the two cannot + * drift; a plain member reads "as a member". Without them the sentence printed the key ("as dpo"). + */ +const ENGLISH_ROLE: Readonly> = { + member: "a member", + ...teamEn.team.roles, +}; + /** * The labels the Czech team page shows for each role (messages/team/cs.json, team.roles and * team.memberRole.member). @@ -56,7 +66,7 @@ export const INVITE_COPY: Record< lead: `${inviterName} has invited you to join `, company: companyName, as: " as ", - role, + role: ENGLISH_ROLE[role] ?? role, end: " on the NIS2 Compliance Platform.", }), click: "Click the button below to accept and get started.", diff --git a/lib/organization/invite-state.test.ts b/lib/organization/invite-state.test.ts new file mode 100644 index 000000000..c8244476d --- /dev/null +++ b/lib/organization/invite-state.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { inviteLinkState, isInviteExpired, reusableInviteToken } from "./invite-state"; + +const NOW = new Date("2026-10-26T09:00:00Z"); +const LATER = new Date("2026-11-02T09:00:00Z"); +const EARLIER = new Date("2026-10-14T09:00:00Z"); + +describe("isInviteExpired", () => { + test("an invite stops working at the instant it expires", () => { + expect(isInviteExpired(NOW, NOW)).toBe(true); + expect(isInviteExpired(EARLIER, NOW)).toBe(true); + expect(isInviteExpired(LATER, NOW)).toBe(false); + }); +}); + +describe("reusableInviteToken", () => { + test("keeps the link of an open invite, so the mail already sent still works", () => { + expect( + reusableInviteToken({ token: "mailed", status: "pending", expiresAt: LATER }, NOW), + ).toBe("mailed"); + }); + + test("never revives an expired link", () => { + expect( + reusableInviteToken( + { token: "mailed", status: "pending", expiresAt: EARLIER }, + NOW, + ), + ).toBeNull(); + }); + + test("a withdrawn or accepted invite gets a new link, so withdrawing ends the old one", () => { + for (const status of ["revoked", "accepted"]) { + expect( + reusableInviteToken({ token: "mailed", status, expiresAt: LATER }, NOW), + ).toBeNull(); + } + }); + + test("a first invite has nothing to keep", () => { + expect(reusableInviteToken(undefined, NOW)).toBeNull(); + }); +}); + +describe("inviteLinkState", () => { + test("says why a link does not open, case by case", () => { + expect(inviteLinkState(undefined, NOW)).toBe("invalid"); + expect(inviteLinkState({ status: "accepted", expiresAt: LATER }, NOW)).toBe( + "accepted", + ); + expect(inviteLinkState({ status: "revoked", expiresAt: LATER }, NOW)).toBe("revoked"); + expect(inviteLinkState({ status: "pending", expiresAt: EARLIER }, NOW)).toBe( + "expired", + ); + expect(inviteLinkState({ status: "pending", expiresAt: LATER }, NOW)).toBe("open"); + }); + + test("an accepted or withdrawn invite says so even after its date", () => { + expect(inviteLinkState({ status: "accepted", expiresAt: EARLIER }, NOW)).toBe( + "accepted", + ); + expect(inviteLinkState({ status: "revoked", expiresAt: EARLIER }, NOW)).toBe( + "revoked", + ); + }); + + test("an unknown status opens nothing", () => { + expect(inviteLinkState({ status: "archived", expiresAt: LATER }, NOW)).toBe( + "invalid", + ); + }); +}); diff --git a/lib/organization/invite-state.ts b/lib/organization/invite-state.ts new file mode 100644 index 000000000..9daf440c2 --- /dev/null +++ b/lib/organization/invite-state.ts @@ -0,0 +1,48 @@ +/** + * Where a team invite stands, decided once for every reader: the accept page, the team page's + * pending list, the procedures that send and renew invites, and the platform admin's list. + */ + +/** How long an invite link works, from the moment it is sent or sent again. */ +export const INVITE_EXPIRY_DAYS = 7; + +/** An invite stops working at the instant it expires. */ +export const isInviteExpired = (expiresAt: Date, now: Date): boolean => expiresAt <= now; + +export interface InviteFacts { + readonly token: string; + readonly status: string; + readonly expiresAt: Date; +} + +/** + * The token to keep when the same address is invited, or sent the invite, again: the one already + * mailed, while it still works, so the link in the mail the person has keeps working. A revoked, + * accepted or expired invite gets a new one, so revoking ends the old link and an expired link is + * never revived. + */ +export const reusableInviteToken = ( + existing: InviteFacts | undefined, + now: Date, +): string | null => + existing && existing.status === "pending" && !isInviteExpired(existing.expiresAt, now) + ? existing.token + : null; + +/** What opening an invite link shows. */ +export type InviteLinkState = "invalid" | "accepted" | "revoked" | "expired" | "open"; + +/** + * "invalid" covers a token no row holds: never issued, or replaced when the address was invited + * again after this link had expired or been withdrawn. + */ +export const inviteLinkState = ( + invite: Pick | undefined, + now: Date, +): InviteLinkState => { + if (!invite) return "invalid"; + if (invite.status === "accepted") return "accepted"; + if (invite.status === "revoked") return "revoked"; + if (invite.status !== "pending") return "invalid"; + return isInviteExpired(invite.expiresAt, now) ? "expired" : "open"; +}; diff --git a/lib/platform-admin/invites.ts b/lib/platform-admin/invites.ts index d6c9bc46c..86c017604 100644 --- a/lib/platform-admin/invites.ts +++ b/lib/platform-admin/invites.ts @@ -2,6 +2,7 @@ import "@/lib/server-guard"; import { desc, eq, inArray, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { DbOrTx } from "@/lib/db"; +import { isInviteExpired } from "@/lib/organization/invite-state"; import { company, companyInvite, supplierInvite, user } from "@/schema"; /** @@ -25,7 +26,7 @@ export const inviteStatus = (facts: InviteFacts, now: Date): InviteStatus => ? "joined" : facts.revoked ? "revoked" - : facts.expiresAt <= now + : isInviteExpired(facts.expiresAt, now) ? "expired" : "pending"; diff --git a/lib/public-paths.ts b/lib/public-paths.ts index c49ea890c..07e12161a 100644 --- a/lib/public-paths.ts +++ b/lib/public-paths.ts @@ -36,11 +36,13 @@ * match missed this because `supplier-onboarding` has no slash * after `supplier`) * - * /invite/[token] is also newly enforced. The invite page itself - * renders a public "invalid/expired" card for missing tokens, so the - * inversion changes UX slightly (signin redirect with callbackUrl - * instead of the invalid-card page for unauthenticated visitors with a - * good token), but the underlying acceptance flow stays gated. + * /invite/[token] was enforced here too until 26.10.2026, and that cost the invite: a colleague + * clicking the mail met a bare login form that named neither the company nor the address to use. + * It is public again, as below. The token in the link is the credential, as for the other token + * pages. What the card shows a visitor without a session (company, role, invited address), any + * signed-in account holding the token could already read (the wrong-account card names company + * and address), and accepting (`team.acceptInvite`) still needs the account the invite was sent + * to. * * A new public page is also a choice for the self-hosted image: lib/edition/edition.test.ts fails * until it is either nisd2.eu's alone (lib/edition/nisd2-only.json) or kept on installs. @@ -119,4 +121,6 @@ export const CANONICAL_PUBLIC_PREFIXES: readonly string[] = [ "/supplier-access", // Partner agreement — the token in the link is how a partner reads and accepts it "/partner-agreement", + // Team invite — says whose invite it is and which address to use before anyone signs in + "/invite", ]; diff --git a/messages/team/en.json b/messages/team/en.json index d61e0b8aa..4cdd24319 100644 --- a/messages/team/en.json +++ b/messages/team/en.json @@ -6,6 +6,7 @@ "title": "Members", "description": "{count, plural, one {# member} other {# members}} in your company", "name": "Name", + "member": "Member", "email": "Email", "role": "Role", "you": "you", @@ -17,6 +18,13 @@ "description": "{count, plural, one {# pending invitation} other {# pending invitations}}", "email": "Email", "expires": "Expires", + "validUntil": "The link works until {date}.", + "expiredOn": "The link expired on {date}. Send it again for a new one.", + "copyLink": "Copy link", + "sendAgain": "Send again", + "withdraw": "Withdraw", + "resent": "Invite sent again to {email}. The link works for {days} more days.", + "resentNotEmailed": "The email to {email} did not go out. Copy the link and send it to them yourself.", "linkCopied": "Link copied" }, "invite": { @@ -27,7 +35,9 @@ "complianceRole": "Compliance Role", "complianceRolePlaceholder": "Select role (optional)", "submit": "Invite", - "submitting": "Inviting..." + "submitting": "Inviting...", + "sent": "Invite sent to {email}. If it does not arrive, copy the link below and send it yourself.", + "notEmailed": "The email to {email} did not go out. Copy the link below and send it to them yourself." }, "assignRole": { "label": "Assign Role", @@ -36,28 +46,39 @@ "noCategories": "No categories for this role" }, "remove": { + "action": "Remove", + "title": "Remove {name} from the team?", + "description": "{name} can no longer open this organization. Their categories are unassigned, sign-offs still waiting for them are dropped, and open invites they sent are withdrawn. Sign-offs they already gave stay on record. They get an email saying they were removed.", + "confirm": "Remove", "success": "Member removed" }, "revoke": { - "success": "Invite revoked" + "success": "Invite withdrawn" }, "inline": { "sent": "Invite sent to {email}", - "notEmailed": "Invite created for {email}. This instance has no mail transport, so send them the link yourself.", + "notEmailed": "Invite created for {email}, but the email did not go out. Copy the link and send it to them yourself.", "linkCopied": "Link copied", "send": "Send invite", "copyLink": "Copy the invite link" }, - "invalidTitle": "Invalid Invite", - "invalidDescription": "This invite link is invalid or has already been used.", + "invalidTitle": "Invite link no longer works", + "invalidDescription": "If you were invited again, use the link in the most recent email. Otherwise ask the person who invited you to send it again.", "usedTitle": "Invite Already Used", "usedDescription": "This invite has already been accepted or revoked.", + "acceptedTitle": "Invite already accepted", + "acceptedDescription": "This invite has been accepted. Sign in with the invited address to continue.", + "revokedTitle": "Invite withdrawn", + "revokedDescription": "This invite was withdrawn. Ask the person who invited you to send a new one.", "expiredTitle": "Invite Expired", - "expiredDescription": "This invite link has expired. Ask your admin for a new one.", + "expiredDescription": "This invite link has expired. Ask the person who invited you to send it again.", "accept": { "joinTitle": "Join {company}", "joinDescription": "You've been invited to join as {role}.", - "signInDescription": "You've been invited to join as {role}. Sign in or register to continue.", + "signInDescription": "You've been invited to join as {role}.", + "forAddress": "This invite is for {email}. Use that address to create your account or to sign in.", + "createAccount": "Create account", + "haveAccount": "I already have an account", "signInToAccept": "Sign in to accept", "wrongAccountTitle": "Wrong Account", "wrongAccountDescription": "This invite was sent to {inviteEmail}. You're signed in as {userEmail}.", diff --git a/server/trpc/helpers/company-invite.ts b/server/trpc/helpers/company-invite.ts index 285010cf5..977b13a7d 100644 --- a/server/trpc/helpers/company-invite.ts +++ b/server/trpc/helpers/company-invite.ts @@ -5,17 +5,23 @@ */ import { randomBytes } from "node:crypto"; import { TRPCError } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { ALL_ROLE_KEYS, type RoleKey } from "@/lib/compliance/role-keys"; import type { Database, DbOrTx } from "@/lib/db"; +import { INVITE_EXPIRY_DAYS, reusableInviteToken } from "@/lib/organization/invite-state"; import { isMemberOf } from "@/lib/organization/membership"; import { getAppUrl } from "@/lib/utils"; import { companyInvite, user } from "@/schema"; import { verifyAssessmentOwnership } from "../guards"; import { resolveRoleAssignments } from "./resolve-role-assignments"; -export const INVITE_EXPIRY_DAYS = 7; +export const newInviteToken = (): string => randomBytes(32).toString("hex"); + +export const inviteExpiryFrom = (now: Date): Date => + new Date(now.getTime() + INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + +export const inviteUrlOf = (token: string): string => `${getAppUrl()}/invite/${token}`; const batchAssignmentSchema = z.object({ roleKeys: z.array(z.string()), @@ -30,6 +36,23 @@ export const invitedRoleOf = (raw: unknown): RoleKey | null => { return role.success && rest.length === 0 ? role.data : null; }; +/** + * Refuses an address that already belongs to the company. Someone in another organization can be + * invited: accepting adds a membership and leaves their other organizations as they are. + */ +async function assertNotMember(db: Database, companyId: string, email: string) { + const alreadyMember = await db.query.user.findFirst({ + where: and(eq(user.email, email), isMemberOf(db, companyId)), + columns: { id: true }, + }); + if (alreadyMember) { + throw new TRPCError({ + code: "CONFLICT", + message: "This person is already a member of your company.", + }); + } +} + /** * Create or renew the invite for one address into one company. The caller has already established * that `invitedBy` may invite into `companyId` (its procedure tier); this checks the address is not @@ -57,19 +80,7 @@ export async function issueInvite( readonly inviteUrl: string; }> { const email = input.email.toLowerCase(); - - // Someone who belongs to another organization can be invited too: accepting adds a membership - // and leaves their other organizations as they are. - const alreadyMember = await db.query.user.findFirst({ - where: and(eq(user.email, email), isMemberOf(db, input.companyId)), - columns: { id: true }, - }); - if (alreadyMember) { - throw new TRPCError({ - code: "CONFLICT", - message: "This person is already a member of your company.", - }); - } + await assertNotMember(db, input.companyId, email); // Audit H-1 (2026-06-10): the single-format assignmentContext flows into a // `(assessmentId, categoryId)` upsert at accept time. Without an ownership check, an admin in @@ -100,17 +111,31 @@ export async function issueInvite( } : (input.assignmentContext ?? null); - const token = randomBytes(32).toString("hex"); - const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + // One row per company and address. Inviting the address again to the same place keeps a + // still-working link, so the mail the person already has keeps working and keeps meaning what it + // said. An invite to somewhere else (a hand-off, then a plain team invite) gets a new link. + const now = new Date(); + const existing = await db.query.companyInvite.findFirst({ + where: and( + eq(companyInvite.companyId, input.companyId), + eq(companyInvite.email, email), + ), + columns: { token: true, status: true, expiresAt: true, redirectPath: true }, + }); + const kept = + existing?.redirectPath === input.redirectPath + ? reusableInviteToken(existing, now) + : null; + const fresh = newInviteToken(); + const expiresAt = inviteExpiryFrom(now); - // Upsert: if a pending invite exists for this email+company, reset it const [invite] = await db .insert(companyInvite) .values({ companyId: input.companyId, invitedBy: input.invitedBy, email, - token, + token: fresh, expiresAt, redirectPath: input.redirectPath, assignmentContext, @@ -118,7 +143,11 @@ export async function issueInvite( .onConflictDoUpdate({ target: [companyInvite.companyId, companyInvite.email], set: { - token, + // Decided again in the write: an invite withdrawn since it was read gets the new link, so a + // withdrawal racing this re-invite cannot bring its link back. + token: kept + ? sql`CASE WHEN ${companyInvite.status} = 'pending' AND ${companyInvite.token} = ${kept} THEN ${companyInvite.token} ELSE ${fresh} END` + : fresh, expiresAt, invitedBy: input.invitedBy, status: "pending", @@ -128,10 +157,74 @@ export async function issueInvite( assignmentContext, }, }) - .returning({ id: companyInvite.id }); + .returning({ id: companyInvite.id, token: companyInvite.token }); if (!invite) throw new Error("company invite upsert returned no row"); - return { inviteId: invite.id, token, inviteUrl: `${getAppUrl()}/invite/${token}` }; + return { + inviteId: invite.id, + token: invite.token, + inviteUrl: inviteUrlOf(invite.token), + }; +} + +/** An open invite renewed for sending again. */ +export interface RenewedInvite { + readonly email: string; + readonly inviteUrl: string; + readonly role: RoleKey | null; + /** Where accepting lands, which tells a hand-off to management from a team invite. */ + readonly redirectPath: string | null; +} + +/** + * Send an open invite of this company again: seven more days from now, the same link while it + * still works and a new one once it has expired. Who sent it first stays recorded, because that + * decides who may order for the company (`acceptedInviteOf`). Null for an invite that is not this + * company's or no longer open; refused for someone who has joined meanwhile. `authorize` sees the + * invite before anything is written, so the caller can apply the rules of the kind it is. + */ +export async function renewInvite( + db: Database, + input: { readonly inviteId: string; readonly companyId: string }, + authorize: (invite: { readonly redirectPath: string | null }) => Promise, +): Promise { + const open = and( + eq(companyInvite.id, input.inviteId), + eq(companyInvite.companyId, input.companyId), + eq(companyInvite.status, "pending"), + ); + const invite = await db.query.companyInvite.findFirst({ + where: open, + columns: { + token: true, + status: true, + expiresAt: true, + email: true, + assignmentContext: true, + redirectPath: true, + }, + }); + if (!invite) return null; + await assertNotMember(db, input.companyId, invite.email); + await authorize(invite); + + const now = new Date(); + const token = reusableInviteToken(invite, now) ?? newInviteToken(); + const [renewed] = await db + .update(companyInvite) + .set({ token, expiresAt: inviteExpiryFrom(now) }) + // Re-checked in the write: withdrawn, accepted or invited again with another link since it was + // read means this send is no longer the latest, and nothing is sent. + .where(and(open, eq(companyInvite.token, invite.token))) + .returning({ id: companyInvite.id }); + if (!renewed) return null; + + return { + email: invite.email, + inviteUrl: inviteUrlOf(token), + role: invitedRoleOf(invite.assignmentContext), + redirectPath: invite.redirectPath, + }; } /** diff --git a/server/trpc/helpers/invite-limits.ts b/server/trpc/helpers/invite-limits.ts new file mode 100644 index 000000000..4644cc1e5 --- /dev/null +++ b/server/trpc/helpers/invite-limits.ts @@ -0,0 +1,65 @@ +/** + * Who may send which invite, and how often. Every invite mails an address someone typed in, so each + * kind is capped per day; the hand-off to management is the account holder's alone. + */ +import { TRPCError } from "@trpc/server"; +import { holderOf } from "@/lib/billing/accounts"; +import type { Database } from "@/lib/db"; +import { rateLimit } from "@/lib/rate-limit"; +import { handoffLocaleOf } from "./invite-mail"; + +/** Hand-offs to management one company may send per day: a few retries above honest use. */ +export const FORWARD_LIMIT = 5; +/** Team invites one company may send per day: room to onboard a whole department in one sitting. */ +export const INVITE_LIMIT = 50; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Every invite mails an address someone typed in, so each kind is capped per company per day. */ +export async function enforceDailyInviteCap( + kind: "invite" | "forward", + companyId: string, + limit: number, +): Promise { + if (!(await rateLimit(`team:${kind}:${companyId}`, limit, DAY_MS))) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: + "Your organization has sent many of these today. Please try again tomorrow.", + }); + } +} + +/** + * "Send again" under the rules of the invite it sends: a hand-off to management only by the + * account holder and under the hand-off's cap (as `team.forwardToManagement`), anything else under + * the team invite's cap. + */ +export async function authorizeResend( + db: Database, + sender: { readonly companyId: string; readonly userId: string }, + invite: { readonly redirectPath: string | null }, +): Promise { + if (handoffLocaleOf(invite.redirectPath) === null) { + return enforceDailyInviteCap("invite", sender.companyId, INVITE_LIMIT); + } + if ((await holderOf(db, sender.companyId)) !== sender.userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the account holder can send the order to management again.", + }); + } + return enforceDailyInviteCap("forward", sender.companyId, FORWARD_LIMIT); +} + +/** + * The team named at setup is mailed under a cap per person, not per company: a new company is + * created for each setup, so a company cap would start fresh every time. Never throws. + */ +export async function withinSetupInviteCap(userId: string): Promise { + try { + return await rateLimit(`team:setup-invite:${userId}`, INVITE_LIMIT, DAY_MS); + } catch { + // An unreadable cap counts as reached: no mail rather than an unbounded one. + return false; + } +} diff --git a/server/trpc/helpers/invite-mail.test.ts b/server/trpc/helpers/invite-mail.test.ts new file mode 100644 index 000000000..883d1aead --- /dev/null +++ b/server/trpc/helpers/invite-mail.test.ts @@ -0,0 +1,99 @@ +/** + * `mailInvite` decides what the person sending an invite is told: "sent" only when the mail left. + * Before it, the team page reported a send whenever a mail transport existed, whether or not the + * send itself worked. + */ +import { describe, expect, test } from "bun:test"; +import { LOCALE_CODES } from "@/lib/locale"; +import type { SendOutcome } from "@/lib/mail/delivery"; +import { handoffLocaleOf, mailInvite, managementHandoffPath } from "./invite-mail"; + +describe("handoffLocaleOf", () => { + test("reads a hand-off's language back from where it lands, for every language", () => { + for (const locale of LOCALE_CODES) { + expect(handoffLocaleOf(managementHandoffPath(locale))).toBe(locale); + } + }); + + test("a team invite is no hand-off, so sending it again sends the team mail", () => { + expect(handoffLocaleOf(null)).toBeNull(); + expect(handoffLocaleOf("/compliance/governance")).toBeNull(); + }); +}); + +const content = async () => ({ subject: "Join", html: "

Join

", text: "Join" }); + +async function attempt(outcome: SendOutcome | Error) { + const recorded: { action: string; description: string }[] = []; + const sentTo: string[] = []; + const delivered = await mailInvite({ + companyId: "company-1", + actorId: "user-1", + to: "anna@example.test", + content, + sentDescription: "Invite email sent to anna@example.test", + send: async (opts) => { + sentTo.push(opts.to); + if (outcome instanceof Error) throw outcome; + return outcome; + }, + record: async (entry) => { + recorded.push({ action: entry.action, description: entry.description ?? "" }); + }, + }); + return { delivered, recorded, sentTo }; +} + +describe("mailInvite", () => { + test("a mail the transport took is reported sent and recorded", async () => { + const { delivered, recorded, sentTo } = await attempt({ success: true, id: "msg-1" }); + expect(delivered).toBe(true); + expect(sentTo).toEqual(["anna@example.test"]); + expect(recorded).toEqual([ + { + action: "email.invite_sent", + description: "Invite email sent to anna@example.test", + }, + ]); + }); + + test("a failed send is reported as not sent, with no 'sent' row", async () => { + const { delivered, recorded } = await attempt({ success: false }); + expect(delivered).toBe(false); + expect(recorded).toEqual([]); + }); + + test("a suppressed send (no transport, mail disabled) is not a send", async () => { + for (const id of ["no-transport", "disabled", "dev-blocked"]) { + const { delivered, recorded } = await attempt({ success: true, id }); + expect(delivered).toBe(false); + expect(recorded).toEqual([]); + } + }); + + test("never throws, even when the transport does", async () => { + const { delivered, recorded } = await attempt(new Error("socket hang up")); + expect(delivered).toBe(false); + expect(recorded).toEqual([]); + }); + + test("a mail whose text cannot be built is not sent and does not throw", async () => { + const sent: string[] = []; + const delivered = await mailInvite({ + companyId: "company-1", + actorId: "user-1", + to: "anna@example.test", + content: async () => { + throw new Error("template failed"); + }, + sentDescription: "unused", + send: async (opts) => { + sent.push(opts.to); + return { success: true, id: "msg-1" }; + }, + record: async () => {}, + }); + expect(delivered).toBe(false); + expect(sent).toEqual([]); + }); +}); diff --git a/server/trpc/helpers/invite-mail.ts b/server/trpc/helpers/invite-mail.ts new file mode 100644 index 000000000..4ab2a0568 --- /dev/null +++ b/server/trpc/helpers/invite-mail.ts @@ -0,0 +1,189 @@ +/** + * Mailing a team invite, for every way one is sent: the team page, the inline invite, the + * hand-off to management, "send again" and the team named at setup. + */ +import { eq } from "drizzle-orm"; +import { getPathname } from "@/i18n/navigation"; +import { logAudit } from "@/lib/audit"; +import type { Database } from "@/lib/db"; +import { walkLocaleOf } from "@/lib/durchgang/types"; +import { LOCALE_CODES, type LocaleCode } from "@/lib/locale"; +import { + accountEmailLocale, + inviteEmail, + managementHandoffEmail, + sendMail, +} from "@/lib/mail"; +import { type SendOutcome, wasDelivered } from "@/lib/mail/delivery"; +import type { EmailContent } from "@/lib/mail/layout"; +import { inviteRedirectPath } from "@/lib/organization/invite-redirect"; +import { INVITE_EXPIRY_DAYS } from "@/lib/organization/invite-state"; +import { company } from "@/schema"; +import type { RenewedInvite } from "./company-invite"; + +type Send = (opts: { + readonly emailType: "account.invite"; + readonly to: string; + readonly subject: string; + readonly html: string; + readonly text: string; + readonly replyTo?: string; +}) => Promise; + +/** + * Mail the invite and say whether it left. Awaited, so the person who sent it learns the answer: + * true only when the transport took the mail, never because a transport exists. Never throws. A + * send that did not leave writes no "sent" row; a failed one is recorded by `sendMail` itself. + */ +export async function mailInvite(input: { + readonly companyId: string; + readonly actorId: string; + readonly to: string; + readonly content: () => Promise; + /** The audit row's words once it left, e.g. "Invite email sent to anna@example.com". */ + readonly sentDescription: string; + /** Injected by tests; mocking the modules would leak into every other test file. */ + readonly send?: Send; + readonly record?: typeof logAudit; +}): Promise { + const send: Send = input.send ?? sendMail; + const record = input.record ?? logAudit; + const outcome = await input + .content() + .then((content) => send({ emailType: "account.invite", to: input.to, ...content })) + .catch((err: unknown): SendOutcome => { + console.error("[team] invite email not sent", err); + return { success: false }; + }); + + const delivered = wasDelivered(outcome); + if (delivered) { + record({ + companyId: input.companyId, + userId: input.actorId, + action: "email.invite_sent", + entityType: "email", + entityId: outcome.id ?? null, + description: input.sentDescription, + }); + } + return delivered; +} + +/** Who an invite mail comes from: the company and the person sending it. */ +export interface InviteSender { + readonly companyId: string; + readonly companyName: string | null; + readonly actorId: string; + readonly name: string | null | undefined; + readonly email: string | null | undefined; +} + +/** The sender as a procedure knows it: the company's name is read once here. */ +export async function inviteSenderOf(ctx: { + readonly db: Database; + readonly companyId: string; + readonly userId: string; + readonly session: { + readonly user: { readonly name?: string | null; readonly email?: string | null }; + }; +}): Promise { + const row = await ctx.db.query.company.findFirst({ + where: eq(company.id, ctx.companyId), + columns: { name: true }, + }); + return { + companyId: ctx.companyId, + companyName: row?.name ?? null, + actorId: ctx.userId, + name: ctx.session.user.name, + email: ctx.session.user.email, + }; +} + +/** The team invite mail, in the language of the sender's account. */ +export async function mailTeamInvite( + sender: InviteSender, + invite: { + readonly to: string; + readonly inviteUrl: string; + readonly role: string; + readonly sentDescription?: string; + }, +): Promise { + // The mail's language must never cost the invite: an unreadable account sends the default. + const locale = sender.email + ? await accountEmailLocale(sender.email).catch(() => undefined) + : undefined; + + return mailInvite({ + companyId: sender.companyId, + actorId: sender.actorId, + to: invite.to, + content: () => + inviteEmail({ + companyName: sender.companyName ?? "your company", + inviterName: sender.name ?? "Your team admin", + inviteUrl: invite.inviteUrl, + role: invite.role, + locale, + }), + sentDescription: invite.sentDescription ?? `Invite email sent to ${invite.to}`, + }); +} + +/** Where accepting a hand-off to management lands: the approval page, in the page's language. */ +export const managementHandoffPath = (locale: LocaleCode): string => + inviteRedirectPath(getPathname({ href: "/pricing/approval", locale })); + +/** The language a hand-off was sent in, read back from where it lands; null for a team invite. */ +export const handoffLocaleOf = (redirectPath: string | null): LocaleCode | null => + LOCALE_CODES.find((locale) => managementHandoffPath(locale) === redirectPath) ?? null; + +/** The hand-off to management: the person is asked to decide on the order, not only to join. */ +export function mailManagementHandoff( + sender: InviteSender, + handoff: { + readonly to: string; + readonly inviteUrl: string; + /** The person's name as the sender typed it; a mail sent again has none and greets plainly. */ + readonly name: string; + readonly locale: LocaleCode; + }, +): Promise { + return mailInvite({ + companyId: sender.companyId, + actorId: sender.actorId, + to: handoff.to, + content: () => + managementHandoffEmail({ + locale: walkLocaleOf(handoff.locale), + name: handoff.name, + inviterName: sender.name ?? null, + companyName: sender.companyName, + inviteUrl: handoff.inviteUrl, + days: INVITE_EXPIRY_DAYS, + }), + sentDescription: `Management invite email sent to ${handoff.to}`, + }); +} + +/** An invite sent again goes out as the mail it first was: a hand-off stays a hand-off. */ +export function mailRenewedInvite( + sender: InviteSender, + renewed: RenewedInvite, +): Promise { + const handoffLocale = handoffLocaleOf(renewed.redirectPath); + return handoffLocale + ? mailManagementHandoff(sender, { + to: renewed.email, + inviteUrl: renewed.inviteUrl, + name: "", + locale: handoffLocale, + }) + : mailTeamInvite(sender, { + to: renewed.email, + inviteUrl: renewed.inviteUrl, + role: renewed.role ?? "member", + }); +} diff --git a/server/trpc/helpers/member-removed-mail.ts b/server/trpc/helpers/member-removed-mail.ts new file mode 100644 index 000000000..14cdf56f3 --- /dev/null +++ b/server/trpc/helpers/member-removed-mail.ts @@ -0,0 +1,53 @@ +/** + * The mail that tells a removed member they no longer belong to the company, sent by + * `team.removeMember` once the removal has committed. + */ +import { eq } from "drizzle-orm"; +import { logAudit } from "@/lib/audit"; +import type { Database } from "@/lib/db"; +import { memberRemovedEmail, sendMail } from "@/lib/mail"; +import { wasDelivered } from "@/lib/mail/delivery"; +import { resolveEmailLocale } from "@/lib/mail/locale"; +import { company } from "@/schema"; + +/** Reads the company's name, then sends without waiting: the removal stands either way. */ +export async function mailMemberRemoved(input: { + readonly db: Database; + readonly companyId: string; + readonly actorId: string; + readonly member: { + readonly name: string; + readonly email: string; + readonly locale: string | null; + }; +}): Promise { + const { db, companyId, actorId, member } = input; + const companyRow = await db.query.company.findFirst({ + where: eq(company.id, companyId), + columns: { name: true }, + }); + + memberRemovedEmail({ + companyName: companyRow?.name ?? "your company", + memberName: member.name, + locale: resolveEmailLocale(member.locale, null), + }) + .then((content) => + sendMail({ emailType: "account.member_removed", to: member.email, ...content }), + ) + .then((r) => { + // Only a mail that left: a suppressed send also reports success. `r.success` is there to + // narrow the type for `r.id`; `wasDelivered` decides. + if (r.success && wasDelivered(r)) { + logAudit({ + companyId, + userId: actorId, + action: "email.member_removed", + entityType: "email", + entityId: r.id ?? null, + description: `Removal email sent to ${member.email}`, + }); + } + }) + .catch((err) => console.error("[team] removal email not sent", err)); +} diff --git a/server/trpc/helpers/setup-helpers.test.ts b/server/trpc/helpers/setup-helpers.test.ts new file mode 100644 index 000000000..a816a39dd --- /dev/null +++ b/server/trpc/helpers/setup-helpers.test.ts @@ -0,0 +1,124 @@ +/** + * The team named at setup is invited by `processTeamRoleAssignments`. It used to skip anyone + * whose role owns no category (the DPO owns none in NIS 2), silently, and mailed the others from + * inside the setup's transaction, before the rows it linked to were committed. + */ +import { describe, expect, test } from "bun:test"; +import { ALL_ROLE_KEYS } from "@/lib/compliance/role-keys"; +import type { DbOrTx } from "@/lib/db"; +import { companySetupInput, processTeamRoleAssignments } from "./setup-helpers"; + +describe("companySetupInput", () => { + const company = { name: "Acme", sector: "energy", entityType: "important" } as const; + const withTeam = (teamRoles: unknown) => + companySetupInput.safeParse({ ...company, teamRoles }); + + test("takes only the known roles, since everyone named is mailed", () => { + expect(withTeam([{ roleKey: "dpo", email: "dana@example.test" }]).success).toBe(true); + expect(withTeam([{ roleKey: "intern", email: "x@example.test" }]).success).toBe( + false, + ); + }); + + test("names one person per role", () => { + const everyRole = ALL_ROLE_KEYS.map((roleKey, i) => ({ + roleKey, + email: `p${i}@example.test`, + })); + expect(withTeam(everyRole).success).toBe(true); + const twice = [ + { roleKey: "ceo", email: "a@example.test" }, + { roleKey: "ceo", email: "b@example.test" }, + ]; + expect(withTeam(twice).success).toBe(false); + }); +}); + +/** `members` are the addresses already in the company. */ +function fakeDb(members: readonly string[] = []) { + const invites: { email: string; token: string; assignmentContext: unknown }[] = []; + const asked: string[] = []; + const db = { + query: { + requirementCategory: { findMany: async () => [] }, + // The member check: the fake cannot read the condition, so it answers by call order. + user: { + findFirst: async () => { + const email = pending.shift() ?? ""; + asked.push(email); + return members.includes(email) ? { id: "member-1" } : undefined; + }, + }, + }, + // getSlugsForRole: no category lists the role. isMemberOf builds a subquery from it too. + select: () => ({ from: () => ({ where: async () => [] }) }), + insert: () => ({ + values: (values: { email: string; token: string; assignmentContext: unknown }) => { + invites.push(values); + return { onConflictDoUpdate: async () => undefined }; + }, + }), + }; + const pending: string[] = []; + return { db: db as unknown as DbOrTx, invites, pending, asked }; +} + +const setup = (db: DbOrTx, teamRoles: { roleKey: string; email: string }[]) => + processTeamRoleAssignments(db, { + teamRoles, + companyId: "company-1", + userId: "user-1", + userEmail: "owner@example.test", + frameworkAssessmentMap: new Map(), + }); + +describe("processTeamRoleAssignments", () => { + test("invites a person whose role owns no category, with the role itself", async () => { + const { db, invites } = fakeDb(); + const toMail = await setup(db, [{ roleKey: "dpo", email: "Dana@Example.test" }]); + + expect(invites).toHaveLength(1); + expect(invites[0]).toMatchObject({ + email: "dana@example.test", + assignmentContext: { roleKeys: ["dpo"], categoryIds: [] }, + }); + expect(toMail).toEqual([ + { + email: "dana@example.test", + inviteUrl: expect.stringContaining(`/invite/${invites[0]?.token}`), + roleKeys: ["dpo"], + }, + ]); + }); + + test("hands the invites back for mailing instead of mailing them itself", async () => { + const { db } = fakeDb(); + const toMail = await setup(db, [ + { roleKey: "ceo", email: "ceo@example.test" }, + { roleKey: "cto", email: "ceo@example.test" }, + ]); + // One person named for two roles is one invite, carrying both. + expect(toMail.map((i) => [i.email, i.roleKeys])).toEqual([ + ["ceo@example.test", ["ceo", "cto"]], + ]); + }); + + test("never invites the person doing the setup", async () => { + const { db, invites } = fakeDb(); + const toMail = await setup(db, [{ roleKey: "ciso", email: "owner@example.test" }]); + expect(invites).toEqual([]); + expect(toMail).toEqual([]); + }); + + test("leaves someone already in the company alone, and their accepted invite with them", async () => { + const { db, invites, pending, asked } = fakeDb(["anna@example.test"]); + pending.push("anna@example.test", "ben@example.test"); + const toMail = await setup(db, [ + { roleKey: "ceo", email: "anna@example.test" }, + { roleKey: "cto", email: "ben@example.test" }, + ]); + expect(asked).toEqual(["anna@example.test", "ben@example.test"]); + expect(invites.map((i) => i.email)).toEqual(["ben@example.test"]); + expect(toMail.map((i) => i.email)).toEqual(["ben@example.test"]); + }); +}); diff --git a/server/trpc/helpers/setup-helpers.ts b/server/trpc/helpers/setup-helpers.ts index 9f8467354..763e3bdc5 100644 --- a/server/trpc/helpers/setup-helpers.ts +++ b/server/trpc/helpers/setup-helpers.ts @@ -1,6 +1,5 @@ -import { randomBytes } from "node:crypto"; -import { eq, inArray } from "drizzle-orm"; -import { logAudit } from "@/lib/audit"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; import { createBillingAccount, deleteBillingAccountIfUnused, @@ -11,9 +10,7 @@ import { type RoleKey, } from "@/lib/compliance/role-mapping"; import type { Database, DbOrTx } from "@/lib/db"; -import { accountEmailLocale, inviteEmail, sendMail } from "@/lib/mail"; -import { joinCompany } from "@/lib/organization/membership"; -import { getAppUrl } from "@/lib/utils"; +import { isMemberOf, joinCompany } from "@/lib/organization/membership"; import { categoryAssignment, company, @@ -24,8 +21,46 @@ import { requirementCategory, user, } from "@/schema"; +import { + invitedRoleOf, + inviteExpiryFrom, + inviteUrlOf, + newInviteToken, +} from "./company-invite"; +import { withinSetupInviteCap } from "./invite-limits"; +import { type InviteSender, mailTeamInvite } from "./invite-mail"; -const INVITE_EXPIRY_DAYS = 7; +/** What `assessment.createCompanyAndAssessment` takes: the company's identity and its named team. */ +export const companySetupInput = z.object({ + name: z.string().min(1).max(255), + sector: z.string().min(1).max(255), + entityType: z.enum(["essential", "important", "kritis"]), + legalForm: z.string().max(100).optional(), + employeeCount: z.number().int().positive().optional(), + contactEmail: z.string().email().optional(), + cisoName: z.string().max(255).optional(), + cisoReportsTo: z.string().max(255).optional(), + bsiContactName: z.string().max(255).optional(), + bsiContactEmail: z.string().email().optional(), + bsiContactPhone: z.string().max(50).optional(), + bsiRegistrationId: z.string().max(100).optional(), + annualSecurityBudget: z.string().optional(), + primaryLocations: z.string().max(1000).optional(), + // Each named person is invited and mailed, so only the known roles, each naming one person: a + // role's categories have one assignee, so a second name for it would only overwrite the first. + teamRoles: z + .array( + z.object({ + roleKey: z.enum(ALL_ROLE_KEYS), + name: z.string().max(255).optional(), + email: z.string().email(), + }), + ) + .refine((roles) => new Set(roles.map((r) => r.roleKey)).size === roles.length, { + message: "Each role names one person.", + }) + .optional(), +}); /** * Placeholder identity for an auto-provisioned draft company. name/sector/ @@ -190,13 +225,11 @@ export async function processTeamRoleAssignments( opts: { teamRoles: { roleKey: string; name?: string; email: string }[]; companyId: string; - companyName: string; userId: string; userEmail: string | undefined; - userName: string | undefined; frameworkAssessmentMap: Map; }, -) { +): Promise { const allCategories = await db.query.requirementCategory.findMany(); const slugMap = new Map(); for (const cat of allCategories) { @@ -237,13 +270,9 @@ export async function processTeamRoleAssignments( } const currentUserEmail = opts.userEmail?.toLowerCase(); - const appUrl = getAppUrl(); - const locale = opts.userEmail - ? await accountEmailLocale(opts.userEmail).catch(() => undefined) - : undefined; + const invites: TeamRoleInvite[] = []; for (const [email, { roleKeys, categoryIds }] of byEmail) { - if (categoryIds.size === 0) continue; const categoryIdArray = [...categoryIds]; if (email === currentUserEmail) { @@ -276,8 +305,18 @@ export async function processTeamRoleAssignments( }); } } else { - const token = randomBytes(32).toString("hex"); - const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + // Someone already in the company is not invited again: the upsert below would reset the + // accepted invite that records who brought them in (`acceptedInviteOf`). + const member = await db.query.user.findFirst({ + where: and(eq(user.email, email), isMemberOf(db, opts.companyId)), + columns: { id: true }, + }); + if (member) continue; + + // Invited even when the role owns no category (the DPO owns none in NIS 2): accepting gives + // the person the role itself, as an invite from the team page does (`issueInvite`). + const token = newInviteToken(); + const expiresAt = inviteExpiryFrom(new Date()); await db .insert(companyInvite) @@ -302,32 +341,39 @@ export async function processTeamRoleAssignments( }, }); - const inviteUrl = `${appUrl}/invite/${token}`; - const inviterName = opts.userName ?? "Your team admin"; - - inviteEmail({ - companyName: opts.companyName, - inviterName, - inviteUrl, - role: "member", - locale, - }) - .then((content) => - sendMail({ emailType: "account.invite", to: email, ...content }), - ) - .then((r) => { - if (r.success) { - logAudit({ - companyId: opts.companyId, - userId: opts.userId, - action: "email.invite_sent", - entityType: "email", - entityId: r.id ?? null, - description: `Team role invite sent to ${email} (${roleKeys.join(", ")})`, - }); - } - }) - .catch((err) => console.error("[setup] invite email not sent", err)); + invites.push({ email, inviteUrl: inviteUrlOf(token), roleKeys }); } } + + return invites; +} + +/** An invite written by `processTeamRoleAssignments`, to mail once its transaction has committed. */ +export interface TeamRoleInvite { + readonly email: string; + readonly inviteUrl: string; + readonly roleKeys: readonly string[]; +} + +/** + * Mail the invites the setup wrote. Called after the transaction commits, so nobody is mailed a + * link to a row that was rolled back. Never throws. + */ +export async function mailTeamRoleInvites( + invites: readonly TeamRoleInvite[], + sender: InviteSender, +): Promise { + await Promise.all( + invites.map(async (invite) => { + // Past the cap the invite stands and its link is on the team page; only the mail is held. + if (!(await withinSetupInviteCap(sender.actorId))) return false; + return mailTeamInvite(sender, { + to: invite.email, + inviteUrl: invite.inviteUrl, + // The one role the person was named for, as the team page's invite says it. + role: invitedRoleOf({ roleKeys: invite.roleKeys, categoryIds: [] }) ?? "member", + sentDescription: `Team role invite sent to ${invite.email} (${invite.roleKeys.join(", ")})`, + }); + }), + ); } diff --git a/server/trpc/routers/assessment.ts b/server/trpc/routers/assessment.ts index c4398711d..27382ebad 100644 --- a/server/trpc/routers/assessment.ts +++ b/server/trpc/routers/assessment.ts @@ -51,7 +51,9 @@ import { import { getNis2Assessment, getNis2FrameworkId } from "../helpers/nis2-scope"; import { rosteredOf } from "../helpers/roster"; import { + companySetupInput, createAssessmentsForFrameworks, + mailTeamRoleInvites, processTeamRoleAssignments, } from "../helpers/setup-helpers"; import { recordSignOffChainEntry } from "../helpers/sign-off-chain"; @@ -204,33 +206,7 @@ export const assessmentRouter = router({ // --------------------------------------------------------------------------- createCompanyAndAssessment: protectedProcedure - .input( - z.object({ - name: z.string().min(1).max(255), - sector: z.string().min(1).max(255), - entityType: z.enum(["essential", "important", "kritis"]), - legalForm: z.string().max(100).optional(), - employeeCount: z.number().int().positive().optional(), - contactEmail: z.string().email().optional(), - cisoName: z.string().max(255).optional(), - cisoReportsTo: z.string().max(255).optional(), - bsiContactName: z.string().max(255).optional(), - bsiContactEmail: z.string().email().optional(), - bsiContactPhone: z.string().max(50).optional(), - bsiRegistrationId: z.string().max(100).optional(), - annualSecurityBudget: z.string().optional(), - primaryLocations: z.string().max(1000).optional(), - teamRoles: z - .array( - z.object({ - roleKey: z.string(), - name: z.string().max(255).optional(), - email: z.string().email(), - }), - ) - .optional(), - }), - ) + .input(companySetupInput) .mutation(async ({ ctx, input }) => { // Every verified user auto-gets a DRAFT company at email verification, so // "already has a company" no longer means "already onboarded". Only an @@ -351,19 +327,27 @@ export const assessmentRouter = router({ firstAssessmentId = created.firstAssessmentId; } - if (input.teamRoles && input.teamRoles.length > 0) { - await processTeamRoleAssignments(tx, { - teamRoles: input.teamRoles, - companyId, - companyName: input.name, - userId: ctx.userId, - userEmail: ctx.session.user.email ?? undefined, - userName: ctx.session.user.name ?? undefined, - frameworkAssessmentMap, - }); - } + const teamInvites = + input.teamRoles && input.teamRoles.length > 0 + ? await processTeamRoleAssignments(tx, { + teamRoles: input.teamRoles, + companyId, + userId: ctx.userId, + userEmail: ctx.session.user.email ?? undefined, + frameworkAssessmentMap, + }) + : []; + + return { firstAssessmentId, companyId, frameworkAssessmentMap, teamInvites }; + }); - return { firstAssessmentId, companyId, frameworkAssessmentMap }; + // Mailed after the commit, without waiting: no link to a rolled-back invite. + void mailTeamRoleInvites(result.teamInvites, { + companyId: result.companyId, + companyName: input.name, + actorId: ctx.userId, + name: ctx.session.user.name, + email: ctx.session.user.email, }); for (const [, assessmentId] of result.frameworkAssessmentMap) { diff --git a/server/trpc/routers/team-invites.test.ts b/server/trpc/routers/team-invites.test.ts new file mode 100644 index 000000000..a0fcfdcb6 --- /dev/null +++ b/server/trpc/routers/team-invites.test.ts @@ -0,0 +1,341 @@ +/** + * The team router's invites: inviting an address again, sending an invite again, listing the open + * ones, and accepting. Each test runs the procedure over a fake of just the drizzle calls it makes. + */ +import { describe, expect, mock, test } from "bun:test"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; + +// The auto-audit middleware would otherwise reach for a real database. +mock.module("@/lib/audit", () => ({ logAudit: () => {} })); + +const { createCallerFactory } = await import("../init"); +const { teamRouter } = await import("./team"); +const { managementHandoffPath } = await import("../helpers/invite-mail"); +type TRPCContext = import("../init").TRPCContext; + +const COMPANY = "company-1"; +const OWNER = "11111111-1111-4111-8111-111111111111"; +const SECOND = "22222222-2222-4222-8222-222222222222"; +const MEMBER = "33333333-3333-4333-8333-333333333333"; +const DAY_MS = 24 * 60 * 60 * 1000; +const INVITE_ID = "44444444-4444-4444-8444-444444444444"; + +const adminCaller = (db: unknown) => + createCallerFactory(teamRouter)({ + db: db as TRPCContext["db"], + session: { + role: "admin", + accessLevel: "full", + user: { name: "Olga Owner" }, + } as TRPCContext["session"], + userId: OWNER, + companyId: COMPANY, + ip: "test", + userAgent: null, + } as TRPCContext); + +type ExistingInvite = { + token: string; + status: string; + expiresAt: Date; + redirectPath?: string | null; +}; + +/** + * `invite` to an address that already has a row in this company. The fake returns the token the + * database would keep: the old one while the row is still the open invite that was read. + */ +async function reinvite(existing: ExistingInvite) { + const stored: { values?: { token: string }; set?: { token: string | SQL } } = {}; + const db = { + query: { + user: { findFirst: async () => undefined }, + company: { findFirst: async () => ({ name: "Acme" }) }, + companyInvite: { + findFirst: async () => ({ redirectPath: null, ...existing }), + }, + }, + select: () => ({ from: () => ({ where: () => ({}) }) }), + insert: () => ({ + values: (values: { token: string }) => { + stored.values = values; + return { + onConflictDoUpdate: ({ set }: { set: { token: string | SQL } }) => { + stored.set = set; + const token = typeof set.token === "string" ? set.token : existing.token; + return { returning: async () => [{ id: "invite-1", token }] }; + }, + }; + }, + }), + }; + const result = await adminCaller(db).invite({ email: "anna@example.test" }); + return { stored, result }; +} + +describe("team.invite to an address invited before", () => { + test("keeps the link of the invite still open, so the first mail keeps working", async () => { + const { stored, result } = await reinvite({ + token: "first-mail", + status: "pending", + expiresAt: new Date(Date.now() + DAY_MS), + }); + // Kept only if the row is still that open invite when the write runs; else the new link. + const kept = stored.set?.token; + expect(typeof kept).not.toBe("string"); + expect(new PgDialect().sqlToQuery(kept as SQL).params).toEqual([ + "first-mail", + stored.values?.token, + ]); + expect(result.inviteUrl.endsWith("/invite/first-mail")).toBe(true); + }); + + test("issues a new link once the old one has expired", async () => { + const { stored, result } = await reinvite({ + token: "first-mail", + status: "pending", + expiresAt: new Date(Date.now() - DAY_MS), + }); + expect(stored.set?.token).toBe(stored.values?.token); + expect(stored.set?.token).toHaveLength(64); + expect(result.inviteUrl.endsWith("/invite/first-mail")).toBe(false); + }); + + test("issues a new link for an invite to somewhere else, so a mailed link keeps its meaning", async () => { + // First a hand-off landing on the approval page, then a plain team invite. + const { stored } = await reinvite({ + token: "first-mail", + status: "pending", + expiresAt: new Date(Date.now() + DAY_MS), + redirectPath: "/de/preise/freigabe", + }); + expect(stored.set?.token).toBe(stored.values?.token); + expect(stored.set?.token).not.toBe("first-mail"); + }); + + test("issues a new link after a withdrawal, so the withdrawn one stays dead", async () => { + const { stored } = await reinvite({ + token: "first-mail", + status: "revoked", + expiresAt: new Date(Date.now() + DAY_MS), + }); + expect(stored.set?.token).toBe(stored.values?.token); + expect(stored.set?.token).not.toBe("first-mail"); + }); +}); + +/** `resendInvite` over one stored invite, or none. */ +async function resend( + stored: (ExistingInvite & { email: string }) | undefined, + options: { + readonly joinedMeanwhile?: boolean; + /** Where the invite lands; the approval page makes it a hand-off to management. */ + readonly redirectPath?: string | null; + /** Who holds the company's account. */ + readonly holder?: string; + } = {}, +) { + const { joinedMeanwhile = false, redirectPath = null, holder = OWNER } = options; + const writes: { set: { token: string; expiresAt: Date }; params: unknown[] }[] = []; + const db = { + query: { + user: { findFirst: async () => (joinedMeanwhile ? { id: MEMBER } : undefined) }, + company: { findFirst: async () => ({ name: "Acme" }) }, + companyInvite: { + findFirst: async () => + stored && { + ...stored, + assignmentContext: { roleKeys: ["ceo"], categoryIds: [] }, + redirectPath, + }, + }, + }, + select: () => ({ + from: () => ({ + // isMemberOf builds a subquery from this; nothing reads it. + where: () => ({}), + // holderOf: the account's owner. + innerJoin: () => ({ + where: () => ({ limit: async () => [{ ownerUserId: holder }] }), + }), + }), + }), + update: () => ({ + set: (set: { token: string; expiresAt: Date }) => ({ + where: (where: SQL) => ({ + returning: async () => { + writes.push({ set, params: new PgDialect().sqlToQuery(where).params }); + return [{ id: INVITE_ID }]; + }, + }), + }), + }), + }; + const result = await adminCaller(db).resendInvite({ inviteId: INVITE_ID }); + return { result, writes }; +} + +describe("team.resendInvite", () => { + test("an open invite keeps its link and gets seven more days", async () => { + const before = Date.now(); + const { result, writes } = await resend({ + token: "first-mail", + status: "pending", + expiresAt: new Date(before + DAY_MS), + email: "anna@example.test", + }); + expect(result.inviteUrl.endsWith("/invite/first-mail")).toBe(true); + expect(writes[0]?.set.token).toBe("first-mail"); + expect(writes[0]?.set.expiresAt.getTime()).toBeGreaterThanOrEqual( + before + 7 * DAY_MS, + ); + }); + + test("an expired invite gets a new link", async () => { + const { result, writes } = await resend({ + token: "first-mail", + status: "pending", + expiresAt: new Date(Date.now() - DAY_MS), + email: "anna@example.test", + }); + expect(writes[0]?.set.token).not.toBe("first-mail"); + expect(result.inviteUrl.endsWith(`/invite/${writes[0]?.set.token}`)).toBe(true); + }); + + test("writes only to an open invite of the caller's own company", async () => { + const { writes } = await resend({ + token: "first-mail", + status: "pending", + expiresAt: new Date(Date.now() + DAY_MS), + email: "anna@example.test", + }); + // ...and only while the link is still the one read, so a re-invite in between wins. + expect(writes[0]?.params).toEqual( + expect.arrayContaining([INVITE_ID, COMPANY, "pending", "first-mail"]), + ); + }); + + test("refuses an invite that is not open in this company", async () => { + await expect(resend(undefined)).rejects.toThrow("no longer open"); + }); + + const open = { + token: "first-mail", + status: "pending", + expiresAt: new Date(Date.now() + DAY_MS), + email: "anna@example.test", + }; + + test("mails nobody who has joined the company meanwhile", async () => { + await expect(resend(open, { joinedMeanwhile: true })).rejects.toThrow( + "already a member", + ); + }); + + test("a hand-off to management is sent again only by the account holder", async () => { + const handoff = { redirectPath: managementHandoffPath("de"), holder: SECOND }; + await expect(resend(open, handoff)).rejects.toThrow("Only the account holder"); + const { writes } = await resend(open, { ...handoff, holder: OWNER }); + expect(writes).toHaveLength(1); + }); +}); + +describe("team.listInvites", () => { + test("says which links have expired and hands over nothing but what the page shows", async () => { + const now = Date.now(); + const rows = [ + { + id: "a", + email: "open@example.test", + token: "t-open", + expiresAt: new Date(now + DAY_MS), + assignmentContext: { + roleKeys: ["cto"], + categoryIds: ["55555555-5555-4555-8555-555555555555"], + }, + }, + { + id: "b", + email: "late@example.test", + token: "t-late", + expiresAt: new Date(now - DAY_MS), + assignmentContext: null, + }, + ]; + let columns: unknown; + const db = { + query: { + companyInvite: { + findMany: async (opts: { columns: unknown }) => { + columns = opts.columns; + return rows; + }, + }, + }, + }; + const listed = await adminCaller(db).listInvites(); + expect(listed.map((i) => [i.email, i.expired, i.role])).toEqual([ + ["open@example.test", false, "cto"], + ["late@example.test", true, null], + ]); + expect(Object.keys(columns as object).sort()).toEqual( + ["assignmentContext", "email", "expiresAt", "id", "token"].sort(), + ); + expect(Object.keys(listed[0] ?? {}).sort()).toEqual( + ["email", "expired", "expiresAt", "id", "inviteUrl", "role"].sort(), + ); + expect(listed[0]?.inviteUrl.endsWith("/invite/t-open")).toBe(true); + }); +}); + +describe("team.acceptInvite", () => { + test("joins nobody on an invite withdrawn after it was read", async () => { + const inserted: unknown[] = []; + const db = { + query: { + companyInvite: { + findFirst: async () => ({ + id: INVITE_ID, + companyId: "company-2", + invitedBy: OWNER, + email: "anna@example.test", + role: "member", + status: "pending", + expiresAt: new Date(Date.now() + DAY_MS), + assignmentContext: null, + }), + }, + companyMembership: { findFirst: async () => undefined }, + }, + // listUserCompanies: no other organization. + select: () => ({ + from: () => ({ + innerJoin: () => ({ where: () => ({ orderBy: async () => [] }) }), + }), + }), + transaction: async (fn: (tx: unknown) => Promise) => + fn({ + // The claim finds no open invite: it was withdrawn between the read and here. + update: () => ({ + set: () => ({ where: () => ({ returning: async () => [] }) }), + }), + insert: (table: unknown) => { + inserted.push(table); + return { values: () => ({ onConflictDoUpdate: async () => undefined }) }; + }, + }), + }; + const caller = createCallerFactory(teamRouter)({ + db: db as unknown as TRPCContext["db"], + session: { user: { email: "anna@example.test" } } as TRPCContext["session"], + userId: MEMBER, + companyId: "draft-1", + ip: "test", + userAgent: null, + } as TRPCContext); + + await expect(caller.acceptInvite({ token: "t" })).rejects.toThrow("already used"); + expect(inserted, "no membership was written").toEqual([]); + }); +}); diff --git a/server/trpc/routers/team.test.ts b/server/trpc/routers/team.test.ts index 40b738792..38741cc23 100644 --- a/server/trpc/routers/team.test.ts +++ b/server/trpc/routers/team.test.ts @@ -268,6 +268,7 @@ async function storedRedirectPath(redirectPath: string) { query: { user: { findFirst: async () => undefined }, company: { findFirst: async () => ({ name: "Acme" }) }, + companyInvite: { findFirst: async () => undefined }, }, // isMemberOf builds a subquery from this; nothing reads it. select: () => ({ from: () => ({ where: () => ({}) }) }), @@ -277,7 +278,7 @@ async function storedRedirectPath(redirectPath: string) { return { onConflictDoUpdate: ({ set }: { set: unknown }) => { stored.set = set; - return { returning: async () => [{ id: "invite-1" }] }; + return { returning: async () => [{ id: "invite-1", token: "t" }] }; }, }; }, diff --git a/server/trpc/routers/team.ts b/server/trpc/routers/team.ts index 2d574932e..445354c61 100644 --- a/server/trpc/routers/team.ts +++ b/server/trpc/routers/team.ts @@ -1,26 +1,16 @@ import { TRPCError } from "@trpc/server"; import { and, desc, eq, inArray, ne, or, sql } from "drizzle-orm"; import { z } from "zod"; -import { getPathname } from "@/i18n/navigation"; import { logAudit } from "@/lib/audit"; import { holderOf } from "@/lib/billing/accounts"; import { invalidateModuleSignOffs } from "@/lib/compliance/module-recheck"; import { ALL_ROLE_KEYS } from "@/lib/compliance/role-mapping"; import { pendingSignersOf } from "@/lib/compliance/sign-off-roster"; -import { MANAGEMENT_ROLE, walkLocaleOf } from "@/lib/durchgang/types"; +import { MANAGEMENT_ROLE } from "@/lib/durchgang/types"; import { LIFECYCLE_ENTITY_TYPE } from "@/lib/lifecycle/types"; import { LOCALE_CODES } from "@/lib/locale"; -import { - accountEmailLocale, - inviteEmail, - managementHandoffEmail, - memberRemovedEmail, - sendMail, -} from "@/lib/mail"; -import { isSuppressedSendId } from "@/lib/mail/delivery"; -import { resolveEmailLocale } from "@/lib/mail/locale"; -import { mailSuppressionReason } from "@/lib/mail/send"; import { inviteRedirectPath } from "@/lib/organization/invite-redirect"; +import { isInviteExpired } from "@/lib/organization/invite-state"; import { asMembershipRole, findMembershipRole, @@ -34,7 +24,6 @@ import { setMembershipRole, signupDraftOf, } from "@/lib/organization/membership"; -import { rateLimit } from "@/lib/rate-limit"; import { categoryAssignment, company, @@ -49,10 +38,25 @@ import { user, } from "@/schema"; import { - INVITE_EXPIRY_DAYS, invitedRoleOf, + inviteUrlOf, issueInvite, + renewInvite, } from "../helpers/company-invite"; +import { + authorizeResend, + enforceDailyInviteCap, + FORWARD_LIMIT, + INVITE_LIMIT, +} from "../helpers/invite-limits"; +import { + inviteSenderOf, + mailManagementHandoff, + mailRenewedInvite, + mailTeamInvite, + managementHandoffPath, +} from "../helpers/invite-mail"; +import { mailMemberRemoved } from "../helpers/member-removed-mail"; import { getNis2AssessmentIds } from "../helpers/nis2-scope"; import { resolveRoleAssignments } from "../helpers/resolve-role-assignments"; import { discardDraftCompany } from "../helpers/setup-helpers"; @@ -63,27 +67,6 @@ import { router, } from "../init"; -/** Hand-offs to management one company may send per day: a few retries above honest use. */ -const FORWARD_LIMIT = 5; -/** Team invites one company may send per day: room to onboard a whole department in one sitting. */ -const INVITE_LIMIT = 50; -const DAY_MS = 24 * 60 * 60 * 1000; - -/** Every invite mails an address someone typed in, so each kind is capped per company per day. */ -async function enforceDailyInviteCap( - kind: "invite" | "forward", - companyId: string, - limit: number, -): Promise { - if (!(await rateLimit(`team:${kind}:${companyId}`, limit, DAY_MS))) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: - "Your organization has sent many of these today. Please try again tomorrow.", - }); - } -} - export const teamRouter = router({ /** List members of the current company with their category assignments */ listMembers: protectedProcedure.query(async ({ ctx }) => { @@ -137,15 +120,35 @@ export const teamRouter = router({ })); }), - /** List pending invites for the current company (admin only) */ + /** + * The current company's open invites (admin only), each saying whether its link has expired, so + * the team page can offer to send it again instead of listing a dead link as pending. + */ listInvites: adminProcedure.query(async ({ ctx }) => { - return ctx.db.query.companyInvite.findMany({ + const rows = await ctx.db.query.companyInvite.findMany({ where: and( eq(companyInvite.companyId, ctx.companyId), eq(companyInvite.status, "pending"), ), + columns: { + id: true, + email: true, + token: true, + expiresAt: true, + assignmentContext: true, + }, orderBy: desc(companyInvite.createdAt), }); + const now = new Date(); + return rows.map((row) => ({ + id: row.id, + email: row.email, + // The link as the mail carries it, so a copied link is the same one. + inviteUrl: inviteUrlOf(row.token), + expiresAt: row.expiresAt, + expired: isInviteExpired(row.expiresAt, now), + role: invitedRoleOf(row.assignmentContext), + })); }), /** Create an invite link (admin only) */ @@ -183,57 +186,42 @@ export const teamRouter = router({ assignmentContext: input.assignmentContext, }); - // Send invite email (fire-and-forget) - const companyRow = await ctx.db.query.company.findFirst({ - where: eq(company.id, ctx.companyId), - columns: { name: true }, - }); - const inviterName = ctx.session.user.name ?? "Your team admin"; - const emailRole = input.complianceRole ?? "member"; - - // Asked before sending, not after, because the send is fire-and-forget - // and the answer has to reach the caller. An instance with no transport - // (the BOOTSTRAP_ADMIN route, where the operator never configured mail) - // can still create the invite — the row and the token are real — but - // nobody will receive it, so the UI has to say so and offer the link - // instead of reporting a send that did not happen. - const emailed = mailSuppressionReason() === null; - // The mail's language must never cost the invite: an unreadable account sends the default. - const inviterEmail = ctx.session.user.email; - const locale = inviterEmail - ? await accountEmailLocale(inviterEmail).catch(() => undefined) - : undefined; - - inviteEmail({ - companyName: companyRow?.name ?? "your company", - inviterName, + // Awaited: the row and the token are real either way, but only the send says whether the + // person will hear of it. Where it did not leave (no transport on a self-hosted instance, a + // failed send) the UI says so and hands over the link instead of reporting a send. + const emailed = await mailTeamInvite(await inviteSenderOf(ctx), { + to: email, inviteUrl, - role: emailRole, - locale, - }) - .then((content) => - sendMail({ emailType: "account.invite", to: email, ...content }), - ) - .then((r) => { - // isSuppressedSendId, not r.success: a suppressed send also reports - // success, and an audit trail claiming "invite email sent" for mail - // that never left the box is worse than no line at all. - if (r.success && "id" in r && !isSuppressedSendId(r.id)) { - logAudit({ - companyId: ctx.companyId, - userId: ctx.userId, - action: "email.invite_sent", - entityType: "email", - entityId: r.id ?? null, - description: `Invite email sent to ${email}`, - }); - } - }) - .catch((err) => console.error("[team] invite email not sent", err)); + role: input.complianceRole ?? "member", + }); return { inviteId, token, inviteUrl, emailed }; }), + /** + * Send an open invite again (admin only): seven more days, the same link while it still works + * and a new one once it has expired. The invite must be this company's; capped like `invite`, + * since every send mails an address someone typed in. + */ + resendInvite: adminProcedure + .input(z.object({ inviteId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const renewed = await renewInvite( + ctx.db, + { inviteId: input.inviteId, companyId: ctx.companyId }, + (invite) => authorizeResend(ctx.db, ctx, invite), + ); + if (!renewed) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "This invite is no longer open.", + }); + } + + const emailed = await mailRenewedInvite(await inviteSenderOf(ctx), renewed); + return { inviteUrl: renewed.inviteUrl, emailed }; + }), + /** * The hand-off at the walk's lock: the account holder invites management into this company to * decide on the order. The same invite as above, for the management role, landing on the @@ -266,42 +254,16 @@ export const teamRouter = router({ companyId: ctx.companyId, invitedBy: ctx.userId, email, - redirectPath: inviteRedirectPath( - getPathname({ href: "/pricing/approval", locale: input.locale }), - ), + redirectPath: managementHandoffPath(input.locale), complianceRole: MANAGEMENT_ROLE, }); - const companyRow = await ctx.db.query.company.findFirst({ - where: eq(company.id, ctx.companyId), - columns: { name: true }, - }); - const emailed = mailSuppressionReason() === null; - - managementHandoffEmail({ - locale: walkLocaleOf(input.locale), - name: input.name, - inviterName: ctx.session.user.name ?? null, - companyName: companyRow?.name ?? null, + const emailed = await mailManagementHandoff(await inviteSenderOf(ctx), { + to: email, inviteUrl, - days: INVITE_EXPIRY_DAYS, - }) - .then((content) => - sendMail({ emailType: "account.invite", to: email, ...content }), - ) - .then((r) => { - if (r.success && "id" in r && !isSuppressedSendId(r.id)) { - logAudit({ - companyId: ctx.companyId, - userId: ctx.userId, - action: "email.invite_sent", - entityType: "email", - entityId: r.id ?? null, - description: `Management invite email sent to ${email}`, - }); - } - }) - .catch((err) => console.error("[team] management invite email not sent", err)); + name: input.name, + locale: input.locale, + }); return { emailed }; }), @@ -348,7 +310,7 @@ export const teamRouter = router({ }); } - if (invite.expiresAt < new Date()) { + if (isInviteExpired(invite.expiresAt, new Date())) { throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired.", @@ -379,6 +341,22 @@ export const teamRouter = router({ }); await ctx.db.transaction(async (tx) => { + // Claimed first, and only while still open: a withdrawal that lands after the read above + // wins, and nobody joins on a withdrawn invite. + const [claimed] = await tx + .update(companyInvite) + .set({ status: "accepted", acceptedBy: ctx.userId, acceptedAt: new Date() }) + .where( + and(eq(companyInvite.id, invite.id), eq(companyInvite.status, "pending")), + ) + .returning({ id: companyInvite.id }); + if (!claimed) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invite not found or already used.", + }); + } + // An existing member keeps their role; the invite does not change it. Otherwise the invite // row's free-text role falls back to the least privileged one a membership can hold, and // an invite for a compliance role (management, asked to approve the walk's documents) @@ -400,15 +378,6 @@ export const teamRouter = router({ }); } } - - await tx - .update(companyInvite) - .set({ - status: "accepted", - acceptedBy: ctx.userId, - acceptedAt: new Date(), - }) - .where(eq(companyInvite.id, invite.id)); }); // Discard the now-abandoned draft shell when nobody else is in it (best-effort, post-commit, @@ -422,6 +391,17 @@ export const teamRouter = router({ } } + // Filed under the company joined. The automatic row goes under the session's company, which + // for a new account is the draft just discarded: that write fails, and no joining was logged. + logAudit({ + companyId: invite.companyId, + userId: ctx.userId, + action: "team.invite_accepted", + entityType: "team", + entityId: invite.id, + description: `Invite accepted by ${invite.email}`, + }); + // Auto-assign based on the invite's assignment context if (invite.assignmentContext) { await applyAssignmentContext( @@ -582,34 +562,13 @@ export const teamRouter = router({ await leaveCompany(tx, { userId: input.userId, companyId: ctx.companyId }); }); - // Notify removed member (fire-and-forget) - const companyRow = await ctx.db.query.company.findFirst({ - where: eq(company.id, ctx.companyId), - columns: { name: true }, + await mailMemberRemoved({ + db: ctx.db, + companyId: ctx.companyId, + actorId: ctx.userId, + member, }); - memberRemovedEmail({ - companyName: companyRow?.name ?? "your company", - memberName: member.name, - locale: resolveEmailLocale(member.locale, null), - }) - .then((content) => - sendMail({ emailType: "account.member_removed", to: member.email, ...content }), - ) - .then((r) => { - if (r.success) { - logAudit({ - companyId: ctx.companyId, - userId: ctx.userId, - action: "email.member_removed", - entityType: "email", - entityId: r.id ?? null, - description: `Removal email sent to ${member.email}`, - }); - } - }) - .catch((err) => console.error("[team] removal email not sent", err)); - // Losing a member changes who holds which role, which is the evidence // behind requirement 1.2. Every other module reverts its requirements // when its data changes; the team module did not, so a company could