Skip to content

Team administration & offboarding — roles, pending-invite status, seats, leave/remove #72

Closed

Description

@kipavy

Part of the team-ux initiative (siblings: #65–#69).

Relates: #67 (pending-invite management overlaps the inbox), #68 (roles & unified panels), #71 (hub is the likely home for admin entry).

Problem

The inviter/admin surface (MembersPage.tsx) is dense and under-designed, and the offboarding side is essentially absent.

Administration (inviter side):

  • Roles — assigning/changing roles (owner/manager/editor/member/connect-only) is present but not clearly explained; no per-role "what can this person do" surface.
  • Pending invites — status is thin: revoke is a hover-x on PendingInviteCard; there's no clear resend, no "invited but not yet accepted" clarity, no expiry.
  • Seats / paywall — the seats usage bar → HTTP 402 → BuySeatsModal path works but is transactional and easy to hit as a wall; the "why is my invite blocked" moment isn't well handled.
  • Two-step member removal + undo exists but isn't a coherent, reassuring flow.

Offboarding (missing):

  • A member leaving a team — no clear action or consequence explanation.
  • An owner removing a member — what visibly happens to that person's access and keys (revocation, re-wrap) is neither surfaced to the admin nor to the removed user.

Proposed reflow

  • Clear role model: show what each role grants at the point of assignment; make changes obviously reversible.
  • First-class pending-invite management: status (invited / accepted / expired), resend and revoke as explicit actions, consistent with the receive-side inbox (Unified team notification inbox — invites, shared sessions & control requests through the bell + toasts #67).
  • Seat UX that isn't a wall: surface seat usage before the invite is blocked; make the buy-more path calm and explain what a seat is.
  • Explicit offboarding: "Leave team" and "Remove member" as honest flows that state the access/key consequences, with confirmation.

Decisions (brainstorm 2026-07-24)

Still open

Activity

  1. added
    enhancementNew feature or request
    designUI/UX design & interaction
    team-uxTeam & collaboration UX flows (sessions, vaults, invites, presence)
    on Jul 24, 2026
  2. kipavy commented on Sep 7, 2026

    @kipavy
    ContributorAuthor

    Audit 2026-09-07 — much of this issue is stale, and two real bugs sit underneath it

    Re-read the code against this issue's claims. A lot shipped since the 2026-07-24 brainstorm, so the issue as written now points at the wrong things. Full design: docs/superpowers/specs/2026-09-07-team-admin-offboarding-design.md.

    Already built — these parts of the issue are no longer true

    • Roles are explained. RoleChip has a hover tooltip listing granted permission labels (src/components/members/MembersPage.tsx:43); all 17 permissions carry a label + description, translated (src/components/settings/sections/RolesSection.tsx:33); TeamRolesPanel is reachable from the Members toolbar and Settings › Vaults.
    • Changes are already reversible. Role assign/remove and member removal push undo/redo into historyStore (MembersPage.tsx:392, :445).
    • Seat usage is already surfaced before the invite. SeatsMeter renders in InvitePanel above the search field, and isAtSeatLimit short-circuits before the request (MembersPage.tsx:720, :803) — so the 402 is not normally reached. (Except under a trial; see below.)
    • Invite expiry exists. 7 days, enforced, expires_at already on the wire (server/src/routes/teams.rs:1376). The client just never renders it.
    • Resend effectively exists. Re-inviting the same person upserts and bumps expires_at = now() + INTERVAL '7 days' (teams.rs:466, :1277, :1329). A resend button needs no new endpoint.
    • Leave team is already supported server-side. remove_member does if auth.0 != user_id { require MANAGE_MEMBERS } (teams.rs:504), so self-removal passes; the owner is blocked separately. Leaving is DELETE /v1/teams/{id}/members/{me} — this is client UI only, not a server feature.
    • Removal consequences are implemented, thoroughly, and completely invisible. The server deletes the team_vault_keys row, revokes session grants, pushes membership_changed (teams.rs:545-563); the removed client evicts the in-memory key, unlinks the vault and clears every team store slice (src/services/sync.ts:882-926).

    Also stale: "stays per-vault in MembersPage". It is already a top-level nav page (src/components/layout/MainPanel.tsx:97), so the open question about the hub does not block anything here.

    Two bugs found while auditing

    1. Team secrets survive removal in the departed member's OS keychain. fetchTeamData writes every team secret — connection passwords, private keys, passphrases — into the local keychain (src/services/teamVaultSync.ts:388). The only function that removes them from disk, _clearTeamStores (teamVaultSync.ts:499-524), is called exclusively from fetchTeamData's own error/403 branches. The removal path calls the plain in-memory clearTeamConnections/clearTeamKeys/… store methods instead (sync.ts:918-925, src/stores/connectionStore.ts:75).

    The ordering makes it unrecoverable: the wipe needs the object IDs, and the code says so — "Wipe secrets from disk before clearing in-memory state so we still have the IDs." onTeamRemoved clears memory first, so a later 403 finds empty arrays and deletes nothing. A removed member keeps the team's plaintext passwords and private keys indefinitely.

    2. The seat pre-check is wrong under a trial. /subscription-info returns the raw purchased seat_count (server/src/routes/billing.rs:519-523), not the trial-clamped cap the invite path enforces via owner_seat_cap (teams.rs:49, seats.min(10)). A trial owner with 25 purchased seats sees "4 of 25 used, 21 available", the pre-check passes, and the server rejects at 10 with a 402 — the wall this issue complains about, in the one case nothing explains it.

    Key rotation is not the fix

    Removal deletes the wrapped key but never rotates the team DEK. Rotating it would not help, because the departing member held the plaintext:

    Exposure Protected today? Rotation helps?
    Secrets they already read No — permanently compromised No. Only rotating the credentials themselves helps
    Future team content Yes — membership, wrapped key and session grants all deleted server-side No, absent a server-authz bypass
    Blob ciphertext they exfiltrated They held the DEK No — a copy someone holds cannot be retroactively re-encrypted
    Local plaintext residue No — bug 1 above No

    Rotation protects only blobs written after rotation, which a removed member cannot fetch anyway. Its real value is bounding blast radius if the encrypted blob leaks from a backup or stale replica — defense in depth, and filed separately with that rationale rather than as offboarding.

    What this issue becomes

    1. Wipe team secrets from the local keychain on removal (bug 1) — call the existing wipe from onTeamRemoved, before clearing memory.
    2. Tell the removed user: new InboxKind: "membershipEnded" plus a toast, with a "Review affected credentials" action. Not raised when someone leaves voluntarily.
    3. Add "Leave team" — client UI only.
    4. Unify the three existing removal paths plus leave into one teamOffboarding service. Bulk kick currently has no confirmation at all (MembersPage.tsx:1261) while single removal is two-step; the unification fixes that as a side effect.
    5. Invite lifecycle: stop filtering expired invitations out of the admin's list (teams.rs:1404), return a status, and give the card a sent date, an expiry, and explicit Resend/Revoke buttons.
    6. Seats: send an effective_seats computed by the existing owner_seat_cap (fixes bug 2), explain the trial clamp, and say that seats count distinct people across every team you own.
    7. Explain roles at the point of assignment — extract the existing RoleChip tooltip and attach it to the toggle chips too.

    Plus decomposing MembersPage.tsx (1629 lines, eight components), which the above touches throughout, behind a before/after pixel diff.

  3. kipavy commented on Sep 8, 2026

    @kipavy
    ContributorAuthor

    #225 implements gaps 1, 2, 4, 5, 6 and 8 from the audit above, plus the MembersPage decomposition. Paired with VoltiusApp/server#13 for the invite status.

    Still open on this issue after #225 merges:

    One correction to the audit: it said removal was written three times. It is four — VaultsSection.tsx had its own copy with a two-step confirm and no undo at all. dev has since deleted that panel entirely (#221), so #225 takes that deletion rather than fixing it.

  4. kipavy commented on Sep 10, 2026

    @kipavy
    ContributorAuthor

    All remaining scope shipped in #232 (fix #219) — trial-clamp copy and account-wide-scope explanation are already in SeatsMeter.tsx on dev (members.invite.seatsTrialClamp / seatsScope, translated en/fr/ru/zh), just not yet in a tagged release (latest is v0.32.1, which predates it).

    Closing as fixed, ships with the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    designUI/UX design & interactionenhancementNew feature or requestteam-uxTeam & collaboration UX flows (sessions, vaults, invites, presence)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions