Skip to content

feat: admin-initiated user invitations (#100)#103

Merged
andregoepel merged 4 commits into
mainfrom
feature/100-user-invitations
Jul 17, 2026
Merged

feat: admin-initiated user invitations (#100)#103
andregoepel merged 4 commits into
mainfrom
feature/100-user-invitations

Conversation

@andregoepel

Copy link
Copy Markdown
Owner

Closes #100.

Problem

With self-service registration disabled — the default, and the right setting for a staff-only backend — there was no way to create a second user at all. /Account/Register redirects to login, and Administration → Users had no create/invite action. So the root administrator from setup was the only account that could ever exist, unless the operator turned on public registration for the whole internet or hand-inserted rows into Postgres.

What this adds

An admin invites a colleague by email from Administration → Users → Invite user. The account is created passwordless and unconfirmed, and a single-use link is emailed. The invitee sets their own password on /Account/AcceptInvitation and lands signed in. Registration stays closed; no colleague's password ever passes through an admin's hands.

Design notes

  • The authorization check lives in UserInvitationService, not the store. UserStore.CreateAsync is intentionally ungated (self-service registration is anonymous), so the invite path enforces "administrator only" itself — verified against the live projection, failing closed for an unidentified caller, consistent with the #41/#69 defence-in-depth convention. A page [Authorize] alone would have been the only barrier otherwise.
  • The accept page is deliberately not feature-gated. It is the counterpart to Register, so gating it behind the UserRegistration flag would disable the only way to add a user precisely when registration is off. A middleware test pins this.
  • Single use falls out of the design. Accepting sets a password, which rotates the security stamp the token is bound to, so a forwarded/replayed link stops validating. No extra bookkeeping.
  • Pending invitees stay off the forgot-password path (it requires a confirmed email), so a pending invitation can't be redirected by someone who guesses the address. Re-inviting an accepted account is refused, so "resend" can't become an unauthenticated reset.
  • Non-breaking email. IUserInvitationEmailSender is optional; the default reuses the host's existing IEmailSender<User> reset path, so no host must change to upgrade. Register your own for proper invitation copy.
  • Dedicated 7-day token lifespan, host-overridable, independent of the 1-day password-reset default.

Decisions taken (with the repo owner)

  • Optional invite-email interface with a fallback (non-breaking) — vs. reusing reset mail or a required abstraction.
  • Dedicated 7-day, configurable token provider — vs. reusing the 1-day default.
  • Password-only accept for now; the invitee can add a passkey afterwards via the existing Manage → Passkeys page.

Tests

  • Integration (real Postgres): authorization matrix (anonymous / non-admin / admin), passwordless+unconfirmed account shape, role assignment, duplicate refusal, re-invite-after-accept refusal — 7 tests.
  • bUnit: the accept page refuses to render its form for a missing/garbled query, unknown user, or rejected token; renders it for a valid one.
  • Feature-gate middleware: /Account/AcceptInvitation passes through even with every feature disabled — the deterministic proof that the flow works when registration is off.
  • E2E (Aspire + Playwright): full invite → accept → signed-in, and the link cannot be replayed after acceptance.

Full suite green: 371 fast tests + 26 E2E, no regressions.

Add an admin-only "Invite user" flow so hosts can add people while
self-service registration stays disabled (the secure default). Closes the
gap where the root admin was the only account that could ever exist unless
public registration was turned on for everyone.

An administrator invites by email; the account is created passwordless and
unconfirmed, and a single-use link is emailed. The invitee sets their own
password on /Account/AcceptInvitation and lands signed in — no password
ever passes through the admin's hands.

Core:
- UserInvitationService enforces the admin check itself, because
  UserStore.CreateAsync is intentionally ungated (anonymous registration).
  Fails closed for an unidentified caller, refuses to re-invite an
  already-accepted account, and refuses duplicate emails.
- Dedicated invitation token provider with its own 7-day lifespan
  (host-overridable), independent of the 1-day password-reset default.

Blazor:
- AcceptInvitation page (AllowAnonymous, deliberately NOT feature-gated, so
  it works precisely when registration is disabled). Verifies the token,
  sets the password, confirms the email, signs in via the existing handoff.
  Single use falls out of the security-stamp rotation.
- IUserInvitationEmailSender with a default that reuses the host's existing
  password-reset mail path, so upgrading breaks no host; register your own
  for proper invitation copy.
- Invite dialog on Administration -> Users, plus an "Invited" badge and a
  "Resend invitation" row action for pending accounts.

Tests: authorization matrix + account shape + re-invite refusal
(integration), invalid-link gate (bUnit), the feature-gate exemption, and
the full invite -> accept -> signed-in flow with replay protection (E2E).

Closes #100
The sample's Setup page opted out of LoginLayout and hand-rolled a bare,
centred RadzenStack, so it rendered in the default app layout instead of
the branded login card — visibly inconsistent with the login page it sits
next to.

Use @layout LoginLayout and the ag-login-head/ag-login-sub heading and
field styling the login page uses, so first-run setup and login share the
same card. Drop the now-dead topbar breadcrumb (there is no topbar under
the login layout). Field names and the submit text are unchanged, so the
E2E admin-provisioning flow is unaffected.
…irmed

The invitation happy-path test asserted the "Confirmed" badge on the
invitee's grid row without narrowing the grid first. The grid pages at 10
rows, so on CI — where the shared suite database has accumulated enough
accounts — the newly-created invitee lands on a later page and Radzen never
renders its row, failing the assertion. Passed locally only because a
fresher database kept the row on page one.

Type the invitee's email into the grid filter first, leaving a single row
on the first page. Deterministic regardless of how many accounts exist.
Two problems surfaced testing an invite in the sample.

1. The generated link was rejected as invalid. The mailer HTML-encoded the
   URL before handing it to the email sender, turning the query separator
   into "&amp;". A sender that emits plain text (the sample's dev logger)
   writes that verbatim, so the copied link reaches the browser as
   "?userId=…&amp;code=…" — the query splits on "&amp;", the code parameter
   is lost, and the token never validates. Pass the raw URL; HTML-encoding
   is the sender's job, done only when it embeds the link in an HTML body.

2. The accept page rendered a card inside the login card with an oversized
   H3. It set @layout LoginLayout but kept the standalone RadzenCard/H3
   markup copied from ResetPassword (which uses no login layout). Drop the
   inner card and use the ag-login-head/ag-login-sub + H4 styling that the
   login and setup pages use, so it sits cleanly in the login card.
@andregoepel
andregoepel merged commit ed5afa6 into main Jul 17, 2026
4 checks passed
andregoepel added a commit that referenced this pull request Jul 18, 2026
…#108)

InvitationTests.Admin_InvitesUser_InviteeSetsPassword_AndIsSignedIn
failed consistently in CI (green locally). After the invitee is
confirmed, the admin reloads the users grid and filters by email before
asserting the "Confirmed" badge. The filter runs over the Blazor-Server
circuit (@oninput -> grid reload); the raw Playwright navigation did not
wait for the circuit, so the input event landed before the handler
attached and was silently dropped. Unfiltered, the fresh row sits on a
later page of the 10-row grid and never renders -- so the assertion
timed out. Only reproduces in CI because the shared suite database holds
enough accounts to push the new row off page one.

- Wait for the circuit after navigating, then filter via a retrying
  helper (FilterUsersGridAsync) that re-types until the row renders --
  the same connect-gap guard the login and setup flows already use.
- Record a Playwright trace per test, saved only on failure and uploaded
  as a CI artifact, so a headless-runner failure can be replayed instead
  of guessed at.

Pre-existing failure introduced with the invitation feature (#103);
unrelated to any dependency change.
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.

No way to create users when self-service registration is disabled (Administration → Users has no "add user")

1 participant