Skip to content

Explicit "Share" verb for vaults — a real share sheet, and unify the two invite panels #68

Closed

Description

@kipavy

Part of the team-ux initiative (brainstorm 2026-07-24).

Relates: #41.

Problem

There is no "share this vault" action. A vault becomes shared as a side effect of inviting a member from its Members page: handlePrivateAdd in MembersPage.tsx silently createTeam(vault.name), links it, initTeamVaultKey, and converts the private vault into a team/cloud vault — with no confirmation and no explanation. Users may not realize a "team" now exists or that their vault is now synced/shared.

There are also two divergent invite panels — InvitePanel (existing team vaults) and PrivateVaultInvitePanel (not-yet-team vaults) — with slightly different role sets; inconsistent.

Proposed reflow

  • Add an explicit "Share" verb on a vault (sidebar context menu + Members page), opening a share sheet (Google-Docs-style, single mental model):
    • People with access + their roles (add/change/remove in one place).
    • Invite by in-app user search or email (one unified control, clear which is which).
    • On first share of a private vault, an explicit moment: "Sharing turns <vault> into a team vault — it will sync to the cloud and become accessible to people you add. Continue?" — no more silent conversion.
  • Unify InvitePanel + PrivateVaultInvitePanel into one component with one role set, parameterized by whether the vault is already a team vault.

Decisions (brainstorm 2026-07-24)

  • Explicit verb + explicit conversion consent — the private→team change is significant (cloud sync, key distribution) and must not be a hidden side effect.
  • Single share surface reused everywhere a resource can be shared (aligns with the overall "one mental model" goal).

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 Aug 25, 2026

    @kipavy
    ContributorAuthor

    Partially shipped — PR #179, merged to dev as 653a8b37

    The consent half of this issue is done. The share-sheet half was built, reviewed in the running app, and deliberately reverted.

    What shipped

    The silent conversion is gone. handlePrivateAdd used to call createTeam, link the vault, initialise the vault key and convert a private vault to cloud-synced as a side effect of adding one person. Inviting someone into a private vault now opens a consent modal first, stating the three costs:

    • Opens only while Voltius can reach the sync server — no offline access.
    • Everyone you add gets a copy of its key. Removing them later doesn't take it back.
    • There's no way back to private.

    The wording is deliberate on two points. Both vault kinds sync end-to-end encrypted (a private vault is pushed as a per-device blob), so the honest difference is availability and custody, not encryption. And nothing in client or server rotates the team vault key on member removal — the only rotate in the server is for session short codes — so removal is the server refusing to serve, not a cryptographic revocation. The copy says so.

    PrivateVaultInvitePanel and PRIVATE_VAULT_ROLES are deleted. A private vault now goes consent → convert → the existing team InvitePanel. That removes the second invite panel and its rival hardcoded role set without changing the Members page layout, which is the outcome this issue wanted from the "unify the two panels" bullet.

    Bugs found by driving the running app against a live server

    These were not in the issue. Most were invisible to the test suite.

    1. The chosen role never reached the invitee. addMemberById creates a pending invitation, not a member row, so the follow-up assignMemberRole 404s — and an empty catch swallowed it. Every invitee silently landed on the default role, on both the search and email paths. Observed:
      POST /v1/teams/…/members              → 201  "Pending invitation created for existing user"
      POST /v1/teams/…/members/<user>/roles → 404
      
      The role now travels on the invitation; role assignment runs only for someone already a member.
    2. Failed invites reported success — the handler caught every error, cleared the typed query, and told its parent an invite had happened.
    3. Seats rendered ? available · ? total whenever the subscription load failed.
    4. A raw transport error leaked the internal server URL into the invite panel (error sending request for url (http://…/invite)).
    5. Inviting with no role selected silently granted member — you can untick every chip. Invite actions are now disabled with a hint, and the code-level fallback is the least-privileged role, never member.
    6. Text on accent backgrounds used a hardcoded #08151b, unreadable under a dark accent; now uses --t-on-accent, which is computed from accent luminance.
    7. markTeamVaultLoadedAfterLocalActivation was imported from the wrong module. The test mocked the whole path, so it passed while production would have thrown on the first real conversion.

    Verified live, not only in tests: an email invite with connect-only selected stores connect-only on the invitation row; at the seat limit the buy-seats flow opens and the typed address survives; with the server stopped the error names the failure and contains no URL.

    What was built and reverted, and why

    A tabbed VaultShareSheet (People / Invite / Links) replacing the Members page was implemented and then reverted after review in the running app. It had taken MembersPage.tsx from 1747 to 205 lines, removing bulk role actions, the member detail panel, the role filter, the Roles button, the seats meter, and person-first invite-to-session — none of which this issue asked to remove. The explicit Share verb in the vault header was also reverted in favour of the existing +.

    One improvement from that work was kept: the vault header's member avatar stack was hover-only with cursor-default, unreachable by keyboard and on touch. It is now a focusable button with aria-haspopup/aria-expanded and Enter/Space handling. The + affordance itself is unchanged.

    Still open

    • The share sheet / explicit Share verb. Not shipped. The existing Members page plus the + affordance is the preferred surface for now.
    • Vault invite links. Both forms remain unbuilt:
      • An addressed link needs no new route — the invitee's inbox entry is already invite:<invitation_id> with Accept/Decline actions, and the existing notification route is already navigate class and opens the centre at a given entry id.
      • An open link needs a server-side grant object (role fixed at mint, max_uses, TTL, revocation, redemption validated and consumed in one transaction) and a new vault-join route in the confirm trust class. Modelled on the existing session grants (migrations/037_terminal_session_grants.sql, src/session_grants.rs). No link can carry vault access — keys are wrapped per member with X25519 — so a link grants membership only; see Deep links: https fallback in front of every route, plus the next routes #144 Part 4.
    • Role-model reconciliation is resolved for the invite path (one panel, server roles only), but the multi-select semantics remain: primaryRoleName picks the most privileged selected role for the invitation, while the remaining roles are applied only for an existing member.

    #70 is unaffected by this PR.

  3. kipavy commented on Sep 7, 2026

    @kipavy
    ContributorAuthor

    Inherited from #71: the last silent team-creation path

    #71 is closed (see the rationale there), and this residual moves here because it belongs with "unify the two invite panels" rather than with any hub work.

    ConvertToTeamGate already asks for consent before a private vault becomes a team vault, and it is wired into both MembersPage.tsx:1380 and VaultShareSheet.tsx:102. One path still skips it — PrivateVaultMembersPanel.handleAdd in src/components/settings/sections/VaultsSection.tsx converts on the first add, with no gate:

    const teamId = await createTeamVaultFromVault(vaultId, vaultName);
    await addMemberById(teamId, user.user_id);

    So Settings → Vaults is currently the one place where picking a person silently creates a team, hands the vault's key custody to the server, and makes the vault unavailable offline — none of which the user is told, while the same act from the other two surfaces shows the full cost list first.

    The fix is to route this panel through ConvertToTeamGate like the other two callers, rather than adding a third bespoke confirmation. Since this issue is already about collapsing InvitePanel and PrivateVaultMembersPanel into one share surface, the cleanest outcome is that the unified panel has a single conversion path and createTeamVaultFromVault keeps no un-gated callers.

    Worth checking while in there: handleAdd also assigns the builtin member role after adding, which the other paths do elsewhere — a good candidate to fold into the shared path instead of duplicating a third time.

  4. kipavy commented on Sep 8, 2026

    @kipavy
    ContributorAuthor

    Closing — shipped across #179, #220, #221, #223 and #226

    All merged to dev; the last is 7b550a85.

    Both remaining bullets are done. What follows is what shipped, and what was deliberately not built.

    The silent conversion is gone, everywhere

    handlePrivateAdd used to createTeam, link the vault, initTeamVaultKey and convert a private vault to cloud-synced as a side effect of adding one person, with no confirmation. Inviting into a private vault now opens ConvertToTeamGate first, and it states the real costs:

    • Opens only while Voltius can reach the sync server — no offline access.
    • Everyone you add gets a copy of its key. Removing them later doesn't take it back.
    • You can make it private again later — that removes everyone else's access, but not the copies they already have.

    The wording is deliberate on two points, and both were corrected during the work. Both vault kinds sync end-to-end encrypted (a private vault is pushed as a per-device blob), so the honest difference is availability and custody, not encryption. And nothing rotates the team vault key on member removal — the only rotate in the server is for session short codes — so removal is the server refusing to serve, not a cryptographic revocation. The copy says so.

    The last un-gated caller, PrivateVaultMembersPanel.handleAdd in Settings → Vaults (inherited here from #71), went through the same gate in #220, and createTeamVaultFromVault now has no un-gated callers. The third copy of the role-assignment shape was folded into one shared inviteUserWithRoles at the same time.

    #223 then made make-private tell the truth too, and stopped it losing data.

    The two invite panels are one

    PrivateVaultInvitePanel and its rival hardcoded PRIVATE_VAULT_ROLES are deleted. A private vault now goes consent → convert → the existing team invite control. The dead TeamVaultPanel went with it (#221). One panel, one role set, server roles only.

    Vault invite links (#226)

    Addressed. The pending-invitation row's link button copies voltius://notification?n=invite:<id>, landing the invitee on their own inbox entry where Accept and Decline already live. No new route and no new server work — notification is already navigate class, correctly: the link carries no capability, because the invitation row is what confers anything and it is already bound to one user.

    Open. A new vault-join route in the confirm trust class, against the server half already on main (migration 038, src/team_join_grants.rs). A Links tab in the share sheet mints, lists and revokes grants, with the role fixed at mint, a use count, a TTL and revocation.

    🔴 A link can never carry vault access. Vault keys are wrapped per member with X25519, so a grant confers membership only, and the confirm sheet says so rather than letting a joiner assume otherwise: "You'll join straight away, but the vault stays locked until someone who already has its key is online." account_id is the KDF salt and appears in no link — the codec has exactly two parameters and a test pins that — and the secret rides in the fragment of the https form, so it never reaches a web server log, a CDN or a Referer.

    The secret is returned by the mint call and never again. So a grant listed back from the server offers no Copy button; it says "Link no longer viewable — revoke it and make a new one." instead of a button that would produce a broken link.

    Interaction with #41 (the last open sub-bullet): no new state was needed. A redeemer becomes a member with no key, getTeamVaultKey 404s → awaiting_key, and #70 already renders that as a real waiting state — "Waiting for access — waiting for @owner to grant access — they need to come online once" — plus an inbox entry and a retry. Confirmed live end to end: mint → redeem on a second account → land in that panel → owner grants → the row clears.

    Role-model reconciliation

    Resolved. One panel, server roles only, and a link may confer manager | editor | member | connect-only; owner is a 400 on the server and is never offered. The multi-select semantics are unchanged and still stand as noted: primaryRoleName picks the most privileged selected role for the invitation, and the rest are applied only once the person is a member.

    The share sheet and the explicit Share verb — settled, narrowly

    The vault header's + already opened the share sheet; it just never said so, being a bare icon tooltipped "Invite member". It now says Share. That is the whole verb: no second entry point and no second surface to keep in step with this one.

    It is now also shown on a private vault, which is the fix that made the verb worth having — the sheet's !teamId branch is the consent gate, so hiding the verb until a team already existed put it everywhere except the one place it matters most.

    A sidebar context-menu "Share" was considered and dropped: the 4.75rem rail has no context menu today, and a popover anchored away from the click reads as a glitch.

    The tabbed sheet replacing the Members page stays reverted, deliberately. It cut MembersPage.tsx from 1747 lines to 205 and removed bulk role actions, the member detail panel, the role filter, the Roles button, the seats meter and person-first invite-to-session — none of which this issue asked to remove. The Members page remains the deep surface; the popover is the quick one. The remaining open question from the original description, "whether Members page becomes a tab inside the share sheet", is answered: no.

    Bugs found by driving the running app against a live server

    Not in the issue, and mostly invisible to the test suite.

    From #179: the chosen role never reached the invitee (addMemberById creates a pending invitation, so the follow-up assignMemberRole 404s — swallowed by an empty catch, so every invitee silently landed on the default role); failed invites reported success; seats rendered ? available · ? total; a raw transport error leaked the internal server URL; inviting with no role selected silently granted member; text on accent backgrounds used a hardcoded colour, unreadable under a dark accent; and markTeamVaultLoadedAfterLocalActivation was imported from the wrong module behind a whole-path mock, so the test passed while production would have thrown on the first real conversion.

    From #226:

    1. Four dead buttons in a shipped surface. The share sheet renders live as the vault-header popover, and passed () => {} for onRemove, onRevoke, onGrantKey and onCopyInviteLink while PeopleList drew a button for each.
    2. The popover was blank for every invited member. It resolved vaultId only against local vaults, but the sidebar's standalone-team entry passes a team id — so anyone without a local vault opened it and got nothing.
    3. "Awaiting key" was the viewer's own status applied to everyone, so a reader who was waiting saw the whole roster as waiting, and would have been offered "Grant now" against people who already had a key. It now comes per member from getVaultKeyHolders.
    4. "Grant now" failed silently for a non-key-holder — distributeKeyToNewMember returns quietly when this device cannot unwrap the key, which is right for the background reconcile and wrong for a button someone pressed.
    5. Row layout collapsed at the popover width; the Links form pushed Create below the fold; and expiry read "6 days left" on a link minted seconds earlier with a 7-day life.

    Deliberately not done

    • No new share sheet, and no move of the Members page into one. Reasoning above.
    • No sidebar context-menu entry. Reasoning above.
    • No key rotation on member removal. Out of scope here; the consent copy is honest that removal is not a cryptographic revocation.
    • No email delivery of an open link. A vault-join link is a live credential, and enterprise mail rewriters (SafeLinks, Defender ATP) re-encode a mailed link onto their own logging host — the same reasoning that already keeps the raw verification token out of mail. The link is copied and shared by whatever channel the user already trusts.

    Notes carried forward

    • An exhausted grant stays in GET /grants: the server's list_live filters on revocation and expiry, not on uses. The Links tab is honest about it ("0 uses left of 1") rather than hiding it, and it can be revoked.
    • Unrelated and pre-existing, found while testing: switching accounts in-session leaves an owner unable to unwrap her own team vault key — the server returns the wrapped key 200 and the DB is consistent, so it is the local vault key after a switch; a fresh sign-in resolves it. Nothing in this work touches key derivation. Worth its own issue.

    #222 (the Vaults settings section rework) is untouched and stays its own issue.

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