Skip to content

Team page and invites: remove button on screen, confirmation, invites that arrive - #492

Merged
simonorzel26 merged 5 commits into
mainfrom
fix/team-invites
Oct 26, 2026
Merged

simonorzel26 merged 5 commits into
mainfrom
fix/team-invites

Conversation

@simonorzel26

@simonorzel26 simonorzel26 commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Team page

  • The remove button is always on screen. The members table had six columns with cells that never wrap, so inside the portal (max-w-4xl beside the sidebar) it scrolled sideways and the remove button sat off to the right. Members are now a list: columns where the card is wide enough (a container query on the card, since the sidebar takes a varying share of the window), stacked below that. The remove button has a label instead of a bare icon.
  • Removing asks first. A confirmation names the person and says what removal does: they lose access, their categories are unassigned, sign-offs still waiting for them are dropped, open invites they sent are withdrawn, sign-offs they gave stay on record, they get an email. Each of these is what team.removeMember does today.
  • The removal mail is recorded as sent only when it left (wasDelivered), not on a suppressed send.
  • The invite cards move to components/team/TeamInvites.tsx.

Invites

  • Expired invites say so, and can be sent again. The pending list used to show every pending row with only a date, including dead links, and the only way to retry was withdraw and add again. Each invite now says "the link works until …" or, in amber, "the link expired on …", and has Send again (team.resendInvite): seven more days, the same link while it still works, a new one once it has expired. An expired link is not offered for copying.
  • Inviting an address again keeps the first mail working. issueInvite replaced the token on every re-invite, so the link in the earlier mail stopped working and said "invalid or already used". It now keeps the token of an invite that is still open; a withdrawn, accepted or expired invite still gets a new one, so withdrawing ends a link and an expired link is never revived.
  • "Emailed" means the mail left. team.invite and team.forwardToManagement reported emailed: true whenever a transport was configured and sent fire-and-forget. The send is now awaited through one helper (server/trpc/helpers/invite-mail.ts), and emailed is true only when the transport took the mail. The team page says when it did not go out and hands over the link.
  • The invite link opens without a session again. Since the public allowlist inverted, /invite/[token] redirected anyone signed out straight to a bare login form that named neither the company nor the address to use. It is public again (lib/public-paths.ts, kept on self-hosted installs) and shows who invited them where, as which role, and which address to use, with Create account (register mode, address filled in) and I already have an account. Accepted, withdrawn, expired and replaced links each get their own message.
  • Setup invites everyone it names. processTeamRoleAssignments skipped anyone whose role owns no category (the DPO owns none in NIS 2) and mailed the others from inside the setup transaction. It now invites them all, and the router mails after the commit. The procedure's input schema moves to setup-helpers.ts unchanged.
  • One rule for when an invite has expired (lib/organization/invite-state.ts), used by the accept page, the team page, the procedures and the platform admin list.
  • A join is logged under the company joined. The automatic audit row of team.acceptInvite goes under the session's company, which for a new account is the draft that accepting discards, so the row failed on its foreign key and no join of a new account was ever logged. acceptInvite now writes team.invite_accepted under the inviting company. The automatic row still fails in that case (shared middleware, see below).

Review round (code-review, high)

Fixed:

  • Re-invite race: the kept token is decided again inside the upsert (CASE on the row's status and token), so a withdrawal landing between the read and the write cannot bring its link back; the returned token is the one written.
  • Setup takes only known roles, one person each: teamRoles[].roleKey is z.enum(ALL_ROLE_KEYS) and the list is capped at the number of roles, since everyone named is now invited and mailed.
  • Send again keeps the hand-off a hand-off: an invite that lands on the approval page goes out again as the management hand-off mail, in the language it was sent in (read back from its landing path); its greeting has no name, which the template already handles.
  • Role labels in the English invite mail: it printed the key ("as hr_director", "as member"); it now uses the team page's labels, as the Czech mail already did.
  • Send again refuses someone who has joined meanwhile, like issueInvite.
  • A used link no longer names the company.
  • One invite mail assembly (inviteSenderOf, mailTeamInvite, mailManagementHandoff, mailRenewedInvite); setup uses it too. The copied link is the server's (inviteUrlOf), not one rebuilt from the browser's origin.
  • Days come from INVITE_EXPIRY_DAYS (moved to lib/organization/invite-state.ts).

Kept: r.success && wasDelivered(r) in the removal mail; r.success narrows the type for r.id.

Not taken: German and other translations of the new strings (English first, by decision; see below).

Second review round (code-review, high) and independent security review

The security review found nothing high or medium; seven low findings, four of them the same as the code review's. Fixed:

  • Send again on a hand-off follows the hand-off's rules: the account holder only, under the 5-a-day cap (authorizeResend); before, any admin could re-send a hand-off under the 50-a-day invite cap. The caps move to server/trpc/helpers/invite-limits.ts unchanged.
  • Accept claims the invite first, in the same transaction and only while it is pending; a withdrawal that lands after the read now wins (older than this PR, but the PR promises that withdrawing ends a link).
  • Send again writes only while the link is still the one it read, so a withdraw-and-re-invite in between is not undone.
  • A re-invite keeps the open link only when it lands in the same place, so a mailed hand-off link does not turn into a plain team invite.
  • Setup: each role names one person (the comment claimed it, the schema did not enforce it); someone already in the company is not invited again (the upsert reset their accepted invite, which acceptedInviteOf reads for order rights); its mails count against a per-person cap and go out without setup waiting on them.
  • The English invite mail reads its role labels from messages/team/en.json.

Not taken, with reason:

  • Store the invite's kind instead of reading a hand-off from its landing path: needs a migration; the path is the stored fact today, and a test checks it reads back for every locale.
  • Free accounts cannot withdraw an invite: they cannot reach the team page at all, so this needs UI, not only an allowlist entry.
  • inviteLinkState beside the platform admin's inviteStatus: they answer different questions; the shared fact is the expiry rule.
  • "Send again" keeps a working link working: intended; withdrawing is how a link is ended.

Security check

  • team.resendInvite: adminProcedure (company admin, paid tier, like listInvites and revokeInvite). It reads and writes only a pending row with this company's id and the token it read; the conditions are in the write too, and a unit test asserts the write's parameters. It mails only the address already on the row, never a new one; a team invite counts against team.invite's cap, a hand-off is the account holder's alone under the hand-off cap. Not reachable with an assistant key (not in lib/mcp/procedures.ts).
  • team.listInvites now returns only what the page shows (id, email, link, expiry, expired, role); before it returned every column. The link was and is returned to the company's admin, for copying.
  • Setup (assessment.createCompanyAndAssessment) now invites everyone it names, so its input takes only the known role keys, each role once; existing members are skipped; its mails count against a per-person daily cap.
  • /invite/[token] without a session shows the company name, the role and the invited address to whoever holds the token (32 random bytes). Any signed-in account holding the token could already read company and address on the wrong-account card. Accepting still needs the invited account (team.acceptInvite unchanged). The page is noindex. Analytics drops token routes and every query key but campaign tags, so neither the token nor the email handed to sign-in leaves.
  • ?email= on the sign-in link carries the invitee's own address on their own device. It only fills the field, which stays editable.
  • No raw HTML, no secrets, no new public mutation.

Tests

  • Unit: invite expiry, token reuse and link states (lib/organization/invite-state.test.ts); the mail helper reports sent only for a delivered mail and never throws (invite-mail.test.ts); re-invite keeps a live token and replaces an expired or withdrawn one, resend renews and is tenant-scoped, the list's shape (team-invites.test.ts); setup invites a role without categories, hands invites back for mailing and takes only known roles (setup-helpers.test.ts); a hand-off's language reads back from its landing path in every locale (invite-mail.test.ts); the English invite mail names roles by label (czech-mail.test.ts). Second round: a hand-off is sent again only by the holder, resend writes only while the link is unchanged, a re-invite to another landing page gets a new link, accept joins nobody on an invite withdrawn after it was read (team-invites.test.ts); setup takes each role once and skips existing members (setup-helpers.test.ts). Unit suite: 2806 pass, 0 fail.
  • E2E: e2e/l3/team-invites.spec.ts (new area team), run locally against the production build: the member list fits at 1280 and 390 px with the remove button in view, cancel removes nobody, confirm removes; the "did not go out" note; re-invite keeps the token; an expired invite shows as expired, is sent again with a new link and withdrawn; a visitor without a session sees whose invite it is, lands on account creation with the address filled in, and withdrawn and unknown links say so; a colleague without an account opens the link, creates an account with the invited address, confirms the code, accepts, and is a member, with the join in the company's log. 6 passed.
  • Rendered on the dev server at 1440x900 and 390x844: no sideways scroll at either width.

Not in this PR

  • The invite mail still says "expires in 7 days" as text in its English and Czech copy, rather than reading INVITE_EXPIRY_DAYS.
  • New strings are in English only; other locales fall back to English per key. team.inline.notEmailed changed meaning in English (the mail did not go out, not only "no transport"); the other locales keep the old wording until the translation pass.
  • The automatic audit row is written for a mutation that failed, so a refused team.invite (for example inviting someone already in the company) is logged like a sent one, and it is filed under the session's company even when the handler has just deleted it (the join above). That is the tRPC middleware in packages/isms-trpc, shared by every procedure, so it is left for its own change.
  • team.usedTitle / team.usedDescription and a few table-header keys are no longer read; left in place for the translation pass to remove in every locale at once.

…nd invites that arrive

Team page
- The members list wraps instead of scrolling sideways inside the portal, so the remove action is
  always on screen (columns only where the card is wide enough, measured on the card).
- Removing a member asks first, and says what removal does.
- The removal mail is recorded as sent only when it left.
- The invite cards move to components/team/TeamInvites.tsx.

Invites
- Expired invites say so on the team page, and every open invite can be sent again: seven more
  days, the same link while it still works, a new one once it has expired.
- Inviting an address again keeps the link of the invite still open, so the first mail keeps
  working; a withdrawn or expired link is never revived.
- The send is awaited: "emailed" is true only when the mail left, never because a transport
  exists. The team page says when it did not go out and hands over the link.
- The invite link opens for visitors without a session again: it names the company, the role and
  the address to use, and leads to account creation with that address filled in. Accepted,
  withdrawn, expired and replaced links each say what happened.
- Setup invites a person whose role owns no category (the DPO owns none in NIS 2), and mails its
  invites only after the transaction commits. Its input schema moves to setup-helpers.ts.

One rule for when an invite has expired (lib/organization/invite-state.ts), used by the accept
page, the team page, the procedures and the platform admin list.
- Re-invite decides the kept token again inside the upsert, so a withdrawal racing it cannot
  bring its link back.
- Setup takes only the known role keys, at most one entry per role, now that everyone it names is
  invited and mailed.
- Send again on a hand-off to management sends the hand-off mail, in the language it was sent in.
- The English invite mail names roles by their label instead of the key.
- Send again refuses someone who has joined meanwhile.
- A used invite link no longer names the company.
- One invite mail assembly for the team page, the hand-off, send again and setup; the copied link
  is the server's.
- INVITE_EXPIRY_DAYS lives in lib/organization/invite-state.ts and feeds the resend message.
Accepting an invite was never logged for a new account: the automatic audit row goes under the
session's company, which for a new account is the draft that accepting discards, so the write
failed on its foreign key. acceptInvite now writes "team.invite_accepted" under the company joined.

The e2e spec now walks the step that makes someone a member: open the link signed out, create an
account with the invited address, confirm the code, accept, and end up a member, with the join in
the company's log.

The member-removed mail moves unchanged to server/trpc/helpers/member-removed-mail.ts.
- Send again on a hand-off to management follows the hand-off's rules: the account holder only,
  under the 5-a-day cap. The caps move to server/trpc/helpers/invite-limits.ts unchanged.
- Accepting claims the invite first, and only while it is still open, so a withdrawal that lands
  after the read wins and nobody joins on a withdrawn invite.
- Send again writes only while the link is still the one it read, so a re-invite in between wins.
- Inviting an address again keeps the open link only for an invite to the same place, so a mailed
  link keeps meaning what it said.
- Setup: each role names one person (it said so and did not enforce it), someone already in the
  company is not invited again (the upsert reset their accepted invite), and its mails count
  against a cap per person and go out without setup waiting on them.
- The English invite mail reads the role labels from messages/team/en.json.
- The invite procedures' tests move to server/trpc/routers/team-invites.test.ts.
@simonorzel26
simonorzel26 merged commit beef40a into main Oct 26, 2026
8 checks passed
@simonorzel26
simonorzel26 deleted the fix/team-invites branch October 26, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant