Skip to content

Add workspace admission approvals and invite-by-email to Settings - #3291

Merged
mstomar125 merged 3 commits into
mainfrom
issue-3288-tenant-admissions-ui
Sep 29, 2026
Merged

mstomar125 merged 3 commits into
mainfrom
issue-3288-tenant-admissions-ui

Conversation

@mstomar125

Copy link
Copy Markdown
Contributor

Summary

People who sign in to a workspace (tenant) with admission enforcement land pending and see nothing until an owner or tenant admin approves them. Until now there was no UI for that. This PR adds it.

Settings → Account → "Workspace members" (Pro + Labs "Team synchronization", signed in):

  • Waiting for approval: pending rows from ListTenantMembers (the cloud list_tenant_members returns every non-removed row with its status), each with an Approve button (ApproveAdmission). After an approval the list reloads.
  • Invite by email: InitiateAdmission (the daemon resolves the email through the worker's POST /admission/resolve-invitee, then calls initiate_admission). This creates a pending admission, which the admin then approves above. Handles initiated and already_member (pending/active/other).
  • Visibility: the card renders only if the caller's own roster row (matched on GetIdentity's person id) is an active owner / tenant_admin. Plain members, pending users, un-bound devices, local-only databases, and failed loads (role unknown) see nothing. The card is re-keyed on the active database.

The pending user's own state. The daemon had no pending UX, so this PR adds it in two places:

  • Settings → Account: "Waiting for approval to join X…" (from ListTenantMemberships, which is global).
  • The "Add synced database" dialog's no-workspace step: a pending-only user now sees "a workspace owner or admin hasn't approved you yet…" instead of "you don't belong to any active workspace".

Actionable errors. The Tauri layer (admission_error) maps the daemon's status codes to next steps:

  • Unauthenticated: sign in again.
  • NotFound on invite: the invitee must sign in to Pro once.
  • PermissionDenied on approve: only owners/admins can approve.
  • ResourceExhausted: wait a minute.
  • FailedPrecondition: re-do the browser sign-in.
  • Unavailable: check the connection.
  • Unimplemented: update NodeSpace.
  • Timeouts get their own message; any other code keeps the daemon's message.

RPCs

  • Used: ListTenantMembers, ApproveAdmission, InitiateAdmission, GetIdentity, ListTenantMemberships.
  • No sync change is needed. There's no dedicated list-pending RPC, but ListTenantMembers already carries pending rows with their status.
  • Decline is not offered: no decline/reject RPC exists in the proto. Cloud has remove_from_tenant, but the daemon doesn't expose it.
  • The vendored nodespace_pro.proto is refreshed from sync main. The diff is purely additive: the two admission RPCs and their messages.

Not included

  • CLI equivalent: skipped. packages/cli only talks to the core daemon (nodespace-proto) and has no CloudSyncService client.
  • Invite by any active member: the cloud allows it, but per the issue the UI is owner/admin-only.

Tests

  • workspace-members-card.test.ts (15 tests):
    • Visibility: shown to an owner and to a tenant_admin. Hidden for a member, a pending owner row, an un-bound device, and a failed load.
    • Approve: approve-and-reload, a per-row error that keeps the row, the empty state, and the no-email fallback.
    • Invite: email validation, the trimmed invite with its notice, clear and reload, already_member pending/active, and the no-account error.
  • account-settings.test.ts (+3): the pending notice; no card and no notice for a plain member (with the real wire-shape identity); the card for an owner.
  • add-synced-database-dialog.test.ts: the pending-only case now shows the approval notice, plus a new truly-empty case.
  • Rust: 8 unit tests on admission_error.
  • Mutation-checked: 11 frontend mutants, and 10 were killed. The survivor was removing .trim(). That mutant is equivalent, because type=email inputs already sanitize whitespace. The one Rust mutant (moving the NotFound arm) was killed.
  • bun run test:changed passed on every tier (frontend, scripts, Chromium, Rust nextest). quality:fix is clean. Crate-scoped clippy with -D warnings -D clippy::unused-async is clean.

Live verification

Not done in the app. The browser dev-proxy routes no pro_* commands, so pro_tier falls back to community and the Account page shows "Not available". Exercising this for real needs the Tauri app, the Pro daemon, and an OAuth sign-in to a live cloud tenant, which weren't available in this session. Coverage rests on the component and unit tests above.

Closes #3288

🤖 Generated with Claude Code

@mstomar125

Copy link
Copy Markdown
Contributor Author

Pragmatic code review (medium): APPROVE

The PR follows the existing pro_sync.rs / membership-service.ts / Account-gating patterns. I checked the semantics against sync and cloud origin/main:

  • The GetIdentity person id matches list_tenant_members.person_node_id.
  • isTenantAdmin matches cloud is_tenant_admin: active, and owner or tenant_admin.
  • approve_admission is a no-op on a non-pending row.
  • already_member shapes are handled.
  • The error mapping lines up with the worker and PostgREST codes.

The UI gate is cosmetic, since every write is cloud-authorized and initiate_admission hardcodes member. There are no #NNNN references in comments, no lint suppressions, and no changes to packages/agent.

Critical: none.

Improvements

  1. account-settings.svelte: {#key databaseStore.activeDatabaseId} re-mounts the card before pro_activate_database resolves. The first load can therefore hit the previous tenant, and an Approve or Invite click then lands on the new one.
  2. pro_sync.rs: the Unavailable arm drops the daemon message. Worker 502/503 errors (upstream error, missing service-role key) then read as connectivity problems.
  3. workspace-members-card.svelte: there's no test for a failed refresh once the admin view is showing. The card should keep showing, with an error and Retry.

Nits: workspaceLabel duplicates tenantLabel; overlapping load() calls can resolve out of order; the Invite NotFound arm drops msg.

@mstomar125

Copy link
Copy Markdown
Contributor Author

Address-review (commit on top of 13a0d58)

Fixed

  1. Stale tenant after a database switch. Added databaseStore.proSyncSettled(), which resolves once the queued pro_activate_database re-targets settle. switchTo sets activeDatabaseId and extends the chain in the same synchronous block, so the re-mounted card's load() awaits it before GetIdentity/ListTenantMembers. A loadSeq guard also drops results from a superseded load, which covers the overlapping-approve nit.
  2. Unavailable detail. The actionable prefix is kept and the daemon message is appended as (…). Added a Rust unit test for this.
  3. Missing refresh-failure test. Added: approve succeeds, the refresh fails, the card stays with the alert, and Retry recovers. Also added a test that the roster isn't read before the Pro re-target settles.

Mutation-checked:

  • Removing the settle-await: killed.
  • Hiding the card on refresh failure: killed.
  • The loadSeq guard: survives. No test drives out-of-order resolution; the guard is a small defensive check.

Declined (nits)

  • Hoisting workspaceLabel/tenantLabel: the two fallbacks differ on purpose.
  • Invite NotFound dropping msg: the worker's 404 has exactly one meaning today.

quality:fix, crate-scoped clippy (-D warnings -D clippy::unused-async) and bun run test:changed (all tiers) pass. Fix 1 is non-trivial, so it gets a re-review.

@mstomar125

Copy link
Copy Markdown
Contributor Author

Re-review of 29451a4: APPROVE

All three fixes are correct and complete, and there are no regressions.

  • Ordering claim: verified on both paths.
    • switchTo: activeDatabaseId is set and activateProSync runs in one synchronous block, before the {#key} re-mount's onMount.
    • Fresh-launch load(): a null → resolved re-key waits on the newly queued chain link, and the stale load belongs to a destroyed component.
  • proSyncSettled(): never rejects.
  • loadSeq: correct in both branches.
  • Tests: meaningful, and the spy is restored in afterEach.

Findings: no critical issues and no suggested improvements.

Nits

  • The proSyncSettled() JSDoc was inserted between activateProSync's doc and its method. Fixed in the next commit, which only moves a comment.
  • An empty Unavailable message would render (). Declined: transport and worker errors always carry text.

Address-review: no re-review needed. The branch is review-clean. Not merged: merging is the user's call.

mstomar125 and others added 3 commits September 29, 2026 13:09
Owners and tenant admins get a "Workspace members" card in Settings →
Account listing pending admissions (from ListTenantMembers, status
pending) with Approve (ApproveAdmission) and an invite-by-email form
(InitiateAdmission). The card renders only when the caller's own roster
row is an active owner/tenant_admin. A pending user is told they are
waiting for approval, both in Account and in the add-synced-database
dialog's no-workspace step.

New Tauri commands pro_list_tenant_members, pro_approve_admission and
pro_initiate_admission rewrite daemon status codes into actionable
messages. The vendored Pro proto is refreshed to include the admission
RPCs.

Closes #3288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The members card re-mounts on activeDatabaseId, which switchTo sets
before pro_activate_database resolves, so its first ListTenantMembers
could read the previous database's tenant. databaseStore now exposes
proSyncSettled() and the card awaits it, and a load-sequence guard
drops results from a superseded load.

The Unavailable admission error keeps the daemon's detail, since it
also carries worker-side 5xx failures. Adds tests for the settle wait
and for a failed refresh keeping the admin card with Retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 UI to approve pending tenant joiners (or invite members): Pro daemon exposes ApproveAdmission/InitiateAdmission over gRPC, nothing calls them

1 participant