feat: admin-initiated user invitations (#100)#103
Merged
Conversation
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 "&". A sender that emits plain text (the sample's dev logger) writes that verbatim, so the copied link reaches the browser as "?userId=…&code=…" — the query splits on "&", 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
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.
Open
4 tasks
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.
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/Registerredirects 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/AcceptInvitationand lands signed in. Registration stays closed; no colleague's password ever passes through an admin's hands.Design notes
UserInvitationService, not the store.UserStore.CreateAsyncis 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/#69defence-in-depth convention. A page[Authorize]alone would have been the only barrier otherwise.Register, so gating it behind theUserRegistrationflag would disable the only way to add a user precisely when registration is off. A middleware test pins this.IUserInvitationEmailSenderis optional; the default reuses the host's existingIEmailSender<User>reset path, so no host must change to upgrade. Register your own for proper invitation copy.Decisions taken (with the repo owner)
Tests
/Account/AcceptInvitationpasses through even with every feature disabled — the deterministic proof that the flow works when registration is off.Full suite green: 371 fast tests + 26 E2E, no regressions.