Skip to content

feat(members): enrolment tokens and join requests for Business teams - #828

Merged
kipavy merged 14 commits into
devfrom
feat/enrollment-tokens
Nov 9, 2026
Merged

kipavy merged 14 commits into
devfrom
feat/enrollment-tokens

Conversation

@kipavy

@kipavy kipavy commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Adds the admin side of enrolment tokens for Business teams: a team admin creates one company-wide token and decides who joins through it. Part of #757. The server part shipped in VoltiusApp/server#128 and is deployed (sha-e5af0d4). voltius-cli enroll and the first-launch screen that use the token come in a later PR.

What changes

  • Device enrolment (vault share sheet › Links, below join links; needs the invite permission): create a token with a role (any built-in role except Owner), allowed email domains, admin approval, or both, and an optional expiry. The token is shown once with Copy and a voltius-cli enroll <token> example, and is never stored. The live token shows its guards, who created it, when it was last used, and "N joined · M waiting", which update as people join. Rotate and Revoke ask first; rotating keeps the guards and a future expiry, and an expired token says so. On a server without email delivery the approval switch is forced on. Below Business the section shows the lock line and keeps Revoke. An older server without these routes shows nothing.
  • Join requests (Members page, also on mobile; only for members who can invite): one row per waiting person with the reason (waiting for approval, waiting for email verification, no seat left) and Approve / Deny. Approve is hidden only for an unconfirmed email. "No seat left" keeps Approve, since the server never clears that state by itself; approving again after adding seats admits the person. A new join_requests:<team> event refreshes only this list. The list is cleared and guarded across account switches.
  • Token string vte1.<server>.<id>.<secret>: one TS helper builds and parses it from the account's canonical server URL, with a shared fixture in src-tauri/src/policy/fixtures/enrollment_token.json for the CLI's Rust twin later.
  • Share sheet fix: a confirm dialog opened from inside the share popover no longer closes the popover. Before, clicking the dialog's button counted as an outside click. The fix is in useAnchoredPopover, so every PickerSurface gets it.
  • Small refactors along the way: one useServerCapability hook shape for server capability flags, shared base64url helpers (passkeys use them too, byte-identical output), a shared copy field for one-time secrets used by join links and enrolment, and BusinessLockLine gains a compact size.

Screenshots

Desktop and phone layout from the built branch against a private sha-e5af0d4 server, including the rotate flow with real clicks: https://claude.ai/artifact/NMdGCyWfsatGHf7tdJiVsm. The "no seat left" request, the lapsed team and the admin's confirmed email were set by hand, since a self-hosted test server can't reach them by itself.

Tests

tsc clean; 916 test files / 8269 tests pass.

Known gaps, left for later

  • Join request rows have no role chip (the mockup had one): the server doesn't send the role, and each token has a single role.
  • On phones, the existing Members page toolbar overlaps slightly at 412 px. That predates this branch.

Docs: VoltiusApp/docs PR to follow.

kipavy added 14 commits November 9, 2026 11:51
…ttern

bytesToBase64Url/base64UrlToBytes live in utils/base64 and replace the
hand-rolled copies in enrollmentToken and passkeyRoundTrip. The token
parser reuses GRANT_SECRET_RE, and buildEnrollmentToken refuses a server
URL that is not http(s) instead of encoding it anyway.
useServerCapability(flag) carries the refresh effect and the selector;
the named hooks stay as one-line wrappers because many callers and test
mocks use them.
The select-on-focus input with its Copy button moves out of JoinLinksTab
into CopySecretField so the enrolment token can use it too.
Join requests: Approve stays enabled on seat-limit rows so an admin can
add seats and approve again; below Business it is disabled with the
reason as its title. A pending answer to approve means the email is
still unverified and says so. On a phone the row stacks the person
above the status and actions instead of overflowing.

Enrolment token: a failed load shows the error with a retry and never
the create form, so Create cannot silently rotate a live token; a 404
(older server) or TEAM_INVITES_DISABLED hides the section. Stale loads
for a previous team are dropped. An expired token says so and points at
Rotate, and rotating it keeps its lifetime counted from now. Rotate and
Revoke are disabled while busy, rotate toasts say rotated, domains keep
the part after the last @ and must be hostname-shaped, and bare 400/403
read as sentences.

Refusal mapping and the lapsed-plan refresh live in one place
(enrollmentRefusal, enrollmentErrorMessage, runEnrollmentAction), toast
punctuation follows the rest of members, and the duplicate
error.unavailable string is gone.
The share popover's outside-press handler (useAnchoredPopover) only
counted DOM descendants of the panel, so a press in the Rotate/Revoke
ConfirmModal, portalled to body, closed the popover. Rotation still went
through but the section unmounted and the new secret was lost. The hook
now also counts presses React routes through the panel
(onMouseDownCapture, wired in PickerSurface), so any dialog the panel
opens stays inside it.

The token card refetches its counts when the team's join requests
reload (the join_requests SSE signal) and keeps showing the fresh
token. BusinessLockLine gains a compact size, used in the 11 px
enrolment section; its upgrade link now takes the line's size.
@kipavy
kipavy merged commit 0644367 into dev Nov 9, 2026
8 checks passed
@kipavy
kipavy deleted the feat/enrollment-tokens branch November 9, 2026 14:19
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