Repository navigation
Team page and invites: remove button on screen, confirmation, invites that arrive - #492
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Team page
max-w-4xlbeside 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.team.removeMemberdoes today.wasDelivered), not on a suppressed send.components/team/TeamInvites.tsx.Invites
pendingrow 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.issueInvitereplaced 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.team.inviteandteam.forwardToManagementreportedemailed: truewhenever a transport was configured and sent fire-and-forget. The send is now awaited through one helper (server/trpc/helpers/invite-mail.ts), andemailedis true only when the transport took the mail. The team page says when it did not go out and hands over the link./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.processTeamRoleAssignmentsskipped 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 tosetup-helpers.tsunchanged.lib/organization/invite-state.ts), used by the accept page, the team page, the procedures and the platform admin list.team.acceptInvitegoes 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.acceptInvitenow writesteam.invite_acceptedunder the inviting company. The automatic row still fails in that case (shared middleware, see below).Review round (code-review, high)
Fixed:
CASEon 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.teamRoles[].roleKeyisz.enum(ALL_ROLE_KEYS)and the list is capped at the number of roles, since everyone named is now invited and mailed.issueInvite.inviteSenderOf,mailTeamInvite,mailManagementHandoff,mailRenewedInvite); setup uses it too. The copied link is the server's (inviteUrlOf), not one rebuilt from the browser's origin.INVITE_EXPIRY_DAYS(moved tolib/organization/invite-state.ts).Kept:
r.success && wasDelivered(r)in the removal mail;r.successnarrows the type forr.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:
authorizeResend); before, any admin could re-send a hand-off under the 50-a-day invite cap. The caps move toserver/trpc/helpers/invite-limits.tsunchanged.pending; a withdrawal that lands after the read now wins (older than this PR, but the PR promises that withdrawing ends a link).acceptedInviteOfreads for order rights); its mails count against a per-person cap and go out without setup waiting on them.messages/team/en.json.Not taken, with reason:
inviteLinkStatebeside the platform admin'sinviteStatus: they answer different questions; the shared fact is the expiry rule.Security check
team.resendInvite:adminProcedure(company admin, paid tier, likelistInvitesandrevokeInvite). It reads and writes only apendingrow 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 againstteam.invite's cap, a hand-off is the account holder's alone under the hand-off cap. Not reachable with an assistant key (not inlib/mcp/procedures.ts).team.listInvitesnow 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.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.acceptInviteunchanged). The page isnoindex. Analytics drops token routes and every query key but campaign tags, so neither the token nor theemailhanded 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.Tests
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/l3/team-invites.spec.ts(new areateam), 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.Not in this PR
INVITE_EXPIRY_DAYS.team.inline.notEmailedchanged meaning in English (the mail did not go out, not only "no transport"); the other locales keep the old wording until the translation pass.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 inpackages/isms-trpc, shared by every procedure, so it is left for its own change.team.usedTitle/team.usedDescriptionand a few table-header keys are no longer read; left in place for the translation pass to remove in every locale at once.