diff --git a/README.md b/README.md
index 3e09f56..3550199 100644
--- a/README.md
+++ b/README.md
@@ -226,18 +226,30 @@ What is not here: transferring a directory, dragging between the panes, and rout
bastion — the last needs jump hosts the connection layer has not got. All three are in
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
-### Working as a team
+### Sharing a vault
-**TEAMS** in the nav rail creates a team, adds and invites members, shares vaults, hands a team over and
-archives one. One distinction runs through the whole screen and is worth having before you use it.
+**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds and invites people to one,
+shares its key, renames it and hands it over. One distinction runs through the whole screen and is worth
+having before you use it.
-**Adding somebody to a team and giving them a key are two different acts, and only the first is something
-the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in
-their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's
-public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has
-the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed
-over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so
-rather than implying the server handed anything out.
+**A vault is the thing you make, and the group of people is behind it.** The server authorises through a
+*team* — `VaultAccessService` resolves a shared vault through `team_membership`, and every membership call
+names a team id — but nothing asks you to make one: naming a vault makes the membership list that carries
+it, named after the vault and owned by you. So the thing you came to share is the thing you create, and
+"which team is this in" stops being a question you need an answer to before you can share four servers with
+two colleagues. Renaming the vault renames that membership list with it, as long as it carries nothing else.
+
+The one case where the distinction resurfaces is a team owning several vaults, which this screen cannot
+produce and does not hide: the members section then says so, because adding somebody to one of those vaults
+adds them to all of them.
+
+**Adding somebody to a vault and giving them its key are two different acts, and only the first is something
+the server can do.** Adding a member changes what the server will *serve* them: the vault appears in their
+list immediately. It cannot make it readable, because a vault key is sealed to each member's public key and
+this server never holds one — so until somebody presses **SHARE KEY** from a machine that has the key, their
+vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed over later; it
+is what "the operator cannot read the credentials it stores" costs, and the screen says so rather than
+implying the server handed anything out.
Sharing verifies before it wraps. The client reads the server's append-only key log, checks its hash chain
from the first entry, and refuses unless the key the directory just offered appears in that log unchanged.
@@ -246,14 +258,14 @@ published in a log every other client also reads. **It does not prove the key is
Compare the fingerprint with them over something this server does not carry; that is the only step that
closes it, and the success message says so every time.
-**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD
-MEMBER** — and it does whichever of the two applies, because which one applies is a fact about the server's
-account table rather than about what you are trying to do. If the directory knows the address, that account
-is added straight away. If it does not, the address is invited instead, and the status line says which
-happened, because the difference decides what you do next.
+**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD** —
+and it does whichever of the two applies, because which one applies is a fact about the server's account
+table rather than about what you are trying to do. If the directory knows the address, that account is added
+straight away. If it does not, the address is invited instead, and the status line says which happened,
+because the difference decides what you do next.
An invitation is a standing instruction rather than a message: the next account that signs in with that
-address joins this team, at the role you chose. There is no link and no token, because this server has no
+address joins this vault, at the role you chose. There is no link and no token, because this server has no
outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a
channel this server does not carry, and a link nobody can deliver would be worse than no link. An
invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a
@@ -265,9 +277,9 @@ you the public key you are about to verify and wrap a vault to, and an invitatio
there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful
sequence is still the same one: add them, see them appear in the members list, then share.
-Inviting an address that already belongs to a member of the team is refused and says so. Inviting one that
+Inviting an address that already belongs to a member of the vault is refused and says so. Inviting one that
merely *has* an account here is not — that would make this a way of asking the server which addresses have
-accounts, which is not a question anybody willing to create a team first should be able to put to it. Such
+accounts, which is not a question anybody willing to create a vault first should be able to put to it. Such
an invitation simply gets claimed sooner: within the hour, on the same sweep that records they were here,
rather than waiting for a first sign-in that has already happened.
@@ -275,39 +287,34 @@ rather than waiting for a first sign-in that has already happened.
to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or
sent under another name — claims nothing at all, and no setting turns that off: an invitation decides what
the server will serve, and one that could be taken by anybody able to obtain a token asserting somebody
-else's address is a way into a team. **If your invitations never activate, this is the first thing to
+else's address is a way into a vault. **If your invitations never activate, this is the first thing to
check.** They sit at *pending* rather than failing, the server logs a warning each time it declines to
claim one, and the two fixes are on your side: set `Oidc:EmailVerifiedClaim` to whatever your provider
calls the claim if it is not `email_verified`, and make sure the provider puts it in the **access** token
rather than only in the ID token or the userinfo response.
-**Ownership is sole, and handing it over is one act.** Transferring names an existing active member: they
-become owner and you become an admin, in a single transaction. Not two role changes — promoting first
-leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the
-authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep
-your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody
-handing over a team is usually staying in it.
+**Ownership is sole, and handing a vault over is one act.** Transferring names an existing active member:
+they become owner and you become an admin, in a single transaction. Not two role changes — promoting first
+leaves it owned twice, demoting first leaves it owned by nobody, and there is nobody with the authority to
+finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep your vault
+key grants; removing you would revoke them and flag the vault for rekey, and somebody handing a vault over
+is usually staying in it.
-**Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team
-vault is readable *because* of membership, so archiving a team that still owned vaults would take them away
-from everybody holding a key — including you — quietly and all at once. Nothing in this product deletes a
-vault, so there is no order of operations that gets past the refusal today, and it says so with a count of
-what is in the way rather than failing vaguely. Archiving an empty team takes its memberships and its
-outstanding invitations with it, in one transaction. Its name can be changed whenever you like; its slug
-cannot, because a slug is unique only among live teams and a rename could take one an archived team is
-still holding.
+Five limits, stated rather than discovered:
-Four limits, stated rather than discovered:
-
-- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey,
- and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials
- that matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
+- **A vault cannot be deleted.** Nothing in this product removes one, and the server refuses to archive the
+ membership list behind a vault that still exists — a shared vault is readable *because* of membership, so
+ archiving it would take the vault away from everybody holding a key, including you, quietly and all at
+ once. The screen says so where you would otherwise go looking for the button.
+- **Removing a member is not retroactive.** It revokes their grants and flags the vault for rekey, and
+ blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials that
+ matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
- **The rekey is flagged, never performed.** See the milestone note above.
-- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used
- from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will
- then trust silently for a host you defined. The cost is that each member approves a team host's key once
- on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what
- has been trusted.
+- **Host key trust stays in your personal vault.** A pin approved for a shared vault's host is recorded and
+ used from your own vault, not the shared one, so a colleague cannot pre-approve a fingerprint that your
+ client will then trust silently for a host you defined. The cost is that each member approves a shared
+ host's key once on each of their machines. Shared vaults' pins are still *listed* on the Pins screen, so
+ you can see what has been trusted.
- **LAST ACTIVE is coarse on purpose.** The server records it at most once per account per hour, so a value
an hour old means "recently" and not "at that moment". That is the granularity the question is really
asked at — whether somebody is still using this deployment — and writing it on every request would put an
@@ -315,9 +322,14 @@ Four limits, stated rather than discovered:
as roughly-when rather than to the minute, because showing it to the minute would be reading a precision
into it that is not there.
-Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors
-show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
-vault is visible to everybody in that team and moving it back means deleting and retyping.
+Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's
+editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two
+vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it
+again — and the picker is therefore absent when you edit an existing host rather than present and refusing.
+Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen.
+Both default to your personal vault and neither moves on its own, because an item put in a shared vault is
+visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
+it can be filed under: a group is an item like any other and lives in exactly one vault.
### The Android head
@@ -329,7 +341,7 @@ fit 360dp.
Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the
last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP,
-S3 buckets, logs, teams and preferences behind Settings. A bottom bar is for the places a session moves
+S3 buckets, logs, vaults and preferences behind Settings. A bottom bar is for the places a session moves
between, and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is
a 190-pixel labelled nav rail in place of the icon rail, a centred search box in the titlebar, and session
tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see
@@ -369,9 +381,9 @@ confirmation — without offering to change it. What this head does make, it mak
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's
remember tick, which stores the password just typed and moves the host onto it. Renaming either is still a
desktop job. Pins and import have no phone screen either, and importing an `~/.ssh/config` has no meaning
-on a phone at all. **TEAMS does have one**, behind MORE, and it is there for a reason the design could not
-have anticipated: an invitation is claimed by signing in, so somebody being told they have been put in a
-team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on
+on a phone at all. **VAULTS does have one**, behind MORE, and it is there for a reason the design could not
+have anticipated: an invitation is claimed by signing in, so somebody being told they have been added to a
+vault is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on
a head they have not installed is a membership they cannot see.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
@@ -570,15 +582,16 @@ keychain plus a terminal — and the spike that gates all of it.
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
-- **M3 — teams**, sharing, ACLs. *Done, except rekey.* Teams with roles, a public-key directory, the
- append-only key log served for clients to verify against, team-owned vaults, and vault key grants
- wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
+- **M3 — shared vaults**, sharing, ACLs. *Done, except rekey.* Membership with roles, a public-key
+ directory, the append-only key log served for clients to verify against, shared vaults, and vault key
+ grants wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs
- every vault it holds a key for, and a real TEAMS screen replaces the placeholder. A team can be renamed,
- handed to another member, and archived once it owns no vaults; a member row carries when that account was
- last here; and an address with no account on this deployment can be invited, joining the moment somebody
- signs in with it. See
- [Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth
+ every vault it holds a key for, and a real VAULTS screen replaces the placeholder. The screen is
+ vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the
+ team is behind the vault rather than a thing anybody has to create first. A vault can be renamed and
+ handed to another member; a member row carries when that account was last here; and an address with no
+ account on this deployment can be invited, joining the moment somebody signs in with it. See
+ [Sharing a vault](#sharing-a-vault) for the one distinction the whole design rests on, and the limits worth
knowing before you rely on it; the reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md).
diff --git a/docs/adding-hosts-on-the-phone.md b/docs/adding-hosts-on-the-phone.md
index 802856c..9397adf 100644
--- a/docs/adding-hosts-on-the-phone.md
+++ b/docs/adding-hosts-on-the-phone.md
@@ -55,7 +55,7 @@ Three of those fields do not exist at any layer, and one of them is refused on t
| Asked for | What exists today |
| --- | --- |
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* |
-| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's `VaultScreen.axaml` already draws it. |
+| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's keychain screen already draws it. |
| Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. |
| Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. |
| A group's parent | **Refused on the record.** `HostGroupSecret`'s own remark says groups are flat because two clients can each re-parent A under B and B under A offline, a scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot see, because it is inside the payload. |
diff --git a/docs/adr/0009-team-access-model.md b/docs/adr/0009-team-access-model.md
index 8b1efcd..1bb0eb1 100644
--- a/docs/adr/0009-team-access-model.md
+++ b/docs/adr/0009-team-access-model.md
@@ -115,6 +115,36 @@ Three decisions inside it belong here, because each had a more convenient altern
an address already belonging to a member of *this* team is refused, and that is a fact the caller can
already read off the members table, so naming it leaks nothing.
+### Addendum: the vault is what the product shows, and the team is behind it
+
+The model above is unchanged. What changed afterwards is which half of it a person is asked about.
+
+The first interface built on this ADR made the team the subject: you created a team, then a vault in it,
+then wrapped a key. Two of those three steps are about a concept nobody arrives wanting. So the screen now
+lists **vaults**, and naming one creates the membership list that carries it — named after the vault,
+owned by the creator, one per vault. Nothing on the server moved: `VaultAccessService` still resolves a
+shared vault through `team_membership`, every membership call still names a team id, and the split this
+ADR is about — membership authorises, a grant unlocks — is still what the screen is built around, now
+stated per vault rather than per team.
+
+Three consequences of the change belong here:
+
+- **A team owning several vaults is still legal and is no longer produced.** The client cannot make one;
+ an operator or a pre-existing deployment can. The screen refuses to hide it: a vault whose membership
+ list carries others says so, because on a vault-shaped screen "adding somebody here adds them there" is
+ precisely the fact that would otherwise be invisible.
+- **Archiving left the interface.** It was only ever possible for a team owning no vaults, and a screen
+ whose rows are vaults has no row for one — so the button would have been unreachable or always refused.
+ The endpoint is unchanged and the screen states the limit instead. The one place a vault-less team can
+ still appear is a create whose second call failed; cancelling that form archives it, which is a
+ deliberate exception to this client's rule against tidying up on the user's behalf, made because nothing
+ else can reach it.
+- **A vault can be renamed**, which it could not before: `PUT /api/v1/vaults/{id}` requires
+ `PermissionFlags.Admin` — the line `UpdateTeamEndpoint` already draws, because a name is what everybody
+ in the vault sees it called rather than part of its contents. It renames the owning team with it when
+ that team carries nothing else, so the row an operator reads and the name a user says do not drift
+ apart. The slug never moves, for the reason it never moves on a team rename.
+
## Consequences
The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index cf55bb8..53b82c3 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -29,16 +29,16 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`;
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
>
-> **A sixth is behind MORE that v2 never drew: TEAMS.** It is the reverse case — a shipped screen the
+> **A sixth is behind MORE that v2 never drew: VAULTS.** It is the reverse case — a shipped screen the
> design had no slot for — and it is on the phone for a reason the design could not have anticipated,
> because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the
-> person being invited is at least as likely to be holding a phone as sitting at a desktop; a team the
+> person being invited is at least as likely to be holding a phone as sitting at a desktop; a vault the
> server has just put somebody in, visible only on a head they may not have installed, is a membership
> they cannot see. It runs over the same view model the desktop screen drives, like the other four.
>
> | v2 element | What ships instead |
> | --- | --- |
-> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
+> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps VAULTS in its rail. |
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
@@ -84,7 +84,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | **Split ⌘D** | Still omitted — the renderer stacks panes and shows one; tiling needs a pane geometry it has not got. |
> | macOS traffic lights, and `⌘K` | The window's own minimise/maximise/close, and `CTRL K`. Development is Windows-first and the chrome is `BorderOnly` for a documented reason. |
> | No status bar | Kept, and cut down to the one thing the titlebar does not now carry: `Vault.Status`, which is the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of a working afternoon and has nowhere to put a sentence like that. |
-> | The sidebar's five destinations, and a **Team vault** card at its foot | Seven destinations, because Pins, Teams and Preferences are built screens and dropping their entry would strand them — and two fewer than v2 shipped with, because SFTP and S3 became tabs; see v3 below. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. |
+> | The sidebar's five destinations, and a **Team vault** card at its foot | Seven destinations, because Pins, Vaults and Preferences are built screens and dropping their entry would strand them — and two fewer than v2 shipped with, because SFTP and S3 became tabs; see v3 below. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. |
>
> ## The desktop's v3
>
@@ -157,7 +157,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
-> | The **chevron beside the vault name** | The name alone. An item cannot be moved between vaults: the two are encrypted under different keys, so moving one is a delete and a retype. Where a *new* item is filed is chosen on the keychain screen, which is the only vault question with an answer. |
+> | The **chevron beside the vault name** | The name alone, on the pane about an existing host: an item cannot be moved between vaults, because the two are encrypted under different keys and moving one is a delete and a retype. The half of the question that *does* have an answer — where a new host goes — is asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker instead. |
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
@@ -188,7 +188,7 @@ this document where what shipped differs from what the row predicted.
**~~Teams are schema and nothing else.~~ Built in M3.** The `team` and `team_membership` tables were there
from the first migration with nothing reading them, and `VaultAccessService.ResolveAsync` denied every vault
that was not the caller's own. Both changed in M3 and neither needed a migration, which is what carrying two
-unused tables bought. See [Teams](#teams). What has *not* changed is the split underneath: the server
+unused tables bought. See [Vaults](#vaults). What has *not* changed is the split underneath: the server
decides what it will serve, and only a client can decide who can read it — so "shared with" is two facts on
this screen, not one.
@@ -282,7 +282,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
| Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- |
-| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the TEAMS screen, where they are about something. |
+| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
@@ -358,15 +358,17 @@ transfer primitive. The queue does its own 64 KiB copy loop and shares nothing w
---
-## Vault
+## Keychain — the design's Vault screen
The screen ships and is real: four categories over the vault's four item types, one table, a detail pane,
-and both editors. What follows is what the design drew around them.
+and both editors. It is called the Keychain in both heads, which is what the rail has always labelled it;
+the section below keeps the design's word only where it quotes the design. What follows is what the design
+drew around them.
| Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- |
| `ACCESS` column and `your access → CONNECT-ONLY` | server | Per-item ACLs. `EncryptedPayload.DataKeyId` is documented as the seam for per-item grants **in M5**. `VaultSummary` does carry an opaque `int Permissions`, but nothing gives the bits a meaning on the wire — `PermissionFlags` itself lives in `DodoSSH.Domain` — and those are per *vault*, never per item. There is no `ConnectOnly` role in `TeamRole` at all. | Omitted. The column shows sync state instead — whether a change is still sitting in this machine's outbox — which the design had no column for. |
-| `SHARED WITH · 6` avatars | server | **Built in M3**, minus the avatars — no picture is stored anywhere. `GET /api/v1/vaults/{id}/grants` lists who holds a key. | The list lives on the TEAMS screen, beside the members it is about, rather than as a count on an item row: a grant is per *vault*, and putting a number on an item would imply per-item sharing, which is M5. |
+| `SHARED WITH · 6` avatars | server | **Built in M3**, minus the avatars — no picture is stored anywhere. `GET /api/v1/vaults/{id}/grants` lists who holds a key. | The list lives on the VAULTS screen, beside the members it is about, rather than as a count on an item row: a grant is per *vault*, and putting a number on an item would imply per-item sharing, which is M5. |
| "Private key never leaves the vault. Sessions sign through the team agent (dodod)" | client-ssh | **Both sentences are false here, and the second cannot be made true by this architecture.** There is no agent of any kind, and the connect path decrypts the private key and hands the bytes to SSH.NET. | Omitted. The key editor already says what is true: the key and its passphrase are encrypted here and never reach the server in a readable form. |
| Scope rail: `PERSONAL` / `TEAM · PLATFORM` / `TEAM · DATA` | server | **Built in M3.** `VaultAccessService.ListAsync` resolves team membership, so a session can hold several vaults. | Not a rail, because it would be a selector with nothing to select: every list on the screen already spans every vault this session can read. What replaces it is a picker for where a *new* item is filed, which is the only vault question with an answer. |
| `SCOPE` column | server | **Built in M3.** Scope is a property of a vault, never of an item, and that has not changed. | Each row names the vault it is in, and rows are grouped by vault. |
@@ -379,20 +381,30 @@ and both editors. What follows is what the design drew around them.
| `added by anna@dodotech.dev` | contracts | The server records `CreatedByUserId`, but `SyncChange` carries no actor field and no other user's name is fetchable. | Omitted, and the detail pane says in one line that items record no author, no timestamps and no sharing. |
| `created 2026-03-14` | client-sync | Recoverable in principle — entity ids are UUIDv7 and carry a timestamp — but nothing surfaces it. | Omitted. |
| `TEST CONNECT` | ui | Connecting is host-scoped, not credential-scoped: there is nothing to test a credential *against* without a host. | Omitted. |
-| `REVOKE` | server | **Built in M3**, as WITHDRAW KEY on the TEAMS screen — because there are now grants to revoke, and a grant is what it acts on. ADR 0001 constrains how it is presented, and it is: the message says it blocks future reads only, and that what they already hold is unaffected. | On this screen, still Delete, named for what it does. Deleting an item and withdrawing somebody's key remain different acts. |
+| `REVOKE` | server | **Built in M3**, as WITHDRAW KEY on the VAULTS screen — because there are now grants to revoke, and a grant is what it acts on. ADR 0001 constrains how it is presented, and it is: the message says it blocks future reads only, and that what they already hold is unaffected. | On this screen, still Delete, named for what it does. Deleting an item and withdrawing somebody's key remain different acts. |
| `SSH KEY · ED25519` | client-domain | No algorithm field, and deriving it means parsing the armour. | The type without the algorithm. |
| One `+ ADD CREDENTIAL` button | ui | — | Two buttons, one per kind that can be added. "Credential" is a specific item type in this codebase (a username and a password), so using it as an umbrella word would collide with the vocabulary. |
| — | — | — | **`HOST KEYS` is the reverse case:** a fully-backed, shipped category the design had no slot for. It is in the rail. |
---
-## Teams
+## Vaults
-**Built in M3.** The screen ships: a team list, a members table with a real last-active column, the
-invitations standing against addresses that have no account here yet, the team's vaults, and the two buttons
-the whole design was really about — add a member, and share a vault key. A team can also be renamed, handed
-to another member, and archived, the last only while it owns no vaults. What follows is what it still does
-not do, and one thing this document got wrong before it was built.
+**Built in M3, and reshaped since.** The screen ships: a vault list, a members table with a real
+last-active column, the invitations standing against addresses that have no account here yet, who holds a
+key, and the two buttons the whole design was really about — add somebody, and share a vault key. A vault
+can also be renamed and handed to another member.
+
+**It lists vaults where it used to list teams, and that is the reshaping.** A team is still what the server
+authorises against; what went is the requirement that anybody make one. Naming a vault makes the membership
+list that carries it, named after the vault and owned by its creator, so the thing people came to share is
+the thing they create. The team resurfaces in exactly one place and is not hidden there: a membership list
+carrying several vaults — which this screen cannot produce — says so, because adding somebody to one of
+those vaults adds them to all of them. Archiving is gone with the team list: a vault cannot be deleted at
+all, the server refuses to archive a membership list while its vault exists, so the screen says so rather
+than offering a button that always refuses.
+
+What follows is what it still does not do, and one thing this document got wrong before it was built.
**The correction.** The rows below used to describe a screen with nothing behind it, on the grounds that
`VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. That is now the one
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 7af5f01..19a75b7 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -28,7 +28,7 @@ a phase had nothing left for a person to do, which is the good outcome rather th
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
-Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Teams,
+Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Vaults,
Preferences — and both of the fixed tabs, SFTP and S3.
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
@@ -1130,7 +1130,7 @@ case, and those two have to move together — the switch mirrors that property b
---
-## Phase 12 — Teams: the operations that span two accounts
+## Phase 12 — Shared vaults: the operations that span two accounts
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
@@ -1147,16 +1147,16 @@ follows is about what happens the first time it does.
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
-1. Sign in as `alice`, make a team, and open its invitations.
-2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** —
+1. Sign in as `alice`, make a vault on the VAULTS screen, and select it.
+2. Add `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** —
no "invitation emailed", no link to copy, no token anywhere on the screen.
3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out.
Bob is **not** in the members table, because he has no account here for a membership row to point at.
4. Sign in as `bob` on the second profile and enroll.
-5. **Pass:** the team is in Bob's list the first time he looks, at Member, with nothing further pressed on
- either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
+5. **Pass:** the vault is in Bob's list the first time he looks, at Member, with nothing further pressed
+ on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
and Bob is now in the members table.
-6. **Pass, and this is the half that is easiest to lose:** the team's vault is in Bob's list **saying it is
+6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
opens.
@@ -1171,15 +1171,15 @@ reached that machine by a route this architecture says does not exist.
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
Invite that address, then sign in as them.
-**Pass:** they get an account and a personal vault and no team at all. The invitation stays *pending* on
+**Pass:** they get an account and a personal vault and no shared one. The invitation stays *pending* on
the inviter's screen rather than turning into anything, and the API log carries a warning naming how many
invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request
that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not
hurry it along — the account already exists, so there is no second first-sign-in to trigger it.
-**Failure means:** if the team appears while the address is unverified, the one security boundary
+**Failure means:** if the vault appears while the address is unverified, the one security boundary
invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address
-can walk into their team. Stop there. If it stays pending after verifying, the claim is not reaching the
+can walk into their vault. Stop there. If it stays pending after verifying, the claim is not reaching the
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
@@ -1188,27 +1188,27 @@ under some other name.
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
**Pass:** the row reads *revoked* and stays on the list rather than disappearing, and the sign-in produces
-an ordinary account in no team. Revoking one that has *already* been accepted answers that there was
-nothing to withdraw.
+an ordinary account in no shared vault. Revoking one that has *already* been accepted answers that there
+was nothing to withdraw.
**Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An
accepted one that could be unpicked here would be worse: it is a membership now, and removing a member
-revokes their vault key grants and flags every team vault for rekey, which is not what "revoke invitation"
+revokes their vault key grants and flags the vault for rekey, which is not what "revoke invitation"
should quietly do.
### 12.4 An address already in the team is refused; an address that merely has an account is not
-With Bob in the team, invite `bob@example.com` to it again.
+With Bob in the vault, add `bob@example.com` to it again.
**Pass:** refused, with a sentence saying the address already belongs to a member and to change their role
-instead. Now make a **second** team and invite the same address there.
+instead. Now make a **second** vault and invite the same address there.
**Pass:** accepted. Bob having an account is deliberately not a reason to refuse — it is claimed within the
hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked.
**Failure means:** if the second invitation is refused because the address already has an account, this
endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody
-willing to create a team first. See ADR 0009.
+willing to create a vault first. See ADR 0009.
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
@@ -1227,49 +1227,53 @@ impossible to offer honestly before.
As the owner, transfer ownership to another active member, then read both rows.
-**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grants are intact
-and the team's vaults have not come back flagged for rekey. Then try to transfer to somebody who is not a
-member, and to yourself.
+**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grant is intact
+and the vault has not come back flagged for rekey. Then try to hand it to somebody who is not a member, and
+to yourself.
**Pass:** both refused, and the message says which.
**Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and
-either one leaves a team that no client can administer back into shape. If your grants were revoked or the
-vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
+either one leaves a vault that no client can administer back into shape. If your grant was revoked or the
+vault is now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
-### 12.7 Archiving is refused while the team owns a vault
+### 12.7 A vault cannot be deleted, and the screen says so rather than offering a button
-With a team that owns at least one vault, try to archive it.
+Look for a way to remove a vault, on both heads.
-**Pass:** refused, and the message counts the vaults in the way and says there is no way to delete a vault
-in this product. The team is still in everybody's list afterwards and its vaults still open.
+**Pass:** there is none, and the VAULTS screen says why in a sentence: nothing in this product removes a
+vault, and the server refuses to archive the membership list behind one while it exists. Archiving that
+list is still reachable over the API, and the endpoint suite drives both its refusal and its success — what
+is being checked here is that no button offers it.
-**Failure means:** an archive that succeeded here would have taken those vaults out of the list of
-everybody holding a key — including the person who pressed it, quietly, and with nothing in the product
-able to put them back.
+**Failure means:** a delete that worked would take the vault out of the list of everybody holding a key —
+including the person who pressed it, quietly, and with nothing in the product able to put them back. A
+button that always refuses is the milder failure and is still worth removing.
-### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
+### 12.8 Renaming a vault reaches every place its name is drawn · **needs two accounts**
-Make a team that owns no vaults, add the second account to it, invite a third address, and archive it.
+Rename a shared vault from the VAULTS screen.
-**Pass:** the team is gone from both accounts' lists. Sign in with the invited address afterwards and it
-joins nothing. A new team can be created under the archived one's slug.
+**Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain
+screen's "new items go to" picker, in the host editor's vault picker, and in the tab strip's vault menu —
+and on the second account after a refresh. Nothing in the vault needs re-encrypting and everybody's key
+still opens it.
-**Failure means:** the invited address turning up in a team nobody can see is exactly what revoking pending
-invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in
-nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason
-an archived team is only restorable by an operator who checks that first.
+**Failure means:** a name that moved in one place and not another is the shape this rename is most likely to
+fail in, because several screens read it separately from a cached vault row. A vault that stops opening
+after a rename would be far worse, and means that cached row was replaced by the server's answer rather
+than edited — that answer deliberately carries no wrapped key.
-### 12.9 Renaming a team, and the slug that does not move
+### 12.9 Somebody who may write to a vault may not rename it
-Rename a team and change its description.
+As a plain Member of somebody else's vault, look for RENAME.
-**Pass:** the new name is on every screen that names the team, on both accounts after a refresh. The slug is
-unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed.
+**Pass:** it is not drawn. Adding a host to that vault still works, which is what makes this a boundary
+rather than a broken role.
-**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that
-archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented
-data — `team` has no updated-at column, so there is nothing behind it.
+**Failure means:** a name is what everybody in the vault sees it called, so a member renaming it out from
+under the people who share it is an administrative act reached without the role for it. The server refuses
+it too — this is the interface not offering what the server would turn down.
---
diff --git a/src/DodoSSH.Api/Features/Teams/TeamLog.cs b/src/DodoSSH.Api/Features/Teams/TeamLog.cs
index 8480ea6..459c5f2 100644
--- a/src/DodoSSH.Api/Features/Teams/TeamLog.cs
+++ b/src/DodoSSH.Api/Features/Teams/TeamLog.cs
@@ -50,6 +50,16 @@ internal static partial class TeamLog
internal static partial void TeamVaultCreated(
ILogger logger, Guid vaultId, Guid teamId, Guid actorId);
+ ///
+ /// The vault and its team, and no names. A vault name is plaintext on this server, which is not a
+ /// reason to copy it into everything a log aggregator keeps for a year.
+ ///
+ [LoggerMessage(
+ EventId = 2115,
+ Level = LogLevel.Information,
+ Message = "Renamed vault {VaultId} of team {TeamId}.")]
+ internal static partial void VaultRenamed(ILogger logger, Guid vaultId, Guid? teamId);
+
[LoggerMessage(
EventId = 2106,
Level = LogLevel.Information,
diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs
index 97b2289..f832e12 100644
--- a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs
+++ b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs
@@ -50,6 +50,78 @@ internal sealed class ListVaultGrantsEndpoint(
}
}
+/// Renames a vault.
+///
+///
+/// Admin rather than Write, and the line is the one UpdateTeamEndpoint draws: a name is what
+/// everybody in the vault sees it called, so changing it is an administrative act rather than an edit
+/// to the vault's contents. A member who may add hosts to a shared vault may not rename it out from
+/// under the people who share it.
+///
+///
+/// Authenticated rather than Enrolled, unlike everything else about a vault here. A rename touches no
+/// key material and needs none — somebody added to a team before they have finished setting their own
+/// machine up can still be reading this screen — and requiring a published identity key would refuse
+/// them for a reason that has nothing to do with what they are asking.
+///
+///
+internal sealed class RenameVaultEndpoint(
+ ICurrentUserContext currentUser,
+ IVaultAccessService vaultAccess,
+ VaultGrantService grants)
+ : Endpoint, NotFound, ProblemHttpResult>>
+{
+ ///
+ public override void Configure()
+ {
+ // PUT, for the reason the team rename is a PUT: one field, always sent whole, and a repeat is
+ // the same vault rather than a second edit.
+ Put("/api/v1/vaults/{vaultId:guid}");
+
+ Policies(Auth.AuthenticatedPolicy);
+
+ Description(b => b
+ .WithName("RenameVault")
+ .WithSummary("Renames a vault.")
+ .WithTags("Vaults"));
+ }
+
+ ///
+ public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync(
+ UpdateVaultRequest req,
+ CancellationToken ct)
+ {
+ var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
+ var access = await vaultAccess
+ .ResolveAsync(user.Id, Route("vaultId"), ct)
+ .ConfigureAwait(false);
+
+ if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (!access.Permissions.HasFlag(PermissionFlags.Admin))
+ {
+ return Problems.Coded(
+ StatusCodes.Status403Forbidden,
+ ProblemCodes.Forbidden,
+ "Only an admin or the owner of the team that owns this vault can rename it.");
+ }
+
+ try
+ {
+ return TypedResults.Ok(
+ await grants.RenameVaultAsync(access.Vault!, req, ct).ConfigureAwait(false));
+ }
+ catch (TeamInvalidException exception)
+ {
+ return Problems.Coded(
+ StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
+ }
+ }
+}
+
/// Wraps this vault's key to another member.
///
/// The one call in this API whose body the server can neither produce nor check. It stores a sealed
diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs
index 311e31f..bf84e41 100644
--- a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs
+++ b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs
@@ -151,6 +151,71 @@ internal sealed class VaultGrantService(
: name;
}
+ ///
+ /// Renames a vault, and the team behind it where that team exists to carry this vault alone.
+ ///
+ ///
+ ///
+ /// The team is renamed with it, and only when it owns nothing else. A vault made from the
+ /// vaults screen gets a team of its own named after it, and that team is not a thing the person who
+ /// made it was ever shown — so a rename that moved the vault's name and left the team's would leave
+ /// the operator, the logs and the database naming it something nobody uses. A team owning several
+ /// vaults is a different situation: it has a name of its own that somebody chose, and renaming one of
+ /// its vaults must not take it.
+ ///
+ ///
+ /// The slug never moves, exactly as UpdateTeamRequest records: it is unique only among live
+ /// teams, so a rename that changed it could take one an archived team is still holding.
+ ///
+ ///
+ internal async Task RenameVaultAsync(
+ Vault vault,
+ UpdateVaultRequest request,
+ CancellationToken cancellationToken)
+ {
+ var name = RequireVaultName(request.Name);
+
+ vault.Name = name;
+ vault.UpdatedAtUtc = clock.GetUtcNow();
+
+ if (vault.TeamId is { } teamId)
+ {
+ var alone = !await database.Vaults
+ .AnyAsync(
+ other => other.TeamId == teamId
+ && other.Id != vault.Id
+ && other.DeletedAtUtc == null,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ if (alone)
+ {
+ var team = await database.Teams
+ .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (team is not null)
+ {
+ team.Name = name;
+ }
+ }
+ }
+
+ await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ TeamLog.VaultRenamed(logger, vault.Id, vault.TeamId);
+
+ return new VaultSummary(
+ VaultId: vault.Id,
+ Name: name,
+ IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal,
+ TeamId: vault.TeamId,
+ KeyGeneration: (uint)vault.KeyGeneration,
+ Permissions: 0,
+ WrappedVaultKey: null,
+ RekeyRequired: vault.RekeyRequired);
+ }
+
/// Lists who can open a vault.
internal async Task ListGrantsAsync(
Vault vault,
diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
index 7f63319..c9e266f 100644
--- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs
+++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
@@ -56,6 +56,7 @@ internal static class EndpointRegistration
typeof(CreateTeamInvitationEndpoint),
typeof(RevokeTeamInvitationEndpoint),
typeof(CreateTeamVaultEndpoint),
+ typeof(RenameVaultEndpoint),
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
typeof(RevokeVaultGrantEndpoint),
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
index 1a8d0e8..821f6f2 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
@@ -260,6 +260,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
public interface IVaultGrantApi
{
+ ///
+ /// Renames a vault.
+ ///
+ ///
+ /// Here rather than on because the subject is a vault, and because the vaults
+ /// screen that calls it is about vaults — the team a vault belongs to is behind it, and renaming one
+ /// is not an operation on the team. The server renames that team with it where it owns nothing else.
+ ///
+ Task RenameVaultAsync(
+ Guid vaultId,
+ UpdateVaultRequest request,
+ CancellationToken cancellationToken);
+
/// Lists who holds a key to this vault.
Task ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
@@ -559,6 +572,18 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
}
+ ///
+ public Task RenameVaultAsync(
+ Guid vaultId,
+ UpdateVaultRequest request,
+ CancellationToken cancellationToken) =>
+ SendAsync(
+ HttpMethod.Put,
+ string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"),
+ JsonContent.Create(request, DodoSshJsonContext.Default.UpdateVaultRequest),
+ DodoSshJsonContext.Default.VaultSummary,
+ cancellationToken);
+
///
public Task ListVaultGrantsAsync(
Guid vaultId,
diff --git a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
index 135e2f4..298eed1 100644
--- a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
@@ -8,7 +8,7 @@ namespace DodoSSH.Client.App.Views;
///
/// Its data context is the VaultViewModel, in all three of the places it is shown, so every binding
/// in the markup is a property of the vault. See , and
-/// .
+/// .
///
internal sealed partial class ConfirmDeleteCard : UserControl
{
diff --git a/src/DodoSSH.Client.App/Views/HostDrawer.axaml b/src/DodoSSH.Client.App/Views/HostDrawer.axaml
index 7018038..24b4813 100644
--- a/src/DodoSSH.Client.App/Views/HostDrawer.axaml
+++ b/src/DodoSSH.Client.App/Views/HostDrawer.axaml
@@ -49,11 +49,16 @@
hosts screen is a sibling of the WebView. See MainWindow.axaml's occlusion rule.
── WHAT THE DESIGN DRAWS HERE AND THIS PANE HAS NOT GOT ─────────────────────────────────────────────
- Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", the backspace-key mapping row and the vault
- picker's chevron. Five controls with nothing behind them: sharing is per vault and not per item, every
- session here is an SSH channel, there are no identity or certificate item types, nothing carries a
- terminal setting to the renderer, and an item cannot be moved between vaults at all. They are listed in
- docs/design-import-gaps.md with what ships instead, and none of them is drawn disabled.
+ Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", and the backspace-key mapping row. Four
+ controls with nothing behind them: sharing is per vault and not per item, every session here is an SSH
+ channel, there are no identity or certificate item types, and nothing carries a terminal setting to the
+ renderer. They are listed in docs/design-import-gaps.md with what ships instead, and none of them is
+ drawn disabled.
+
+ The design's fifth missing control was the vault picker's chevron, and half of it now exists: a host
+ being *created* is asked which vault it goes into, in the editor below. What still does not exist is
+ the other half — moving an existing host — because the two vaults are encrypted under different keys,
+ so that is a delete and a retype rather than an edit.
-->
@@ -358,6 +364,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
@@ -180,14 +180,17 @@
-
+
-
-
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index b79b599..d464df8 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -112,7 +112,7 @@ internal sealed partial class MainWindow : Window
private IInputElement KeyboardHome => shell switch
{
{ IsTerminalShowing: true } => Terminal,
- { Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
+ { Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
diff --git a/src/DodoSSH.Client.App/Views/NavRail.axaml b/src/DodoSSH.Client.App/Views/NavRail.axaml
index 977a870..1d8d0ac 100644
--- a/src/DodoSSH.Client.App/Views/NavRail.axaml
+++ b/src/DodoSSH.Client.App/Views/NavRail.axaml
@@ -33,10 +33,10 @@
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
counting buckets is going anyway.
- One of the destinations — TEAM — reaches a screen that says it is not built. It is in the list anyway
- rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it
- says plainly what is missing, and a list that quietly had fewer entries would make sharing look like a
- change of product rather than the next milestone. FILES was the other one until M2 built it.
+ The last entry was TEAMS and is now VAULTS, which is a change of subject rather than of destination: the
+ screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
+ VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
+ window, and the button's own comment says which is which.
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
of those hold the selection themselves, so a click moves the highlight before the shell can decide
@@ -76,9 +76,9 @@
with the list under it. It no longer does: buckets have their own entry above, so this number and
this screen now count the same things.
-->
-
@@ -125,18 +125,27 @@
-
+
+ CommandParameter="{x:Static vm:ShellScreen.Vaults}"
+ ToolTip.Tip="Your vaults, the people in each one, and who holds a key">
-
+
diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
index 1def439..011adf4 100644
--- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
@@ -191,7 +191,7 @@
+ Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." />
diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs
deleted file mode 100644
index 190e15d..0000000
--- a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Avalonia.Controls;
-
-namespace DodoSSH.Client.App.Views;
-
-/// Teams: who is in one, what they may do, and which vaults they hold a key to.
-internal sealed partial class TeamsScreen : UserControl
-{
- public TeamsScreen() => InitializeComponent();
-}
diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml
index c8d6c68..e23bcf1 100644
--- a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml
+++ b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml
@@ -162,7 +162,7 @@
HorizontalContentAlignment="Left"
Content="New vault…"
Click="OnNewVaultPressed"
- ToolTip.Tip="Names a vault and makes a team to own it, so you can invite people to it and give them roles" />
+ ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles" />
diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml
similarity index 57%
rename from src/DodoSSH.Client.App/Views/TeamsScreen.axaml
rename to src/DodoSSH.Client.App/Views/VaultsScreen.axaml
index 013dc1c..3b3e3c8 100644
--- a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml
@@ -2,141 +2,140 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:contracts="using:DodoSSH.Contracts"
- x:Class="DodoSSH.Client.App.Views.TeamsScreen"
- x:DataType="vm:TeamsViewModel">
+ x:Class="DodoSSH.Client.App.Views.VaultsScreen"
+ x:DataType="vm:VaultsViewModel">
-
+
-
+ Command="{Binding NewVaultCommand}" IsEnabled="{Binding !IsBusy}"
+ ToolTip.Tip="Makes a vault you can share. Its key is generated on this machine, and nobody else has it until you hand it out." />
-
+
-
+
-
+
+
+
+ IsVisible="{Binding !HasVaults}"
+ Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
-
-
-
+
+
-
+ ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
@@ -146,24 +145,22 @@
+ BorderBrush="{StaticResource Border}" IsVisible="{Binding IsRenamingVault}">
-
-
-
+
+
+ Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes; the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
-
-
+
+
+
+
+
+
+
-
+
+
+
+
-
+
@@ -223,27 +240,27 @@
+ ToolTip.Tip="May pull this vault and may not push. It does not withdraw a key they already hold." />
+ ToolTip.Tip="May read and change what is in this vault." />
+ ToolTip.Tip="May also add and remove people, rename the vault, and share its key." />
-
+ ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
@@ -263,12 +280,12 @@
+ Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
@@ -278,7 +295,7 @@
-
+
+ ToolTip.Tip="Signing in with that address will no longer put them in this vault. An invitation already taken up is a membership — remove the member instead." />
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
+ ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
-
+
@@ -372,7 +358,16 @@
+
+
+
@@ -380,7 +375,7 @@
+ Text="Make a vault on the left, or wait for somebody to add you to one. A vault holds hosts and credentials that a group of people share; its key is what makes those readable, and that key is handed out by people rather than by the server." />
diff --git a/src/DodoSSH.Client.App/Views/VaultsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml.cs
new file mode 100644
index 0000000..71defe9
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Vaults: which there are, who is in each, and who holds a key to it.
+internal sealed partial class VaultsScreen : UserControl
+{
+ public VaultsScreen() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.Session/VaultSharing.cs b/src/DodoSSH.Client.Session/VaultSharing.cs
index 794b141..1279a0d 100644
--- a/src/DodoSSH.Client.Session/VaultSharing.cs
+++ b/src/DodoSSH.Client.Session/VaultSharing.cs
@@ -94,6 +94,53 @@ public sealed partial class VaultSession
}
}
+ ///
+ /// Renames a vault, here and on the server.
+ ///
+ /// The vault calls.
+ /// The vault to rename.
+ /// What to call it. Plaintext, as all vault names are.
+ /// Cancellation token.
+ /// The vault as this machine now holds it.
+ ///
+ ///
+ /// Nothing is re-encrypted. A vault's name is the one thing about it the server stores in the clear —
+ /// a person has to be able to choose a vault before anything is decrypted — so a rename is a plain
+ /// column write at both ends and touches no key.
+ ///
+ ///
+ /// The cached row is edited rather than replaced with the response. The server answers with a
+ /// summary written for a caller who is not this one: no wrapped key and no permissions, because it
+ /// has nothing to say about either that this session does not already hold. Replacing the cached row
+ /// with it would take this machine's own grant away and leave the vault unreadable until the next
+ /// refresh.
+ ///
+ ///
+ public async Task RenameVaultAsync(
+ IVaultGrantApi api,
+ Guid vaultId,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ ArgumentNullException.ThrowIfNull(api);
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ var summary = await api
+ .RenameVaultAsync(vaultId, new UpdateVaultRequest(name), cancellationToken)
+ .ConfigureAwait(false);
+
+ var stored = Vaults.FirstOrDefault(vault => vault.VaultId == vaultId) is { } known
+ ? known with { Name = summary.Name }
+ : ToStored(summary);
+
+ await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
+
+ Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
+
+ return stored;
+ }
+
///
/// Wraps a vault's key to another member, after verifying their published key.
///
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index ec6e321..8c8fb57 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -31,11 +31,11 @@ internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPe
{
/// What the switch says.
///
- /// A team vault is marked as one, exactly as it is in the "file this into" picker, and for a weaker
+ /// A shared vault is marked as one, exactly as it is in the "file this into" picker, and for a weaker
/// version of the same reason: two vaults may hold a host with the same label, and which vault a switch
/// is about is the only thing that tells the two switches apart.
///
- internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
+ internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
/// Whether this vault can be switched off.
///
@@ -100,11 +100,22 @@ internal enum ShellScreen
/// File transfer over SFTP: two directory panes and a queue.
Transfers = 1,
- /// Everything in the vault that is not a host.
- Vault = 2,
+ /// Everything in the open vault that is not a host: keys, passwords, buckets, tags.
+ ///
+ /// Named for what the rail calls it rather than for the vault it reads, which is what it was called
+ /// when arrived beside it. Two members a letter apart, one meaning "one vault's
+ /// contents" and the other "the vaults themselves", is a pair somebody eventually gets the wrong way
+ /// round.
+ ///
+ Keychain = 2,
- /// Shared vaults and the people in them. Both heads draw it.
- Team = 3,
+ /// The vaults themselves and the people in them. Both heads draw it.
+ ///
+ /// Was Team, and the value is unchanged with it: the screen is the same destination, and these
+ /// numbers are written into NavRail.axaml as x:Static literals. What changed is what the
+ /// screen is about — see .
+ ///
+ Vaults = 3,
/// Preferences.
Preferences = 4,
@@ -275,7 +286,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
private readonly ConnectionRecorder connectionLog;
- private readonly TeamsViewModel teams;
+ private readonly VaultsViewModel vaults;
///
/// The tab standing in for each connection that has been asked for and has not answered yet.
@@ -381,7 +392,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// The third argument is how a vault made over there reaches the lists and the menu over here: both
// are built from the session's vault list, and neither would otherwise learn that it had grown until
// something else happened to rebuild them.
- teams = new TeamsViewModel(() => connection, () => Vault?.Session, OnVaultsChangedAsync);
+ vaults = new VaultsViewModel(() => connection, () => Vault?.Session, OnVaultsChangedAsync);
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends.
@@ -532,15 +543,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private LogsViewModel? logsScreen;
///
- /// The teams screen, which the window binds to whether or not a vault is open.
+ /// The vaults screen, which the window binds to whether or not a vault is open.
///
///
- /// Not nullable and never replaced, for the reason is not: the screen reads a
- /// server rather than a vault, and both of its dependencies are fetched through a function at the
- /// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
- /// to rebuild it, and the list it is showing survives both.
+ /// Not nullable and never replaced, for the reason is not: both of its
+ /// dependencies are fetched through a function at the moment they are needed. That means a lock does
+ /// not have to tear it down and an unlock does not have to rebuild it, and the list it is showing
+ /// survives both.
+ ///
+ /// Distinct from , which is one vault's contents — the hosts, keys and
+ /// passwords the rail's other screens draw. This one is the vaults themselves and the people in them.
+ ///
///
- internal TeamsViewModel Teams => teams;
+ internal VaultsViewModel Vaults => vaults;
/// The transfers screen, which the window binds to whether or not a vault is open.
///
@@ -759,10 +774,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
///
- internal bool IsVaultScreen => Screen is ShellScreen.Vault;
+ internal bool IsKeychainScreen => Screen is ShellScreen.Keychain;
///
- internal bool IsTeamScreen => Screen is ShellScreen.Team;
+ internal bool IsVaultsScreen => Screen is ShellScreen.Vaults;
///
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
@@ -801,10 +816,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
///
- internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
+ internal bool IsKeychainShowing => IsShowingPages && IsKeychainScreen;
///
- internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
+ internal bool IsVaultsShowing => IsShowingPages && IsVaultsScreen;
///
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
@@ -834,8 +849,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// tabs are each exactly one thing, and this one is seven.
///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
- /// the rail and never asks this. is in it for the same reason and no
- /// other: the desktop has a rail entry for teams and the phone reaches them through the hub, so a
+ /// the rail and never asks this. is in it for the same reason and no
+ /// other: the desktop has a rail entry for it and the phone reaches it through the hub, so a
/// screen missing here is one whose arrival darkens the tab that led to it and brings the shell's own
/// header back over a screen that already has one.
///
@@ -849,7 +864,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
- or ShellScreen.Team or ShellScreen.Vault;
+ or ShellScreen.Vaults or ShellScreen.Keychain;
///
/// Whether the terminal's WebView may be on screen at this instant.
@@ -1107,6 +1122,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and the two would have had to be kept in step. and
/// are the other two tabs, unchanged and already used by both heads.
///
+ ///
+ /// Not , which is one of the nine screens underneath this tab. The
+ /// two are true together whenever somebody is looking at the vaults screen and are otherwise unrelated:
+ /// this one is "the strip is on its first tab rather than on SFTP, S3 or a terminal".
+ ///
///
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
@@ -1265,19 +1285,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
///
- /// Goes to the teams screen with the new-vault form open.
+ /// Goes to the vaults screen with the new-vault form open.
///
///
- /// A vault gets a team, so the place to make one is the screen that shows teams — where the people, the
- /// roles and the key holders already are, which is the next thing anybody making a shared vault wants.
- /// The form asks for a name and nothing else; see TeamsViewModel.CreateVaultAsync for what is
+ /// The screen a vault is made on is the one that shows vaults — where the people, the roles and the key
+ /// holders already are, which is the next thing anybody making a shared vault wants. The form asks for a
+ /// name and nothing else; see VaultsViewModel.CreateVaultAsync for the membership list that is
/// made behind it.
///
[RelayCommand]
private void ShowNewVault()
{
- ShowScreen(ShellScreen.Team);
- teams.NewVaultInItsOwnTeamCommand.Execute(null);
+ ShowScreen(ShellScreen.Vaults);
+ vaults.NewVaultCommand.Execute(null);
}
// ---- The phone's connect menu ----
@@ -2964,13 +2984,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ = logs.RefreshCommand.ExecuteAsync(null);
}
- // Teams are read from the server rather than from the vault, so there is nothing to show until
- // somebody asks for it — and asking for it on every unlock would be a request per launch for a
- // screen most people never open. Fire-and-forget because a property change cannot await, and
- // because the view model turns every failure into its own status line rather than throwing.
- if (value is ShellScreen.Team)
+ // Who is in each vault is read from the server rather than from the vault itself, so there is
+ // nothing to show until somebody asks for it — and asking for it on every unlock would be a request
+ // per launch for a screen most people never open. Fire-and-forget because a property change cannot
+ // await, and because the view model turns every failure into its own status line rather than
+ // throwing.
+ if (value is ShellScreen.Vaults)
{
- _ = teams.LoadAsync(CancellationToken.None);
+ _ = vaults.LoadAsync(CancellationToken.None);
}
}
@@ -3044,8 +3065,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
- OnPropertyChanged(nameof(IsVaultScreen));
- OnPropertyChanged(nameof(IsTeamScreen));
+ OnPropertyChanged(nameof(IsKeychainScreen));
+ OnPropertyChanged(nameof(IsVaultsScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen));
@@ -3058,8 +3079,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsVaultsTab));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
- OnPropertyChanged(nameof(IsVaultShowing));
- OnPropertyChanged(nameof(IsTeamShowing));
+ OnPropertyChanged(nameof(IsKeychainShowing));
+ OnPropertyChanged(nameof(IsVaultsShowing));
OnPropertyChanged(nameof(IsPreferencesShowing));
OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing));
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index b447e76..d9074aa 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -864,11 +864,12 @@ internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPe
/// What the picker shows.
///
///
- /// A team vault is marked as one. The whole risk this picker introduces is putting a credential
+ /// A shared vault is marked as one. The whole risk this picker introduces is putting a credential
/// somewhere more people can read it, so the option that does that must not look like the option
- /// that does not.
+ /// that does not. It says SHARED rather than TEAM because a team is no longer something the person
+ /// choosing has been shown — see VaultsViewModel.
///
- internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
+ internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
}
internal sealed record VaultItemRowViewModel(
@@ -1064,6 +1065,18 @@ internal sealed partial class VaultViewModel(
///
private Dictionary groupsById = [];
+ ///
+ /// Every readable vault's groups, kept apart by the vault they live in.
+ ///
+ ///
+ /// What the host editor's group picker is built from, and it has to be per vault rather than the one
+ /// list holds. A group is an item like any other, so it lives in exactly one
+ /// vault; offering the personal vault's groups while a host is being filed into a shared one would
+ /// produce a host whose group id nobody else in that vault can resolve — a colleague would see it
+ /// filed under nothing, which is the quietest kind of wrong. See .
+ ///
+ private Dictionary> groupsByVault = [];
+
///
/// The tags as they came out of the vault, before the host counts are attached.
///
@@ -1916,10 +1929,50 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private AuthenticationChoice? editorSelectedAuthentication;
- /// What the group picker offers: "no group", then every group.
+ /// What the group picker offers: "no group", then every group of the chosen vault.
///
internal ObservableCollection EditorGroupChoices { get; } = [];
+ ///
+ /// Which vault a host being created will be filed into.
+ ///
+ ///
+ ///
+ /// The picker in the host editor itself, and it is a second one rather than the keychain screen's
+ /// reused: that one is a standing preference about where new items go and
+ /// this is a field of the host in front of you. Binding both to one selection would mean the box under
+ /// SSH KEYS moved every time somebody put a host somewhere, and — the other way round — that a host
+ /// half-typed on this screen could be moved by a click on that one, which is the bug
+ /// was introduced to prevent.
+ ///
+ ///
+ /// Filled from the same source, so what it offers is what the keychain screen offers: vaults this
+ /// session can both read and write.
+ ///
+ ///
+ internal ObservableCollection EditorVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? editorSelectedVault;
+
+ ///
+ /// Whether the editor should be asking which vault this host goes into.
+ ///
+ ///
+ ///
+ /// Only while creating, and only where there is more than one vault to choose between. An existing
+ /// host's vault is not editable and the picker is not shown disabled beside it: the two are encrypted
+ /// under different keys, so moving an item is a delete and a retype rather than a save — see the note
+ /// on the drawer's header, which says where the host is filed.
+ ///
+ ///
+ /// Hidden at one vault rather than shown with a single option, which is the rule
+ /// already applies for the same reason: a control offering one answer is
+ /// a question nobody was asked.
+ ///
+ ///
+ internal bool ShowsEditorVaultChoice => editingEntityId is null && EditorVaultChoices.Count > 1;
+
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
[NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))]
@@ -2990,6 +3043,8 @@ internal sealed partial class VaultViewModel(
// still this one's.
groupItems = [];
+ var perVault = new Dictionary>();
+
foreach (var vault in session.ReadableVaults)
{
var listing = await session.HostGroups
@@ -3003,6 +3058,13 @@ internal sealed partial class VaultViewModel(
resolvable[group.EntityId] = group.Secret;
}
+ perVault[vault.VaultId] =
+ [
+ .. listing.Items
+ .OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)
+ .Select(group => new GroupChoice(group.EntityId, group.Secret.Label)),
+ ];
+
if (vault.VaultId == session.ActiveVaultId)
{
groupItems =
@@ -3011,6 +3073,7 @@ internal sealed partial class VaultViewModel(
}
groupsById = resolvable;
+ groupsByVault = perVault;
return unreadable;
}
@@ -4118,7 +4181,12 @@ internal sealed partial class VaultViewModel(
}
editingEntityId = null;
+
+ // The keychain screen's picker is the default rather than the answer: the editor has a picker of its
+ // own from here on, and moving that one is what decides where this host lands. See
+ // EditorVaultChoices.
editingHostVaultId = TargetVaultId;
+
EditorLabel = string.Empty;
EditorHostname = string.Empty;
@@ -4132,13 +4200,18 @@ internal sealed partial class VaultViewModel(
EditorNewTag = string.Empty;
BuildTagChoices();
+ // Before the group picker, because a group belongs to one vault and the picker is that vault's.
+ BuildEditorVaultChoices(editingHostVaultId);
+
// A new host opens in the group the screen is already about — the card that is selected, or failing
// that the group whose contents are showing. Adding three machines to the group somebody has just
// made is the ordinary case, and since the grid holds one level at a time the alternative is worse
// than a default nobody chose: a host created inside a group and filed under none would vanish from
- // the screen it was created on. Before the picker, because whether there is a group to inherit from
- // decides whether the picker offers to.
- BuildGroupChoices(GroupTarget?.EntityId);
+ // the screen it was created on. Only when that group is in the vault this host is going into,
+ // though — the grid draws the active vault's groups, and inheriting one into a shared vault would
+ // file the host under something nobody else in it can resolve. Before the authentication picker,
+ // because whether there is a group to inherit from decides whether that one offers to.
+ BuildGroupChoices(GroupInEditingVault(GroupTarget?.EntityId));
BuildAuthenticationChoices(
boundKeyId: null,
@@ -4167,7 +4240,13 @@ internal sealed partial class VaultViewModel(
}
editingEntityId = row.EntityId;
+
+ // The host's own vault, and it does not move: the two are encrypted under different keys, so
+ // saving anywhere else would fork it rather than move it. The picker is hidden for an existing
+ // host — see ShowsEditorVaultChoice — and is filled anyway so that it is not showing the last
+ // host's vault behind the panel.
editingHostVaultId = row.VaultId;
+
EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname;
@@ -4182,6 +4261,7 @@ internal sealed partial class VaultViewModel(
EditorNewTag = string.Empty;
BuildTagChoices();
+ BuildEditorVaultChoices(editingHostVaultId);
BuildGroupChoices(row.Host.GroupId);
BuildAuthenticationChoices(
@@ -6873,14 +6953,98 @@ internal sealed partial class VaultViewModel(
/// somebody editing the host's port would unfile it by saving. It says the group is gone rather than
/// naming it, because there is nothing left to read the name off.
///
+ /// Refills the host editor's vault picker, landing on the vault the editor will write to.
+ ///
+ /// Filled from , which is already the readable-and-writable set and is kept
+ /// in step with the session by . The options are shared objects rather
+ /// than copies, so the two pickers show the same names without either one being able to move the other:
+ /// what they do not share is the selection.
+ ///
+ private void BuildEditorVaultChoices(Guid vaultId)
+ {
+ EditorVaultChoices.Clear();
+
+ foreach (var choice in TargetVaults)
+ {
+ EditorVaultChoices.Add(choice);
+ }
+
+ // Null where the host's vault is one this session cannot write — a team vault this account is a
+ // viewer of. The picker is hidden for an existing host anyway, and leaving the box empty is a
+ // better answer than adding an option that would move the host if it were touched.
+ EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
+
+ OnPropertyChanged(nameof(ShowsEditorVaultChoice));
+ }
+
+ ///
+ /// Moves a half-typed host into the vault just chosen for it.
+ ///
+ ///
+ /// Only while creating. An existing host's vault is fixed, and this guard is what makes that true
+ /// rather than the view merely not drawing the control: an item cannot be moved between vaults, so a
+ /// path that reassigned this on an edit would write the host into a second vault and leave the
+ /// original behind.
+ ///
+ partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
+ {
+ if (value is null || editingEntityId is not null || editingHostVaultId == value.VaultId)
+ {
+ return;
+ }
+
+ editingHostVaultId = value.VaultId;
+
+ var authentication = EditorSelectedAuthentication;
+
+ // The group picker is the vault's, so it has to be rebuilt — and whatever was chosen in it belongs
+ // to the vault just left, so it is kept only if the new one has it too. Which in practice means it
+ // is dropped, because a group is one item in one vault.
+ BuildGroupChoices(GroupInEditingVault(EditorSelectedGroup?.EntityId));
+
+ // Rebuilt after it, because "inherit from group" is offered only to a host that is in one — and
+ // whether this one still is has just been decided above. The key and credential entries are not
+ // filtered by vault, unlike the groups: the key list spans every readable vault by design, and a
+ // host authenticating with a key from another vault is a thing this application already supports.
+ BuildAuthenticationChoices(
+ authentication?.Kind == AuthenticationKind.SshKey ? authentication.EntityId : null,
+ authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
+ asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
+ grouped: EditorSelectedGroup?.EntityId is not null);
+ }
+
+ /// The group, if the vault being written to actually has it; otherwise none.
+ private Guid? GroupInEditingVault(Guid? groupId) =>
+ groupId is { } id
+ && groupsByVault.TryGetValue(editingHostVaultId, out var groups)
+ && groups.Any(choice => choice.EntityId == id)
+ ? id
+ : null;
+
+ /// Refills the host editor's group picker for one vault.
+ /// The group to land on, or null for none.
+ ///
+ ///
+ /// The vault's own groups and no others — see . A vault this session cannot
+ /// read has no entry there and gets an empty list rather than the active vault's, which is the right
+ /// answer for a picker: there is nothing in it that this host could be filed under.
+ ///
+ ///
+ /// A group the vault no longer has keeps a placeholder entry, so that editing a host's port cannot
+ /// quietly unfile it.
+ ///
+ ///
private void BuildGroupChoices(Guid? groupId)
{
EditorGroupChoices.Clear();
EditorGroupChoices.Add(GroupChoice.None);
- foreach (var group in Groups)
+ if (groupsByVault.TryGetValue(editingHostVaultId, out var groups))
{
- EditorGroupChoices.Add(new GroupChoice(group.EntityId, group.Label));
+ foreach (var group in groups)
+ {
+ EditorGroupChoices.Add(group);
+ }
}
if (groupId is { } bound && !EditorGroupChoices.Any(choice => choice.EntityId == bound))
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TeamsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
similarity index 57%
rename from src/DodoSSH.Client.Shell/ViewModels/TeamsViewModel.cs
rename to src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
index 4a572cc..241a4d5 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/TeamsViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
@@ -10,29 +10,107 @@ using DodoSSH.Contracts;
namespace DodoSSH.Client.Shell.ViewModels;
-/// One team, as a row in the list.
-internal sealed record TeamRowViewModel(TeamSummary Team)
+/// One vault, as a row in the list.
+///
+///
+/// Built from what this machine holds — — with the membership facts
+/// laid over it from the server. That order is the right way round: whether a vault exists and whether
+/// this machine can open it are things the session knows and the server cannot, and how many people are
+/// in it is the reverse. A row assembled the other way would go blank the moment the network did.
+///
+///
+/// is for a personal vault and for any
+/// vault whose team has not been read yet, which is what distinguishes.
+///
+///
+/// The vault.
+/// What it is called. Plaintext, unlike everything in it.
+/// Whether this is the vault nobody else can ever be added to.
+/// The membership list behind it, for a shared vault.
+/// This account's own role in it.
+/// How many people are in it, including this account.
+///
+/// How many other vaults the same people are in by virtue of the same membership list.
+///
+/// Zero for everything this screen makes, because a vault made here gets a membership list of its own.
+/// It is not zero for a team that owned several vaults before this screen existed, or one an operator
+/// arranged that way — and in that case adding somebody here adds them to those vaults too, which is the
+/// one thing a vault-shaped screen could otherwise quietly hide.
+///
+///
+/// Whether this machine holds the key.
+/// Whether the server would accept a change to it.
+/// Whether a membership change has left it owing a rekey.
+internal sealed record VaultRowViewModel(
+ Guid VaultId,
+ string Name,
+ bool IsPersonal,
+ Guid? TeamId,
+ TeamMemberRole Role,
+ int MemberCount,
+ int SharedWithOtherVaults,
+ bool IsReadable,
+ bool CanWrite,
+ bool RekeyRequired)
{
- internal Guid TeamId => Team.TeamId;
+ /// Whether anybody else can be in this vault at all.
+ internal bool IsShared => TeamId is not null;
- internal string Name => Team.Name;
+ /// Whether the server has told us who is in it.
+ internal bool MembershipIsKnown => Role != TeamMemberRole.Unspecified;
- internal string Slug => Team.Slug;
+ /// The chip on the right of the row.
+ ///
+ /// A personal vault says PERSONAL rather than a role. It has no membership list, so OWNER would be
+ /// naming a position in a group of one — and the fact worth carrying about it is precisely that it
+ /// is the one vault sharing cannot reach.
+ ///
+ internal string RoleLabel => (IsPersonal, MembershipIsKnown) switch
+ {
+ (true, _) => "PERSONAL",
+ (_, true) => Role.ToString().ToUpperInvariant(),
+ _ => string.Empty,
+ };
- /// The caller's own role, as the chip the list shows.
- internal string Role => Team.Role.ToString().ToUpperInvariant();
+ /// What the row says under its name.
+ internal string Detail => (IsPersonal, MembershipIsKnown) switch
+ {
+ (true, _) => "only you — nothing in here is shared",
+ (_, false) => "shared — connect to see who is in it",
+ _ => string.Create(CultureInfo.CurrentCulture, $"{MemberCount} member(s)"),
+ };
- internal string Detail => string.Create(
- CultureInfo.CurrentCulture,
- $"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");
-
- /// Whether this account may add members and create vaults here.
+ ///
+ /// Whether this account may add people to it and rename it.
+ ///
+ ///
+ /// True of the personal vault, where there is nobody to add and the rename is still the owner's to
+ /// make. The server agrees: a personal vault answers to its owner with every permission there is.
+ ///
internal bool CanAdminister =>
- Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
+ IsPersonal || (IsShared && Role is TeamMemberRole.Admin or TeamMemberRole.Owner);
+
+ /// Whether this account is the one that can hand the vault on.
+ internal bool IsOwned => IsShared && Role == TeamMemberRole.Owner;
+
+ /// What is true of this vault right now, where that is worth a line.
+ ///
+ /// Empty in the ordinary case rather than saying "fine". The two states here are both temporary and
+ /// both mean somebody has to do something, so a row that carried a permanent reassurance beside them
+ /// would train people to stop reading it.
+ ///
+ internal string State => (IsReadable, RekeyRequired) switch
+ {
+ (false, _) => "waiting for a key — ask somebody who holds one to share it",
+ (true, true) => "a rekey is owed after a membership change",
+ _ => string.Empty,
+ };
+
+ internal bool HasState => State.Length > 0;
}
-/// One member, as a row in the members table.
-internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
+/// One member of a vault, as a row in the members table.
+internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
{
internal Guid UserId => Member.UserId;
@@ -57,7 +135,7 @@ internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsS
///
internal string KeyState => Member.IsEnrolled
? "key published"
- : "no key yet — cannot be given a vault";
+ : "no key yet — cannot be given this vault";
///
/// The day they were last here, or that they never have been.
@@ -89,7 +167,7 @@ internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsS
/// state instead, and it is a list rather than a count for a reason worth keeping: a grant is per
/// vault, so a number on an item row would imply per-item sharing, which does not exist.
///
-internal sealed record TeamGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration)
+internal sealed record VaultGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration)
{
internal Guid UserId => Grant.RecipientUserId;
@@ -118,7 +196,7 @@ internal sealed record TeamGrantRowViewModel(VaultGrantSummary Grant, uint Vault
}
/// One invitation, as a row under the members it will join.
-internal sealed record TeamInvitationRowViewModel(TeamInvitationSummary Invitation)
+internal sealed record VaultInvitationRowViewModel(TeamInvitationSummary Invitation)
{
internal Guid InvitationId => Invitation.InvitationId;
@@ -147,67 +225,60 @@ internal sealed record TeamInvitationRowViewModel(TeamInvitationSummary Invitati
}
///
-/// A destructive team operation, armed and waiting to be confirmed.
+/// A destructive vault operation, armed and waiting to be confirmed.
///
///
-/// The armed-state idiom the vault screen uses, and for the same reason: this window has no modal, so
-/// a confirmation is drawn in place of the buttons that armed it. The target id is carried here rather
+/// The armed-state idiom the keychain screen uses, and for the same reason: this window has no modal, so
+/// a confirmation is drawn in place of the buttons that armed it. The target ids are carried here rather
/// than read from the selection at confirm time — otherwise selecting a different row between arming
/// and confirming would apply the answer to something else.
///
-/// The team the action is aimed at.
-/// The member it is aimed at, for a transfer.
+/// The membership list the action is aimed at.
+/// The member it is aimed at.
/// What is being asked.
/// What will actually happen, stated honestly.
-internal sealed record TeamActionRequest(
+internal sealed record VaultActionRequest(
Guid TeamId,
Guid MemberId,
string Question,
string Consequence);
-/// One vault of the selected team, with what this account can do to it.
-internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
-{
- /// What the row says about itself.
- ///
- /// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
- /// somebody has been added to a team and nobody has wrapped the vault key to them yet.
- ///
- internal string State => (IsReadable, RekeyRequired) switch
- {
- (false, _) => "waiting for a key — ask a member who has one to share it",
- (true, true) => "readable · a rekey is owed after a membership change",
- _ => "readable",
- };
-}
-
///
-/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
+/// The vaults screen: which vaults there are, who is in each, and who holds a key to it.
///
///
///
-/// Two separate acts, and the screen is built around saying so. Adding somebody to a team is a
-/// server-side authorization change and takes effect immediately. Giving them a vault key is a
-/// cryptographic act only a machine with that key can perform, and until somebody does it their vault
-/// list shows an entry they cannot open. Every product that hides this ends up implying the server can
-/// hand out access on its own — which, here, it cannot. See TeamService and ADR 0001.
+/// This was the teams screen, and the team is now behind the vault rather than above it. A team is
+/// still what the server authorises against — VaultAccessService resolves a team vault through
+/// team_membership, and every membership call here names a team id. What changed is that nobody is
+/// asked to make one: naming a vault makes the membership list to carry it, so the thing people came to
+/// share is the thing they create, and "which team is this in" stops being a question they have to have an
+/// answer to before they can share four servers with two colleagues.
///
///
-/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
-/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
-/// would be a second copy of something the server is authoritative for.
+/// Two separate acts, and the screen is still built around saying so. Adding somebody to a vault is
+/// a server-side authorization change and takes effect immediately. Giving them the vault key is a
+/// cryptographic act only a machine with that key can perform, and until somebody does it their vault list
+/// shows an entry they cannot open. Every product that hides this ends up implying the server can hand out
+/// access on its own — which, here, it cannot. See ADR 0009.
+///
+///
+/// The vault list is read from the session and works with no network. Everything under it — members,
+/// invitations, key holders — is read from the server on selection and after each change, because
+/// membership is not vault content and has no local mirror.
///
///
+/// The server, or null where there is none.
+/// The unlocked session, or null while the keychain is locked.
///
-/// Told when this screen has created a vault, or null where nobody is listening.
+/// Told when this screen has made or renamed a vault, or null where nobody is listening.
///
/// A delegate rather than an event, and optional, for the reason the two dependencies above are functions:
/// this screen is built once and outlives every lock, so a subscription would be one more thing to detach
/// at exactly the right moment. The one listener is the shell, which has a tab-strip menu and a set of host
/// lists that are both a vault out of date the instant this screen makes one.
-///
///
-internal sealed partial class TeamsViewModel(
+internal sealed partial class VaultsViewModel(
Func connection,
Func session,
Func? vaultsChanged = null) : ObservableObject
@@ -222,14 +293,11 @@ internal sealed partial class TeamsViewModel(
///
private const int MaximumSlugLength = 128;
- /// Teams this account belongs to.
- internal ObservableCollection Teams { get; } = [];
+ /// Every vault this account can see, readable or not.
+ internal ObservableCollection Vaults { get; } = [];
- /// Members of the selected team.
- internal ObservableCollection Members { get; } = [];
-
- /// Vaults the selected team owns, as far as this account can see them.
- internal ObservableCollection Vaults { get; } = [];
+ /// The people in the selected vault.
+ internal ObservableCollection Members { get; } = [];
/// Who holds a key to the selected vault.
///
@@ -237,22 +305,19 @@ internal sealed partial class TeamsViewModel(
/// has to be: the keyring can only answer whether this machine can open a vault, and this
/// question is about everybody else.
///
- internal ObservableCollection Grants { get; } = [];
+ internal ObservableCollection Grants { get; } = [];
/// Invitations to addresses that are not accounts here yet.
- internal ObservableCollection Invitations { get; } = [];
+ internal ObservableCollection Invitations { get; } = [];
[ObservableProperty]
- private TeamRowViewModel? selectedTeam;
+ private VaultRowViewModel? selectedVault;
[ObservableProperty]
- private TeamMemberRowViewModel? selectedMember;
+ private VaultMemberRowViewModel? selectedMember;
[ObservableProperty]
- private TeamVaultRowViewModel? selectedVault;
-
- [ObservableProperty]
- private TeamInvitationRowViewModel? selectedInvitation;
+ private VaultInvitationRowViewModel? selectedInvitation;
[ObservableProperty]
private string status = string.Empty;
@@ -260,17 +325,6 @@ internal sealed partial class TeamsViewModel(
[ObservableProperty]
private bool isBusy;
- // ---- Creating a team ----
-
- [ObservableProperty]
- private bool isCreatingTeam;
-
- [ObservableProperty]
- private string newTeamName = string.Empty;
-
- [ObservableProperty]
- private string newTeamSlug = string.Empty;
-
// ---- Creating a vault ----
[ObservableProperty]
@@ -280,39 +334,25 @@ internal sealed partial class TeamsViewModel(
private string newVaultName = string.Empty;
///
- /// The team the vault being named will belong to, or null for one made along with it.
+ /// The membership list a half-finished create already made, held so the retry does not make a second.
///
///
- /// Captured when the form is armed rather than read from when CREATE is
- /// pressed, for the reason carries its own ids: a click in the team
- /// list between the two would otherwise redirect a vault into a team the user was not looking at when
- /// they typed its name.
- ///
- private Guid? newVaultTeamId;
-
- ///
- /// The team a half-finished create already made, held so the retry does not make a second one.
- ///
- ///
- /// Creating a vault of its own is two calls, and the first can succeed while the second fails. The id
- /// is generated once and kept here, which is the whole of the idempotency story: TeamService
+ /// Creating a vault is two calls, and the first can succeed while the second fails. The id is
+ /// generated once and kept here, which is the whole of the idempotency story: TeamService
/// treats an identical repeat of a create it has already accepted as the same team rather than a new
/// one, so pressing CREATE again resends the first call harmlessly and then retries the second.
///
private Guid? pendingVaultTeamId;
- // ---- Renaming a team ----
+ // ---- Renaming a vault ----
[ObservableProperty]
- private bool isEditingTeam;
+ private bool isRenamingVault;
[ObservableProperty]
- private string editTeamName = string.Empty;
+ private string editVaultName = string.Empty;
- [ObservableProperty]
- private string editTeamDescription = string.Empty;
-
- // ---- Adding a member ----
+ // ---- Adding somebody ----
[ObservableProperty]
private string inviteEmail = string.Empty;
@@ -331,18 +371,18 @@ internal sealed partial class TeamsViewModel(
// ---- Confirming something that cannot be undone ----
[ObservableProperty]
- private TeamActionRequest? pendingAction;
+ private VaultActionRequest? pendingAction;
/// Set while reselects, so the handler does not read as well.
private bool isReselecting;
///
- /// Which selection read owns the lists below the team list.
+ /// Which selection read owns the lists beside the vault list.
///
///
- /// Selecting a second team before the first one's read has answered leaves two reads in flight
+ /// Selecting a second vault before the first one's read has answered leaves two reads in flight
/// against the same collections, and the one that started first can answer last — so the
- /// superseded read drops its answer instead of appending another team's members to the list. UI
+ /// superseded read drops its answer instead of appending another vault's members to the list. UI
/// thread only, which is where every selection change and every continuation on this screen runs.
///
private int selectionGeneration;
@@ -350,45 +390,64 @@ internal sealed partial class TeamsViewModel(
/// Whether there is a server to talk to at all.
internal bool IsOnline => connection() is not null;
- /// Whether the selected team can be administered by this account.
- internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
+ /// Whether the keychain is unlocked, which is what makes the vault list readable.
+ internal bool IsUnlocked => session() is not null;
- /// Whether this account owns the selected team.
+ /// Whether the selected vault can be administered by this account.
+ internal bool CanAdministerSelected => SelectedVault?.CanAdminister == true;
+
+ /// Whether this account owns the selected vault's membership list.
///
/// A narrower gate than , and the server draws the same line:
- /// archiving a team and handing it over decide whether it goes on existing and who controls it,
- /// so an admin the owner promoted must not be able to do either.
+ /// handing a vault on decides who controls it, so an admin the owner promoted must not be able to.
///
- internal bool OwnsSelected => SelectedTeam?.Team.Role == TeamMemberRole.Owner;
+ internal bool OwnsSelected => SelectedVault?.IsOwned == true;
- /// Whether there is anything to show below the team list.
- internal bool HasSelection => SelectedTeam is not null;
+ /// Whether the selected vault is one other people can be in.
+ ///
+ /// False for the personal vault, which is not a shortcoming of this screen: the server refuses a
+ /// grant on a personal vault outright, because a key sealed to somebody it will go on refusing to
+ /// serve is a row that looks like sharing and is not. See VaultGrantService.
+ ///
+ internal bool SelectedIsShared => SelectedVault?.IsShared == true;
- internal bool HasTeams => Teams.Count > 0;
+ /// Whether the selected vault is the personal one.
+ internal bool SelectedIsPersonal => SelectedVault?.IsPersonal == true;
+
+ /// Whether there is anything to show beside the vault list.
+ internal bool HasSelection => SelectedVault is not null;
+
+ internal bool HasVaults => Vaults.Count > 0;
/// Whether a destructive action is armed and waiting for an answer.
internal bool IsConfirming => PendingAction is not null;
- /// Whether the ordinary team buttons should be showing.
+ /// Whether the ordinary vault buttons should be showing.
///
/// The inverse of , so the confirmation replaces the buttons that armed
/// it rather than appearing beneath them still pressable.
///
- internal bool ShowsTeamActions => !IsConfirming;
+ internal bool ShowsVaultActions => !IsConfirming;
- /// Whether the selected team has any invitation worth drawing a list for.
+ /// Whether the selected vault has any invitation worth drawing a list for.
internal bool HasInvitations => Invitations.Count > 0;
- /// Where the vault being named will end up, in one line under the box.
+ ///
+ /// The warning a vault sharing its membership list with others has to carry.
+ ///
///
- /// Worth a sentence because the form is in the left column and one of the two ways to open it is a
- /// button in the right pane — so "which team is this going into" is a question the user can reasonably
- /// have, and the answer was fixed when they pressed the button rather than by whatever is selected now.
+ /// Empty in every case this screen creates, because a vault made here gets a membership list of its
+ /// own. It is not empty for the arrangements that pre-date this screen, and there the sentence is the
+ /// whole point: on a vault-shaped screen, adding somebody to one vault silently adding them to three
+ /// others is exactly the fact a vault-shaped screen is in a position to hide.
///
- internal string NewVaultDestination => newVaultTeamId is { } teamId
- && Teams.FirstOrDefault(row => row.TeamId == teamId) is { } team
- ? $"in the team '{team.Name}'"
- : "in a new team of its own, which you will own. Invite people to it once it is made.";
+ internal string SharedMembershipWarning => SelectedVault is { SharedWithOtherVaults: > 0 } row
+ ? string.Create(
+ CultureInfo.CurrentCulture,
+ $"These people are in {row.SharedWithOtherVaults} other vault(s) as well: this vault's membership list is shared with them. Adding or removing somebody here does the same there.")
+ : string.Empty;
+
+ internal bool HasSharedMembershipWarning => SharedMembershipWarning.Length > 0;
internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;
@@ -396,7 +455,7 @@ internal sealed partial class TeamsViewModel(
internal bool AddsAsAdmin => NewMemberRole == TeamMemberRole.Admin;
- /// Reads the teams this account belongs to, and the selected one's detail.
+ /// Reads the vaults this account can see, and the selected one's detail.
internal Task LoadAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
@@ -405,8 +464,8 @@ internal sealed partial class TeamsViewModel(
/// The same work as , exposed as a command because markup cannot invoke a
/// method. The phone needs it and the desktop does not: this screen is loaded on arrival, and on
/// the desktop leaving the rail and coming back is one click, where on the phone it is a trip out
- /// to MORE and back. Nothing on this screen is cached, so a re-read is the only way to see a change
- /// somebody else made.
+ /// to MORE and back. Nothing below the vault list is cached, so a re-read is the only way to see a
+ /// change somebody else made.
///
[RelayCommand]
private Task RefreshAsync(CancellationToken cancellationToken) =>
@@ -418,52 +477,54 @@ internal sealed partial class TeamsViewModel(
///
/// Separate from because every command ends by reloading, and a command that
/// called the gated version would find the gate held by itself and skip the reload silently — leaving
- /// a team that was created moments ago missing from the list it was just added to.
+ /// a vault that was created moments ago missing from the list it was just added to.
///
+ /// Cancellation token.
///
- /// The team to land on, or null to keep the one already selected.
+ /// The vault to land on, or null to keep the one already selected.
///
/// Here rather than assigned after the call, because the assignment fires
- /// and that starts a read nothing can await — so a caller wanting
- /// the new team's vaults on screen would be racing a fire-and-forget. Passed in, the reselect happens
+ /// and that starts a read nothing can await — so a caller wanting
+ /// the new vault's members on screen would be racing a fire-and-forget. Passed in, the reselect happens
/// under the same guard as every other one and the detail read below is the awaited one.
///
///
private async Task ReloadAsync(CancellationToken cancellationToken, Guid? select = null)
{
- if (connection() is not { } server)
+ var selectedId = select ?? SelectedVault?.VaultId;
+
+ Vaults.Clear();
+
+ if (session() is not { } open)
{
- Teams.Clear();
Members.Clear();
- Vaults.Clear();
+ Invitations.Clear();
+ Grants.Clear();
RaiseState();
- Status = "Offline. Teams are read from the server, so this screen needs a connection.";
+ Status = "Locked. Unlock your keychain to see your vaults.";
return;
}
- var selectedId = select ?? SelectedTeam?.TeamId;
+ // The membership facts, where there is a server to ask. Offline the vault list is still right —
+ // it is what this machine holds — and every row simply says its membership is not known.
+ var teams = connection() is { } server
+ ? await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true)
+ : [];
- var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
+ BuildRows(open, teams);
- Teams.Clear();
-
- foreach (var team in teams)
- {
- Teams.Add(new TeamRowViewModel(team));
- }
-
- // The assignment reselects the same team through a new row object, so the selection handler
- // would start its own read of the very lists this method is about to read — two reads
- // clearing and then appending into the same collections, which draws every member, invitation
- // and vault twice. Suppressed rather than deduplicated, because the read below is awaited and
- // the handler's is not: this is the one that has to be the reload's.
+ // The assignment reselects the same vault through a new row object, so the selection handler
+ // would start its own read of the very lists this method is about to read — two reads clearing
+ // and then appending into the same collections, which draws every member, invitation and key
+ // holder twice. Suppressed rather than deduplicated, because the read below is awaited and the
+ // handler's is not: this is the one that has to be the reload's.
isReselecting = true;
try
{
- SelectedTeam =
- Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
+ SelectedVault =
+ Vaults.FirstOrDefault(row => row.VaultId == selectedId) ?? Vaults.FirstOrDefault();
}
finally
{
@@ -474,518 +535,126 @@ internal sealed partial class TeamsViewModel(
await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);
- Status = Teams.Count == 0
- ? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
+ Status = connection() is null
+ ? "Offline. The vaults are this machine's; who is in them is read from the server."
: string.Empty;
}
- /// Opens the create-a-team form.
- [RelayCommand]
- private void NewTeam()
+ /// Fills the vault list from the session, with the server's membership facts laid over it.
+ ///
+ /// Personal first and then by name, which is the order every other vault list in the application
+ /// uses — and the personal one is first because it is the one that is always there.
+ ///
+ private void BuildRows(VaultSession open, IReadOnlyList teams)
{
- NewTeamName = string.Empty;
- NewTeamSlug = string.Empty;
- IsCreatingTeam = true;
+ var byTeam = teams.ToDictionary(team => team.TeamId);
+ var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
+
+ // How many vaults each membership list carries, so a row can say when it is not the only one.
+ var perTeam = open.Vaults
+ .Where(vault => vault.TeamId is not null)
+ .GroupBy(vault => vault.TeamId!.Value)
+ .ToDictionary(group => group.Key, group => group.Count());
+
+ foreach (var vault in open.Vaults
+ .OrderByDescending(vault => vault.IsPersonal)
+ .ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
+ {
+ var team = vault.TeamId is { } teamId && byTeam.TryGetValue(teamId, out var found)
+ ? found
+ : null;
+
+ Vaults.Add(new VaultRowViewModel(
+ vault.VaultId,
+ vault.Name,
+ vault.IsPersonal,
+ vault.TeamId,
+ team?.Role ?? TeamMemberRole.Unspecified,
+ team?.MemberCount ?? 0,
+ vault.TeamId is { } id && perTeam.TryGetValue(id, out var count) ? count - 1 : 0,
+ readable.Contains(vault.VaultId),
+ vault.CanWrite,
+ vault.RekeyRequired));
+ }
+ }
+
+ /// Opens the name-a-vault form.
+ ///
+ /// What the tab strip's vault menu reaches as well as the button above the list. One field: the
+ /// membership list behind the vault is made with it and named after it — see
+ /// — because somebody who wants to share four servers with two
+ /// colleagues is not asking to found an organisation first.
+ ///
+ [RelayCommand]
+ private void NewVault()
+ {
+ // Opening the form is a fresh attempt, so a membership list left behind by a create that got half
+ // way is not carried into it — the name box has just been emptied, and a retry that reused it
+ // would put a vault called one thing inside a list called another. Finishing the half-done one is
+ // pressing CREATE again on the form that is still open, which is what its message says.
+ pendingVaultTeamId = null;
+
+ NewVaultName = string.Empty;
+ IsCreatingVault = true;
+ IsRenamingVault = false;
Status = string.Empty;
}
- /// Abandons the create-a-team form.
- [RelayCommand]
- private void CancelNewTeam()
- {
- IsCreatingTeam = false;
- Status = string.Empty;
- }
-
- /// Creates a team, with this account as its owner.
- ///
- /// The id is generated here, which is what makes a create whose response was lost safe to send again —
- /// the server treats an identical repeat as the same team rather than a second one.
- ///
- [RelayCommand]
- private async Task CreateTeamAsync(CancellationToken cancellationToken)
- {
- if (connection() is not { } server)
- {
- Status = "Offline. Creating a team needs a connection.";
- return;
- }
-
- var name = NewTeamName.Trim();
- var slug = NewTeamSlug.Trim().ToLowerInvariant();
-
- if (name.Length == 0 || slug.Length == 0)
- {
- Status = "A team needs a name and a slug.";
- return;
- }
-
- await RunAsync(async () =>
- {
- var created = await server.Teams
- .CreateTeamAsync(
- new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
- .ConfigureAwait(true);
-
- IsCreatingTeam = false;
-
- // Selected through the reload rather than assigned after it, so the new team's members and
- // vaults are on screen by the time this returns — see the select parameter.
- await ReloadAsync(cancellationToken, select: created.TeamId).ConfigureAwait(true);
-
- Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
- + "whoever needs it.";
- }).ConfigureAwait(true);
- }
-
///
- /// Adds a member, by looking their address up in the directory first.
+ /// Abandons the name-a-vault form.
///
///
- ///
- /// The directory is asked first, and the order is the point: it is what turns an address into an
- /// account and a public key, and the key that gets verified before any sharing is the one
- /// that lookup returned. Resolving the address server-side when the directory could answer would
- /// put an unwitnessed step between the two.
- ///
- ///
- /// A directory miss is not an absent account, and treating it as one was a bug worth naming.
- /// The directory returns only accounts that have published a key, so everybody between their first
- /// sign-in and their enrollment is missing from it. Falling straight through to an invitation told
- /// somebody who was standing right there that they had no account here, left the members list
- /// unchanged, and made them wait for a sweep that runs at most hourly. So the miss is retried as an
- /// add by address, and only a server that says there is no such account reaches the invitation.
- ///
+ /// Tidies away the membership list a half-finished create left behind, which is a deliberate
+ /// exception to this application's rule against cleaning up on the user's behalf. The reason is that
+ /// nothing on this screen can reach it: a membership list with no vault has no row here, so leaving it
+ /// would be leaving a thing the user cannot see, cannot use and cannot remove. Cancelling is the one
+ /// moment somebody says they are done with this attempt, and it is empty, so archiving it is refused
+ /// by nothing. A failure is ignored: the alternative is an error about something the user was not
+ /// told existed.
///
[RelayCommand]
- private async Task AddMemberAsync(CancellationToken cancellationToken)
- {
- if (connection() is not { } server || SelectedTeam is not { } team)
- {
- // Never silent. This command's failures used to be visible only as a flicker of the busy
- // flag, which reads as a button that does nothing at all.
- Status = connection() is null
- ? "Offline. Adding a member changes who the server will serve, so it needs a connection."
- : "Select a team on the left first — a member is added to one team, not to all of them.";
- return;
- }
-
- var email = InviteEmail.Trim();
-
- if (email.Length == 0)
- {
- Status = "Type the email address of somebody who has signed in to this server.";
- return;
- }
-
- await RunAsync(async () =>
- {
- var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
- .ConfigureAwait(true);
-
- var request = found.Count > 0
- ? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
- : new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
-
- TeamMemberSummary member;
-
- try
- {
- member = await server.Teams
- .AddTeamMemberAsync(team.TeamId, request, cancellationToken)
- .ConfigureAwait(true);
- }
- catch (DodoSshApiException exception)
- when (string.Equals(
- exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
- {
- // The address really is unknown here, which only the server can say. This is the one
- // route to an invitation, and it is now a fact rather than an inference from silence.
- await InviteAsync(server, team, email, cancellationToken).ConfigureAwait(true);
- return;
- }
-
- InviteEmail = string.Empty;
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- Status = Describe(member);
- }).ConfigureAwait(true);
- }
-
- ///
- /// What just happened to the account that was added, and what is still owed them.
- ///
- ///
- /// Both branches say out loud that nothing readable was granted, because the single most common
- /// misunderstanding this design invites is that adding somebody gave them the vault. The unenrolled
- /// branch says more, and has to: their row will sit in the list saying it holds no key, and without
- /// this somebody would read that as the addition having half-failed rather than as a colleague who
- /// has not finished setting their machine up. It is also the one case where SHARE KEY cannot be the
- /// next step, so pointing at it would be pointing at a button that will refuse.
- ///
- private static string Describe(TeamMemberSummary member)
- {
- var who = member.Email ?? member.DisplayName ?? "the account";
-
- return member.IsEnrolled
- ? $"Added {who} as a member. They cannot read anything yet — select a vault below and "
- + "share its key."
- : $"Added {who} as a member. They have no key yet, so their row says so and no vault can "
- + "be shared with them until they finish signing in on their own machine. The "
- + "membership is real in the meantime.";
- }
-
- ///
- /// Invites an address the directory does not know.
- ///
- ///
- ///
- /// Reached by falling through from rather than from a second button,
- /// because the person typing an address does not know or care which of the two applies — that is a
- /// fact about the server's account table, not about what they are trying to do. Which one happened
- /// is reported afterwards, because the difference decides what they have to do next.
- ///
- ///
- /// It is reached only after the server has said there is no such account. The directory's silence
- /// is not enough and never was: it omits everybody who has not published a key, so inviting on the
- /// strength of it told people with accounts that they had none.
- ///
- ///
- /// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
- /// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
- /// better.
- ///
- ///
- private async Task InviteAsync(
- IVaultServer server,
- TeamRowViewModel team,
- string email,
- CancellationToken cancellationToken)
- {
- var invitation = await server.Teams
- .CreateTeamInvitationAsync(
- team.TeamId,
- new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
- cancellationToken)
- .ConfigureAwait(true);
-
- InviteEmail = string.Empty;
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- Status = $"No account here has the address '{email}' yet, so it has been invited instead. "
- + $"They join this team as {invitation.Role.ToString().ToLowerInvariant()} the first time "
- + "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — "
- + "and their identity provider has to confirm the address is theirs.";
- }
-
- /// Withdraws an invitation that has not been taken up.
- [RelayCommand]
- private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
- {
- if (connection() is not { } server
- || SelectedTeam is not { } team
- || SelectedInvitation is not { } invitation)
- {
- return;
- }
-
- await RunAsync(async () =>
- {
- var revoked = await server.Teams
- .RevokeTeamInvitationAsync(team.TeamId, invitation.InvitationId, cancellationToken)
- .ConfigureAwait(true);
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- Status = revoked
- ? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them "
- + "in this team."
- : $"The invitation to {invitation.Email} was already taken up or withdrawn. If they "
- + "are a member now, remove them instead.";
- }).ConfigureAwait(true);
- }
-
- /// Picks the role a newly added or invited account will get.
- [RelayCommand]
- private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;
-
- /// Changes the selected member's role.
- ///
- /// Owner is not offered, and the command refuses it rather than relying on the view not to send
- /// it: the server refuses it too, and a button that produced a server error would be reporting a
- /// rule the interface should have known.
- ///
- [RelayCommand]
- private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken)
- {
- if (connection() is not { } server
- || SelectedTeam is not { } team
- || SelectedMember is not { } member)
- {
- return;
- }
-
- if (role is TeamMemberRole.Owner or TeamMemberRole.Unspecified)
- {
- Status = "Ownership is handed over rather than assigned. Use HAND OVER below.";
- return;
- }
-
- if (member.Member.Role == role)
- {
- return;
- }
-
- await RunAsync(async () =>
- {
- var changed = await server.Teams
- .ChangeTeamMemberRoleAsync(
- team.TeamId,
- member.UserId,
- new ChangeTeamMemberRoleRequest(role),
- cancellationToken)
- .ConfigureAwait(true);
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- SelectedMember = Members.FirstOrDefault(row => row.UserId == member.UserId);
-
- // What a role does and does not reach. A viewer still holds whatever key they were
- // wrapped, so demoting somebody is not a way of taking a vault back from them.
- Status = $"{member.Name} is now {changed.Role.ToString().ToLowerInvariant()}. This changes "
- + "what the server will serve them; it does not withdraw a vault key they already "
- + "hold — use WITHDRAW KEY for that.";
- }).ConfigureAwait(true);
- }
-
- /// Opens the rename form for the selected team.
- [RelayCommand]
- private void RenameTeam()
- {
- if (SelectedTeam is not { } team)
- {
- return;
- }
-
- EditTeamName = team.Name;
- EditTeamDescription = team.Team.Description ?? string.Empty;
- IsEditingTeam = true;
- Status = string.Empty;
- }
-
- /// Abandons the rename form.
- [RelayCommand]
- private void CancelRenameTeam()
- {
- IsEditingTeam = false;
- Status = string.Empty;
- }
-
- /// Saves the renamed team.
- [RelayCommand]
- private async Task SaveTeamAsync(CancellationToken cancellationToken)
- {
- if (connection() is not { } server || SelectedTeam is not { } team)
- {
- return;
- }
-
- var name = EditTeamName.Trim();
-
- if (name.Length == 0)
- {
- Status = "A team needs a name.";
- return;
- }
-
- var description = EditTeamDescription.Trim();
-
- await RunAsync(async () =>
- {
- await server.Teams
- .UpdateTeamAsync(
- team.TeamId,
- new UpdateTeamRequest(name, description.Length == 0 ? null : description),
- cancellationToken)
- .ConfigureAwait(true);
-
- IsEditingTeam = false;
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- // The slug is named because it did not change and somebody expecting it to would
- // otherwise find out from a URL much later.
- Status = $"Renamed to '{name}'. Its slug is still '{team.Slug}' — that is what URLs and "
- + "the server's own records use, and it does not change.";
- }).ConfigureAwait(true);
- }
-
- /// Arms the archive confirmation for the selected team.
- [RelayCommand]
- private void ArchiveTeam()
- {
- if (SelectedTeam is not { } team)
- {
- return;
- }
-
- PendingAction = new TeamActionRequest(
- team.TeamId,
- Guid.Empty,
- $"Archive '{team.Name}'?",
- "Everybody loses sight of it at once, and only somebody with database access can bring it "
- + "back. It is refused outright if the team still owns any vault.");
- }
-
- /// Arms the hand-over confirmation for the selected member.
- [RelayCommand]
- private void TransferOwnership()
- {
- if (SelectedTeam is not { } team || SelectedMember is not { } member)
- {
- return;
- }
-
- if (member.IsSelf)
- {
- Status = "You already own this team.";
- return;
- }
-
- PendingAction = new TeamActionRequest(
- team.TeamId,
- member.UserId,
- $"Hand '{team.Name}' to {member.Name}?",
- "They become the owner and you become an admin. You will not be able to take it back "
- + "yourself — only the new owner can hand it on.");
- }
-
- /// Cancels an armed action.
- [RelayCommand]
- private void CancelAction() => PendingAction = null;
-
- ///
- /// Carries out whichever action was armed.
- ///
- ///
- /// Disarmed before the work rather than after it, so the card goes the moment it is answered and a
- /// second press during a slow round trip has nothing left to agree to.
- ///
- [RelayCommand]
- private async Task ConfirmActionAsync(CancellationToken cancellationToken)
- {
- if (connection() is not { } server || PendingAction is not { } request)
- {
- return;
- }
-
- PendingAction = null;
-
- await RunAsync(async () =>
- {
- if (request.MemberId == Guid.Empty)
- {
- var archived = await server.Teams
- .ArchiveTeamAsync(request.TeamId, cancellationToken)
- .ConfigureAwait(true);
-
- SelectedTeam = null;
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- Status = archived
- ? "Archived. It is gone from everybody's list; the rows are still in the database "
- + "and only an operator can bring them back."
- : "There was no such team to archive.";
-
- return;
- }
-
- await server.Teams
- .TransferTeamOwnershipAsync(
- request.TeamId,
- new TransferTeamOwnershipRequest(request.MemberId),
- cancellationToken)
- .ConfigureAwait(true);
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- Status = "Handed over. You are an admin of this team now, and only its new owner can hand "
- + "it on again.";
- }).ConfigureAwait(true);
- }
-
- /// Removes a member, revoking every vault key grant they hold from this team.
- [RelayCommand]
- private async Task RemoveMemberAsync(CancellationToken cancellationToken)
- {
- if (connection() is not { } server
- || SelectedTeam is not { } team
- || SelectedMember is not { } member)
- {
- return;
- }
-
- await RunAsync(async () =>
- {
- await server.Teams
- .RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
- .ConfigureAwait(true);
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- // The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
- // and a message implying otherwise is the one thing this screen must not say.
- Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything "
- + "they had already downloaded is still on their machine — rotate the credentials that "
- + "matter.";
- }).ConfigureAwait(true);
- }
-
- /// Opens the name-a-vault form, aimed at the selected team.
- [RelayCommand]
- private void NewVault() => ArmNewVault(SelectedTeam?.TeamId);
-
- /// Opens the name-a-vault form, aimed at a team that does not exist yet.
- ///
- /// What the tab strip's vault menu reaches. From there a vault is the thing being made and a team is
- /// what carries it, which is the way round most people mean it: somebody who wants to share four
- /// servers with two colleagues is not asking to found an organisation first.
- ///
- [RelayCommand]
- private void NewVaultInItsOwnTeam() => ArmNewVault(null);
-
- /// Abandons the name-a-vault form.
- ///
- /// Clears the half-finished create with it. Cancelling is the one place somebody says they are done
- /// with this attempt, so a team left behind by a failed second call stops being something the next
- /// CREATE will add a vault to — it stays in the list, where they can archive it or use it.
- ///
- [RelayCommand]
- private void CancelNewVault()
+ private async Task CancelNewVaultAsync(CancellationToken cancellationToken)
{
IsCreatingVault = false;
- pendingVaultTeamId = null;
Status = string.Empty;
+
+ if (pendingVaultTeamId is not { } orphan)
+ {
+ return;
+ }
+
+ pendingVaultTeamId = null;
+
+ if (connection() is not { } server)
+ {
+ return;
+ }
+
+ try
+ {
+ await server.Teams.ArchiveTeamAsync(orphan, cancellationToken).ConfigureAwait(true);
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException
+ and not OperationCanceledException)
+ {
+ // Nothing to say. It is a membership list nobody was shown, holding nobody but its creator.
+ }
}
///
- /// Creates a vault, and the team to own it where there is not one already.
+ /// Creates a vault, and the membership list to carry it.
///
///
///
/// A vault always belongs to a team, and this is what keeps that from being the user's problem.
/// Naming a vault is enough: the team is derived from the name, created with this account as its owner,
/// and the vault goes into it. What that buys is the rest of this screen — members, roles, invitations
- /// and key holders all hang off the team, so they are all there the moment the vault is.
+ /// and key holders all hang off it, so they are all there the moment the vault is.
///
///
- /// Two calls, and the first can succeed alone. When it does, the team is kept rather than tidied
- /// away — see for how the retry avoids a second one. Archiving it here
- /// would be a client deleting something on the user's behalf because a later step failed, which is the
- /// kind of cleanup that eventually archives a team somebody has just been added to.
+ /// Two calls, and the first can succeed alone. When it does, the membership list is kept rather
+ /// than tidied away here — see for how the retry avoids making a
+ /// second one, and for where it does get tidied away.
///
///
[RelayCommand]
@@ -1021,8 +690,7 @@ internal sealed partial class TeamsViewModel(
string name,
CancellationToken cancellationToken)
{
- var teamId = newVaultTeamId
- ?? await EnsureTeamForVaultAsync(server, name, cancellationToken).ConfigureAwait(true);
+ var teamId = await EnsureTeamForVaultAsync(server, name, cancellationToken).ConfigureAwait(true);
StoredVault vault;
@@ -1032,16 +700,13 @@ internal sealed partial class TeamsViewModel(
.CreateTeamVaultAsync(server.Teams, teamId, name, cancellationToken)
.ConfigureAwait(true);
}
- catch (Exception exception) when (pendingVaultTeamId is not null
- && exception is not OperationCanceledException)
+ catch (Exception exception) when (exception is not OperationCanceledException)
{
- // The whole state, not "creating the vault failed". The team is real, it is about to appear in
- // the list on the left, and pressing CREATE again finishes the job rather than making a second
- // one — none of which the user can work out from the failure alone.
- await ReloadAsync(cancellationToken, select: pendingVaultTeamId).ConfigureAwait(true);
-
- Status = $"The team '{name}' was created, but its vault was not: {exception.Message} Press "
- + "CREATE again to add the vault to it — the team is already in the list on the left.";
+ // The whole state, not "creating the vault failed". Pressing CREATE again finishes the job
+ // rather than making a second membership list, and cancelling takes the half-made one away —
+ // none of which the user can work out from the failure alone.
+ Status = $"The vault '{name}' was not created: {exception.Message} Press CREATE again to "
+ + "finish it, or CANCEL to undo what was made.";
return;
}
@@ -1049,20 +714,17 @@ internal sealed partial class TeamsViewModel(
pendingVaultTeamId = null;
NewVaultName = string.Empty;
- await ReloadAsync(cancellationToken, select: teamId).ConfigureAwait(true);
+ await ReloadAsync(cancellationToken, select: vault.VaultId).ConfigureAwait(true);
// After the reload, so this screen is already right when the rest of the shell redraws against the
// same session. Nothing here depends on it having happened.
- if (vaultsChanged is { } notify)
- {
- await notify(cancellationToken).ConfigureAwait(true);
- }
+ await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true);
Status = $"Created the vault '{vault.Name}'. You are the only one who can open it until you share "
- + "its key — add people below, then press SHARE KEY.";
+ + "its key — add people beside it, then press SHARE KEY.";
}
- /// Makes the team a new vault will belong to, or returns the one a retry already made.
+ /// Makes the membership list a new vault will belong to, or returns the one a retry made.
///
/// The slug is derived rather than asked for. It is a URL-safe handle the server needs and not a thing
/// somebody naming a vault has an opinion about, so making them invent one would be a second field for
@@ -1076,7 +738,7 @@ internal sealed partial class TeamsViewModel(
var teamId = pendingVaultTeamId ?? Guid.CreateVersion7();
// Before the call, not after: if this throws, the id has to survive so the retry resends the same
- // request rather than creating a second team.
+ // request rather than creating a second one.
pendingVaultTeamId = teamId;
var slug = Slugify(name, teamId);
@@ -1109,8 +771,8 @@ internal sealed partial class TeamsViewModel(
///
/// Mirrors TeamService.RequireSlug: lowercase, anything outside a-z0-9 becomes a hyphen, runs of
/// hyphens collapse, and the ends are trimmed. A name with nothing sluggable in it — one written
- /// entirely in a non-Latin script, or in emoji — leaves nothing behind, so it falls back to the team's
- /// own id rather than to a refusal the user cannot see the cause of in what they typed.
+ /// entirely in a non-Latin script, or in emoji — leaves nothing behind, so it falls back to the id
+ /// rather than to a refusal the user cannot see the cause of in what they typed.
///
private static string Slugify(string name, Guid teamId)
{
@@ -1138,7 +800,7 @@ internal sealed partial class TeamsViewModel(
return trimmed.Length > 0 ? trimmed : Disambiguate("vault", teamId);
}
- /// Adds enough of the team's id to a slug to get past one somebody else has taken.
+ /// Adds enough of the id to a slug to get past one somebody else has taken.
private static string Disambiguate(string slug, Guid teamId)
{
var suffix = "-" + teamId.ToString("N", CultureInfo.InvariantCulture)[..8];
@@ -1147,21 +809,379 @@ internal sealed partial class TeamsViewModel(
return (slug.Length > room ? slug[..room].TrimEnd('-') : slug) + suffix;
}
- /// Opens the name-a-vault form, aimed wherever the caller says.
- private void ArmNewVault(Guid? teamId)
+ /// Opens the rename form for the selected vault.
+ [RelayCommand]
+ private void RenameVault()
{
- // Opening the form is a fresh attempt, so a team left behind by a create that got half way is not
- // carried into it — the name box has just been emptied, and a retry that reused the team would put
- // a vault called one thing inside a team called another. Finishing the half-done one is pressing
- // CREATE again on the form that is still open, which is what its message says.
- pendingVaultTeamId = null;
+ if (SelectedVault is not { } vault)
+ {
+ return;
+ }
- newVaultTeamId = teamId;
- NewVaultName = string.Empty;
- IsCreatingVault = true;
+ EditVaultName = vault.Name;
+ IsRenamingVault = true;
+ IsCreatingVault = false;
Status = string.Empty;
+ }
- OnPropertyChanged(nameof(NewVaultDestination));
+ /// Abandons the rename form.
+ [RelayCommand]
+ private void CancelRenameVault()
+ {
+ IsRenamingVault = false;
+ Status = string.Empty;
+ }
+
+ ///
+ /// Saves the renamed vault.
+ ///
+ ///
+ /// The whole shell is told, because a vault's name is drawn in more places than this screen: the badge
+ /// on every host card in a session holding several, the file-this-into picker, and the tab strip's
+ /// vault menu.
+ ///
+ [RelayCommand]
+ private async Task SaveVaultNameAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server || session() is not { } open || SelectedVault is not { } vault)
+ {
+ Status = connection() is null
+ ? "Offline. A vault's name is the server's, so renaming it needs a connection."
+ : string.Empty;
+ return;
+ }
+
+ var name = EditVaultName.Trim();
+
+ if (name.Length == 0)
+ {
+ Status = "A vault needs a name.";
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var renamed = await open
+ .RenameVaultAsync(server.Grants, vault.VaultId, name, cancellationToken)
+ .ConfigureAwait(true);
+
+ IsRenamingVault = false;
+
+ await ReloadAsync(cancellationToken, select: vault.VaultId).ConfigureAwait(true);
+ await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true);
+
+ // What a rename does not touch, said once rather than left to be discovered. A vault name is
+ // plaintext on the server — it always was, because a person has to pick a vault before
+ // anything is decrypted — and nothing inside it is re-encrypted by this.
+ Status = $"Renamed to '{renamed.Name}'. The name is stored in plain text, as it was before; "
+ + "nothing in the vault was re-encrypted, and everybody's key still opens it.";
+ }).ConfigureAwait(true);
+ }
+
+ ///
+ /// Adds somebody to the selected vault, by looking their address up in the directory first.
+ ///
+ ///
+ ///
+ /// The directory is asked first, and the order is the point: it is what turns an address into an
+ /// account and a public key, and the key that gets verified before any sharing is the one
+ /// that lookup returned. Resolving the address server-side when the directory could answer would
+ /// put an unwitnessed step between the two.
+ ///
+ ///
+ /// A directory miss is not an absent account, and treating it as one was a bug worth naming.
+ /// The directory returns only accounts that have published a key, so everybody between their first
+ /// sign-in and their enrollment is missing from it. Falling straight through to an invitation told
+ /// somebody who was standing right there that they had no account here, left the members list
+ /// unchanged, and made them wait for a sweep that runs at most hourly. So the miss is retried as an
+ /// add by address, and only a server that says there is no such account reaches the invitation.
+ ///
+ ///
+ [RelayCommand]
+ private async Task AddMemberAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server || SelectedVault?.TeamId is not { } teamId)
+ {
+ // Never silent. This command's failures used to be visible only as a flicker of the busy
+ // flag, which reads as a button that does nothing at all.
+ Status = connection() is null
+ ? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
+ : SelectedIsPersonal
+ ? "Your personal vault is yours alone and cannot be shared. Make a vault for the "
+ + "things you want to share, and put them in it."
+ : "Select a vault on the left first — somebody is added to one vault, not to all.";
+ return;
+ }
+
+ var email = InviteEmail.Trim();
+
+ if (email.Length == 0)
+ {
+ Status = "Type the email address of somebody who has signed in to this server.";
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
+ .ConfigureAwait(true);
+
+ var request = found.Count > 0
+ ? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
+ : new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
+
+ TeamMemberSummary member;
+
+ try
+ {
+ member = await server.Teams
+ .AddTeamMemberAsync(teamId, request, cancellationToken)
+ .ConfigureAwait(true);
+ }
+ catch (DodoSshApiException exception)
+ when (string.Equals(
+ exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
+ {
+ // The address really is unknown here, which only the server can say. This is the one
+ // route to an invitation, and it is now a fact rather than an inference from silence.
+ await InviteAsync(server, teamId, email, cancellationToken).ConfigureAwait(true);
+ return;
+ }
+
+ InviteEmail = string.Empty;
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = Describe(member);
+ }).ConfigureAwait(true);
+ }
+
+ ///
+ /// What just happened to the account that was added, and what is still owed them.
+ ///
+ ///
+ /// Both branches say out loud that nothing readable was granted, because the single most common
+ /// misunderstanding this design invites is that adding somebody gave them the vault. The unenrolled
+ /// branch says more, and has to: their row will sit in the list saying it holds no key, and without
+ /// this somebody would read that as the addition having half-failed rather than as a colleague who
+ /// has not finished setting their machine up. It is also the one case where SHARE KEY cannot be the
+ /// next step, so pointing at it would be pointing at a button that will refuse.
+ ///
+ private static string Describe(TeamMemberSummary member)
+ {
+ var who = member.Email ?? member.DisplayName ?? "the account";
+
+ return member.IsEnrolled
+ ? $"Added {who}. They cannot read anything yet — press SHARE KEY to wrap this vault's key "
+ + "to them."
+ : $"Added {who}. They have no key yet, so their row says so and this vault cannot be shared "
+ + "with them until they finish signing in on their own machine. The membership is real "
+ + "in the meantime.";
+ }
+
+ ///
+ /// Invites an address the directory does not know.
+ ///
+ ///
+ ///
+ /// Reached by falling through from rather than from a second button,
+ /// because the person typing an address does not know or care which of the two applies — that is a
+ /// fact about the server's account table, not about what they are trying to do. Which one happened
+ /// is reported afterwards, because the difference decides what they have to do next.
+ ///
+ ///
+ /// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
+ /// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
+ /// better.
+ ///
+ ///
+ private async Task InviteAsync(
+ IVaultServer server,
+ Guid teamId,
+ string email,
+ CancellationToken cancellationToken)
+ {
+ var invitation = await server.Teams
+ .CreateTeamInvitationAsync(
+ teamId,
+ new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
+ cancellationToken)
+ .ConfigureAwait(true);
+
+ InviteEmail = string.Empty;
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = $"No account here has the address '{email}' yet, so it has been invited instead. "
+ + $"They join this vault as {invitation.Role.ToString().ToLowerInvariant()} the first time "
+ + "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — "
+ + "and their identity provider has to confirm the address is theirs.";
+ }
+
+ /// Withdraws an invitation that has not been taken up.
+ [RelayCommand]
+ private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server
+ || SelectedVault?.TeamId is not { } teamId
+ || SelectedInvitation is not { } invitation)
+ {
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var revoked = await server.Teams
+ .RevokeTeamInvitationAsync(teamId, invitation.InvitationId, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = revoked
+ ? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them "
+ + "in this vault."
+ : $"The invitation to {invitation.Email} was already taken up or withdrawn. If they "
+ + "are a member now, remove them instead.";
+ }).ConfigureAwait(true);
+ }
+
+ /// Picks the role a newly added or invited account will get.
+ [RelayCommand]
+ private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;
+
+ /// Changes the selected member's role.
+ ///
+ /// Owner is not offered, and the command refuses it rather than relying on the view not to send
+ /// it: the server refuses it too, and a button that produced a server error would be reporting a
+ /// rule the interface should have known.
+ ///
+ [RelayCommand]
+ private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server
+ || SelectedVault?.TeamId is not { } teamId
+ || SelectedMember is not { } member)
+ {
+ return;
+ }
+
+ if (role is TeamMemberRole.Owner or TeamMemberRole.Unspecified)
+ {
+ Status = "Ownership is handed over rather than assigned. Use HAND OVER below.";
+ return;
+ }
+
+ if (member.Member.Role == role)
+ {
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var changed = await server.Teams
+ .ChangeTeamMemberRoleAsync(
+ teamId,
+ member.UserId,
+ new ChangeTeamMemberRoleRequest(role),
+ cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ SelectedMember = Members.FirstOrDefault(row => row.UserId == member.UserId);
+
+ // What a role does and does not reach. A viewer still holds whatever key they were
+ // wrapped, so demoting somebody is not a way of taking a vault back from them.
+ Status = $"{member.Name} is now {changed.Role.ToString().ToLowerInvariant()}. This changes "
+ + "what the server will serve them; it does not withdraw a vault key they already "
+ + "hold — use WITHDRAW KEY for that.";
+ }).ConfigureAwait(true);
+ }
+
+ /// Arms the hand-over confirmation for the selected member.
+ [RelayCommand]
+ private void HandOver()
+ {
+ if (SelectedVault is not { TeamId: { } teamId } vault || SelectedMember is not { } member)
+ {
+ return;
+ }
+
+ if (member.IsSelf)
+ {
+ Status = "You already own this vault.";
+ return;
+ }
+
+ PendingAction = new VaultActionRequest(
+ teamId,
+ member.UserId,
+ $"Hand '{vault.Name}' to {member.Name}?",
+ "They become its owner and you become an admin. You will not be able to take it back "
+ + "yourself — only the new owner can hand it on. Your key to it is untouched.");
+ }
+
+ /// Cancels an armed action.
+ [RelayCommand]
+ private void CancelAction() => PendingAction = null;
+
+ ///
+ /// Carries out whichever action was armed.
+ ///
+ ///
+ /// Disarmed before the work rather than after it, so the card goes the moment it is answered and a
+ /// second press during a slow round trip has nothing left to agree to.
+ ///
+ [RelayCommand]
+ private async Task ConfirmActionAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server || PendingAction is not { } request)
+ {
+ return;
+ }
+
+ PendingAction = null;
+
+ await RunAsync(async () =>
+ {
+ await server.Teams
+ .TransferTeamOwnershipAsync(
+ request.TeamId,
+ new TransferTeamOwnershipRequest(request.MemberId),
+ cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = "Handed over. You are an admin of this vault now, and only its new owner can hand "
+ + "it on again.";
+ }).ConfigureAwait(true);
+ }
+
+ /// Removes somebody, revoking every key grant they hold from this vault.
+ [RelayCommand]
+ private async Task RemoveMemberAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server
+ || SelectedVault?.TeamId is not { } teamId
+ || SelectedMember is not { } member)
+ {
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ await server.Teams
+ .RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ // The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
+ // and a message implying otherwise is the one thing this screen must not say.
+ Status = $"Removed {member.Name}. They can no longer fetch this vault, and anything they had "
+ + "already downloaded is still on their machine — rotate the credentials that matter.";
+ }).ConfigureAwait(true);
}
///
@@ -1199,6 +1219,8 @@ internal sealed partial class TeamsViewModel(
Status = outcome.Shared
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
: $"Did not share '{vault.Name}': {outcome.Message}";
+
+ await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
@@ -1228,14 +1250,14 @@ internal sealed partial class TeamsViewModel(
}).ConfigureAwait(true);
}
- partial void OnSelectedTeamChanged(TeamRowViewModel? value)
+ partial void OnSelectedVaultChanged(VaultRowViewModel? value)
{
RaiseState();
- // An armed confirmation names the team it was armed for, so a selection change has to disarm
- // it — otherwise the card stays on screen above a different team and reads as being about it.
+ // An armed confirmation names the vault it was armed for, so a selection change has to disarm
+ // it — otherwise the card stays on screen above a different vault and reads as being about it.
PendingAction = null;
- IsEditingTeam = false;
+ IsRenamingVault = false;
if (isReselecting)
{
@@ -1243,15 +1265,17 @@ internal sealed partial class TeamsViewModel(
}
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
- // from a list box, which has no cancellation token and no way to await. Failures land in Status
- // through RunAsync exactly as a command's would.
- _ = LoadSelectedAsync(CancellationToken.None);
+ // from a list box, which has no cancellation token and no way to await. Wrapped here rather than
+ // inside the read, because the read is also awaited from inside a reload that already holds the
+ // busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
+ // members list entirely.
+ _ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
}
- partial void OnPendingActionChanged(TeamActionRequest? value)
+ partial void OnPendingActionChanged(VaultActionRequest? value)
{
OnPropertyChanged(nameof(IsConfirming));
- OnPropertyChanged(nameof(ShowsTeamActions));
+ OnPropertyChanged(nameof(ShowsVaultActions));
}
partial void OnNewMemberRoleChanged(TeamMemberRole value)
@@ -1261,59 +1285,70 @@ internal sealed partial class TeamsViewModel(
OnPropertyChanged(nameof(AddsAsAdmin));
}
- ///
- /// The grants list belongs to a vault rather than to a team, so it is reloaded on selection here
- /// rather than in — which would leave it showing the previous
- /// vault's key-holders after a click.
- ///
- partial void OnSelectedVaultChanged(TeamVaultRowViewModel? value) =>
- _ = LoadGrantsAsync(CancellationToken.None);
-
/// Reads who holds a key to the selected vault.
+ ///
+ /// Every vault, including the personal one: its own self-grant is what makes it readable here, and a
+ /// list that hid it would leave the one vault everybody has looking as though nobody could open it.
+ ///
private async Task LoadGrantsAsync(CancellationToken cancellationToken)
{
- Grants.Clear();
-
if (connection() is not { } server || SelectedVault is not { } vault)
{
return;
}
- await RunAsync(async () =>
+ // Read rather than incremented: this is a read *within* a selection rather than a new one, and it
+ // is also called on its own after a share. Compared again below for the reason
+ // exists — two vaults' key holders in one list is the failure,
+ // and it is the one an in-order answer cannot be relied on to prevent.
+ var generation = selectionGeneration;
+
+ var response = await server.Grants
+ .ListVaultGrantsAsync(vault.VaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ if (generation != selectionGeneration)
{
- var response = await server.Grants
- .ListVaultGrantsAsync(vault.VaultId, cancellationToken)
- .ConfigureAwait(true);
+ return;
+ }
- Grants.Clear();
+ Grants.Clear();
- foreach (var grant in response.Grants)
- {
- Grants.Add(new TeamGrantRowViewModel(grant, response.KeyGeneration));
- }
- }).ConfigureAwait(true);
+ foreach (var grant in response.Grants)
+ {
+ Grants.Add(new VaultGrantRowViewModel(grant, response.KeyGeneration));
+ }
}
- /// Reads the selected team's members, invitations and vaults.
+ /// Reads the selected vault's members, invitations and key holders.
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
{
var generation = ++selectionGeneration;
Members.Clear();
Invitations.Clear();
- Vaults.Clear();
Grants.Clear();
- if (connection() is not { } server || SelectedTeam is not { } team)
+ OnPropertyChanged(nameof(HasInvitations));
+
+ if (connection() is not { } server || SelectedVault is not { } vault)
{
return;
}
- var open = session();
- var selfId = open?.Profile.UserId;
+ await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);
+
+ if (vault.TeamId is not { } teamId)
+ {
+ // The personal vault. There is no membership list behind it, and there is nothing to
+ // read — which is a fact about this vault rather than about the connection.
+ return;
+ }
+
+ var selfId = session()?.Profile.UserId;
var members = await server.Teams
- .ListTeamMembersAsync(team.TeamId, cancellationToken)
+ .ListTeamMembersAsync(teamId, cancellationToken)
.ConfigureAwait(true);
if (generation != selectionGeneration)
@@ -1323,11 +1358,11 @@ internal sealed partial class TeamsViewModel(
foreach (var member in members)
{
- Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
+ Members.Add(new VaultMemberRowViewModel(member, member.UserId == selfId));
}
var invitations = await server.Teams
- .ListTeamInvitationsAsync(team.TeamId, cancellationToken)
+ .ListTeamInvitationsAsync(teamId, cancellationToken)
.ConfigureAwait(true);
if (generation != selectionGeneration)
@@ -1337,51 +1372,31 @@ internal sealed partial class TeamsViewModel(
foreach (var invitation in invitations)
{
- Invitations.Add(new TeamInvitationRowViewModel(invitation));
+ Invitations.Add(new VaultInvitationRowViewModel(invitation));
}
SelectedInvitation = Invitations.FirstOrDefault(row => row.IsPending);
OnPropertyChanged(nameof(HasInvitations));
-
- if (open is not null)
- {
- ListVaults(open, team.TeamId);
- }
}
- /// Fills the vault list for a team, from what this machine can see.
- ///
- /// Read from the session rather than from a team-vaults endpoint, because the interesting fact
- /// about a team vault here is whether this machine can open it — which is a property of the
- /// keyring and not something the server can answer. No await, so it needs no generation guard: it
- /// runs to completion inside the read that called it.
- ///
- private void ListVaults(VaultSession open, Guid teamId)
- {
- var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
-
- foreach (var vault in open.Vaults.Where(vault => vault.TeamId == teamId))
- {
- Vaults.Add(new TeamVaultRowViewModel(
- vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
- }
-
- SelectedVault = Vaults.FirstOrDefault();
- }
+ /// Tells the shell that the set of vaults, or one of their names, has moved.
+ private Task NotifyVaultsChangedAsync(CancellationToken cancellationToken) =>
+ vaultsChanged is { } notify ? notify(cancellationToken) : Task.CompletedTask;
private void RaiseState()
{
- OnPropertyChanged(nameof(HasTeams));
+ OnPropertyChanged(nameof(HasVaults));
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanAdministerSelected));
OnPropertyChanged(nameof(OwnsSelected));
+ OnPropertyChanged(nameof(SelectedIsShared));
+ OnPropertyChanged(nameof(SelectedIsPersonal));
OnPropertyChanged(nameof(HasInvitations));
+ OnPropertyChanged(nameof(SharedMembershipWarning));
+ OnPropertyChanged(nameof(HasSharedMembershipWarning));
OnPropertyChanged(nameof(IsOnline));
-
- // The hint under the name box reads a team out of the list this method is called after refilling,
- // so it is stale until something says otherwise — and it has no backing field to notify for it.
- OnPropertyChanged(nameof(NewVaultDestination));
+ OnPropertyChanged(nameof(IsUnlocked));
}
///
diff --git a/src/DodoSSH.Contracts/DodoSshJsonContext.cs b/src/DodoSSH.Contracts/DodoSshJsonContext.cs
index a59e56c..58d10e6 100644
--- a/src/DodoSSH.Contracts/DodoSshJsonContext.cs
+++ b/src/DodoSSH.Contracts/DodoSshJsonContext.cs
@@ -53,6 +53,7 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(TeamInvitationSummary))]
[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
+[JsonSerializable(typeof(UpdateVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
[JsonSerializable(typeof(KeyLogPage))]
diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
index 35ad9f7..51695a7 100644
--- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
+++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
@@ -666,6 +666,13 @@ DodoSSH.Contracts.UpdateTeamRequest.Equals(DodoSSH.Contracts.UpdateTeamRequest?
DodoSSH.Contracts.UpdateTeamRequest.Name.get -> string!
DodoSSH.Contracts.UpdateTeamRequest.Name.init -> void
DodoSSH.Contracts.UpdateTeamRequest.UpdateTeamRequest(string! Name, string? Description) -> void
+DodoSSH.Contracts.UpdateVaultRequest
+DodoSSH.Contracts.UpdateVaultRequest.$() -> DodoSSH.Contracts.UpdateVaultRequest!
+DodoSSH.Contracts.UpdateVaultRequest.Deconstruct(out string! Name) -> void
+DodoSSH.Contracts.UpdateVaultRequest.Equals(DodoSSH.Contracts.UpdateVaultRequest? other) -> bool
+DodoSSH.Contracts.UpdateVaultRequest.Name.get -> string!
+DodoSSH.Contracts.UpdateVaultRequest.Name.init -> void
+DodoSSH.Contracts.UpdateVaultRequest.UpdateVaultRequest(string! Name) -> void
DodoSSH.Contracts.VaultGrantsResponse
DodoSSH.Contracts.VaultGrantsResponse.$() -> DodoSSH.Contracts.VaultGrantsResponse!
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList! Grants) -> void
@@ -840,6 +847,9 @@ override DodoSSH.Contracts.TransferTeamOwnershipRequest.ToString() -> string!
override DodoSSH.Contracts.UpdateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.UpdateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.UpdateTeamRequest.ToString() -> string!
+override DodoSSH.Contracts.UpdateVaultRequest.Equals(object? obj) -> bool
+override DodoSSH.Contracts.UpdateVaultRequest.GetHashCode() -> int
+override DodoSSH.Contracts.UpdateVaultRequest.ToString() -> string!
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
@@ -928,6 +938,8 @@ static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator !=(DodoSSH.Contra
static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator ==(DodoSSH.Contracts.TransferTeamOwnershipRequest? left, DodoSSH.Contracts.TransferTeamOwnershipRequest? right) -> bool
static DodoSSH.Contracts.UpdateTeamRequest.operator !=(DodoSSH.Contracts.UpdateTeamRequest? left, DodoSSH.Contracts.UpdateTeamRequest? right) -> bool
static DodoSSH.Contracts.UpdateTeamRequest.operator ==(DodoSSH.Contracts.UpdateTeamRequest? left, DodoSSH.Contracts.UpdateTeamRequest? right) -> bool
+static DodoSSH.Contracts.UpdateVaultRequest.operator !=(DodoSSH.Contracts.UpdateVaultRequest? left, DodoSSH.Contracts.UpdateVaultRequest? right) -> bool
+static DodoSSH.Contracts.UpdateVaultRequest.operator ==(DodoSSH.Contracts.UpdateVaultRequest? left, DodoSSH.Contracts.UpdateVaultRequest? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
diff --git a/src/DodoSSH.Contracts/Teams.cs b/src/DodoSSH.Contracts/Teams.cs
index 70d0717..998f047 100644
--- a/src/DodoSSH.Contracts/Teams.cs
+++ b/src/DodoSSH.Contracts/Teams.cs
@@ -369,6 +369,27 @@ public sealed record CreateTeamVaultRequest(
byte[] GrantSignature,
DateTimeOffset GrantedAt);
+/// Renames a vault.
+///
+///
+/// The one field of a vault a person chose, and the only one that can be changed. A vault's key
+/// generation, its owner and its rekey flag are all consequences of something else happening; its name
+/// is what somebody typed into a box, and typing the wrong thing into a box is the ordinary mistake this
+/// exists to undo.
+///
+///
+/// It is plaintext, as vault names have always been — a person has to be able to choose a vault before
+/// anything is decrypted (docs/crypto.md §10). So a rename is visible to the operator, exactly as
+/// the original name was, and this changes nothing about what the server can read.
+///
+///
+/// A whole replacement rather than a patch, for the reason is one: there
+/// is a single field, so a repeat is the same vault rather than a second edit.
+///
+///
+/// Display name. Required, 1 to 256 characters.
+public sealed record UpdateVaultRequest(string Name);
+
/// Issues a vault key grant to another member.
///
///
diff --git a/tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs b/tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs
index 7b57ee7..123e63a 100644
--- a/tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs
+++ b/tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs
@@ -99,6 +99,11 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
// sharing graph — and the two writes are gated on Share inside the handler, which this table
// cannot see. See VaultGrantEndpoints.
+ // Authenticated, alone among the vault routes: renaming touches no key material, so refusing
+ // somebody who has not published an identity key would be refusing them for an unrelated
+ // reason. Gated on Admin inside the handler, which this table cannot see.
+ "PUT /api/v1/vaults/{vaultId:guid} name=RenameVault tags=Vaults policies=Authenticated anon=False",
+
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
diff --git a/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs b/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
index 115a4cb..f9693b2 100644
--- a/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
+++ b/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
@@ -252,6 +252,134 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.Role.ShouldBe(TeamMemberRole.Owner);
}
+ // ---- Renaming a vault ----
+
+ ///
+ ///
+ /// The rename the vaults screen offers, and the assertion that matters is the second one: the team is
+ /// renamed with the vault when it owns nothing else. A vault made from that screen gets a team of its
+ /// own that nobody was ever shown, so a rename that moved only the vault would leave the operator, the
+ /// logs and the database naming it something no user recognises.
+ ///
+ ///
+ /// The slug is asserted unchanged in the same breath. It is unique only among live teams, so a rename
+ /// that moved it could take one an archived team is still holding — the same limit
+ /// UpdateTeamRequest records.
+ ///
+ ///
+ [Fact]
+ public async Task RenamingAVault_RenamesTheTeamBehindItAndLeavesItsSlugAlone()
+ {
+ var owner = await EnrolledClientAsync("vault-rename-owner");
+
+ var team = await CreateTeamAsync(owner, "Platform secrets");
+ var vaultId = await CreateVaultAsync(owner, team.TeamId);
+
+ var response = await owner.PutContractAsync(
+ VaultUrl(vaultId), new UpdateVaultRequest("Platform"));
+
+ response.EnsureSuccessStatusCode();
+
+ var renamed = (await response.Content.ReadContractAsync())!;
+
+ renamed.Name.ShouldBe("Platform");
+
+ var me = await ReadAsync(owner, MeUrl);
+
+ me.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
+
+ var listed = await ReadAsync>(owner, TeamsUrl);
+ var after = listed.Single(row => row.TeamId == team.TeamId);
+
+ after.Name.ShouldBe("Platform");
+ after.Slug.ShouldBe(team.Slug);
+ }
+
+ ///
+ /// A team carrying several vaults has a name of its own that somebody chose, so renaming one of its
+ /// vaults must not take it. This is the arrangement the vaults screen cannot make and does not hide;
+ /// the server draws the same line.
+ ///
+ [Fact]
+ public async Task RenamingOneOfSeveralVaults_LeavesTheTeamsOwnNameAlone()
+ {
+ var owner = await EnrolledClientAsync("vault-rename-shared-owner");
+
+ var team = await CreateTeamAsync(owner, "Platform Engineering");
+ var vaultId = await CreateVaultAsync(owner, team.TeamId);
+
+ await CreateVaultAsync(owner, team.TeamId);
+
+ var response = await owner.PutContractAsync(
+ VaultUrl(vaultId), new UpdateVaultRequest("Production"));
+
+ response.EnsureSuccessStatusCode();
+
+ var listed = await ReadAsync>(owner, TeamsUrl);
+
+ listed.Single(row => row.TeamId == team.TeamId).Name.ShouldBe("Platform Engineering");
+ }
+
+ ///
+ /// Admin rather than Write, and the line is the one the team rename draws: a name is what everybody in
+ /// the vault sees it called, so a member who may add hosts to it may not rename it out from under them.
+ /// A member is refused with 403 rather than 404 because the vault is visible to them, so naming the
+ /// reason leaks nothing.
+ ///
+ [Fact]
+ public async Task APlainMember_CannotRenameAVaultTheyCanWriteTo()
+ {
+ var owner = await EnrolledClientAsync("vault-rename-limits-owner", "vrowner@example.com");
+ var member = await EnrolledClientAsync("vault-rename-limits-member", "vrmember@example.com");
+
+ var team = await CreateTeamAsync(owner, "Limits");
+ var vaultId = await CreateVaultAsync(owner, team.TeamId);
+ var entry = await LookupAsync(owner, "vrmember@example.com");
+
+ await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
+
+ var response = await member.PutContractAsync(
+ VaultUrl(vaultId), new UpdateVaultRequest("Theirs now"));
+
+ await ShouldBeProblemAsync(response, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
+ }
+
+ ///
+ /// An outsider gets 404 rather than 403, which is the rule IVaultAccessService states: a
+ /// distinct "exists but forbidden" answer is an existence oracle for other tenants' vault ids.
+ ///
+ [Fact]
+ public async Task RenamingSomebodyElsesVault_IsNotFound()
+ {
+ var owner = await EnrolledClientAsync("vault-rename-outsider-owner");
+ var outsider = await EnrolledClientAsync("vault-rename-outsider");
+
+ var team = await CreateTeamAsync(owner, "Private");
+ var vaultId = await CreateVaultAsync(owner, team.TeamId);
+
+ var response = await outsider.PutContractAsync(
+ VaultUrl(vaultId), new UpdateVaultRequest("Mine now"));
+
+ response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ }
+
+ ///
+ /// An empty name is refused rather than stored, because a vault has to be pickable by name before
+ /// anything in it is decrypted — one called nothing is one nobody can choose.
+ ///
+ [Fact]
+ public async Task RenamingAVaultToNothing_IsRefused()
+ {
+ var owner = await EnrolledClientAsync("vault-rename-empty-owner");
+
+ var team = await CreateTeamAsync(owner, "Named");
+ var vaultId = await CreateVaultAsync(owner, team.TeamId);
+
+ var response = await owner.PutContractAsync(VaultUrl(vaultId), new UpdateVaultRequest(" "));
+
+ await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
+ }
+
// ---- Archiving ----
///
@@ -1092,6 +1220,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
+ private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}";
+
/// An address no account holds, uniquified because the container is shared.
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 820971e..7dc6708 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -547,7 +547,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// ---- The vault screen ----
[Fact]
- public async Task TheVaultScreenFitsInEveryCategory()
+ public async Task TheKeychainScreenFitsInEveryCategory()
{
foreach (var section in new[]
{
@@ -565,7 +565,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// wide — the narrowest column any form in this application has to fit into.
///
[Fact]
- public async Task TheVaultScreenFitsWithTheKeyEditorOpen()
+ public async Task TheKeychainScreenFitsWithTheKeyEditorOpen()
{
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
@@ -579,7 +579,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
[Fact]
- public async Task TheVaultScreenFitsWithThePasswordEditorOpen()
+ public async Task TheKeychainScreenFitsWithThePasswordEditorOpen()
{
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
@@ -595,7 +595,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// nobody was told about.
///
[Fact]
- public async Task TheVaultScreenFitsWithTheGenerateFormOpen()
+ public async Task TheKeychainScreenFitsWithTheGenerateFormOpen()
{
vault.NewGeneratedKeyCommand.Execute(null);
vault.IsGeneratingKey.ShouldBeTrue();
@@ -820,7 +820,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// application.
///
[Fact]
- public async Task TheVaultScreenFitsWithADeletionInQuestion()
+ public async Task TheKeychainScreenFitsWithADeletionInQuestion()
{
var keyId = vault.Keys[0].EntityId;
@@ -1607,13 +1607,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
bytesPerSecond,
failure)));
- /// Lays the vault screen out at the width it gets once the nav rail has taken its column.
+ /// Lays the vaults screen out at the width it gets once the nav rail has taken its column.
///
///
- /// The teams screen had no entry in this suite at all until it grew four sections — a rename form, an
- /// armed confirmation, an invitations list and a key-holders list — plus a second line in the member
- /// row. Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav
- /// rail takes 190 and the team list 268, leaving 558 for everything above.
+ /// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
+ /// takes 190 and the vault list 268, leaving 558 for everything above.
///
///
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
@@ -1623,49 +1621,32 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
///
///
[Fact]
- public Task TheTeamsScreen_FitsWithEveryListPopulated() =>
- OnTheTeamsScreenAsync(
- teams => { },
+ public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
+ OnTheVaultsScreenAsync(
+ vaults => { },
window => LayoutHarness.Unreachable(window)
- .ShouldBeEmpty("the teams screen with members, invitations and key holders"));
+ .ShouldBeEmpty("the vaults screen with members, invitations and key holders"));
///
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
///
[Fact]
- public Task TheTeamsScreen_FitsWhileRenamingATeam() =>
- OnTheTeamsScreenAsync(
- teams => teams.RenameTeamCommand.Execute(null),
+ public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
+ OnTheVaultsScreenAsync(
+ vaults => vaults.RenameVaultCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
- .ShouldBeEmpty("the teams screen with the rename form open"));
+ .ShouldBeEmpty("the vaults screen with the rename form open"));
///
- /// The name-a-vault form is in the left column under the team list, and it is the taller of the two
- /// forms that can appear there — one field, but two sentences under it. Worth its own case because the
- /// column is 268 wide and both sentences wrap.
+ /// The name-a-vault form is in the left column under the vault list. Worth its own case because the
+ /// column is 268 wide and the sentence under the field wraps.
///
[Fact]
- public Task TheTeamsScreen_FitsWithTheNewVaultFormOpen() =>
- OnTheTeamsScreenAsync(
- teams => teams.NewVaultInItsOwnTeamCommand.Execute(null),
+ public Task TheVaultsScreen_FitsWithTheNewVaultFormOpen() =>
+ OnTheVaultsScreenAsync(
+ vaults => vaults.NewVaultCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
- .ShouldBeEmpty("the teams screen with the new-vault form open"));
-
- ///
- /// Both forms at once, which is reachable: NEW at the top of the team list and New vault… in the tab
- /// strip's menu arm different forms and neither closes the other. Together they are the most the left
- /// column can be asked to hold.
- ///
- [Fact]
- public Task TheTeamsScreen_FitsWithBothCreateFormsOpen() =>
- OnTheTeamsScreenAsync(
- teams =>
- {
- teams.NewTeamCommand.Execute(null);
- teams.NewVaultInItsOwnTeamCommand.Execute(null);
- },
- window => LayoutHarness.Unreachable(window)
- .ShouldBeEmpty("the teams screen with both create forms open"));
+ .ShouldBeEmpty("the vaults screen with the new-vault form open"));
///
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
@@ -1673,35 +1654,59 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// key-holders list off the bottom.
///
[Fact]
- public Task TheTeamsScreen_FitsWhileConfirmingAnArchive() =>
- OnTheTeamsScreenAsync(
- teams => teams.ArchiveTeamCommand.Execute(null),
+ public Task TheVaultsScreen_FitsWhileConfirmingAHandOver() =>
+ OnTheVaultsScreenAsync(
+ vaults =>
+ {
+ vaults.SelectedMember = vaults.Members.First(member => !member.IsSelf);
+ vaults.HandOverCommand.Execute(null);
+ },
window => LayoutHarness.Unreachable(window)
- .ShouldBeEmpty("the teams screen with the archive confirmation armed"));
+ .ShouldBeEmpty("the vaults screen with the hand-over confirmation armed"));
///
- /// A real TeamsViewModel over a stub server rather than the unlocked vault the rest of this
- /// suite uses, because nothing on this screen is vault content: it is read from the server on open.
- /// The session function answers null, which is the state a member is in before anybody has wrapped
- /// them a key — and it is also the one that draws the most text, since every vault row then carries
- /// the "waiting for a key" sentence.
+ ///
+ /// A real VaultsViewModel over this suite's own unlocked session and a stub server. Both halves
+ /// are needed and they answer different questions: the vault list is the session's, and who is in each
+ /// vault is the server's.
+ ///
+ ///
+ /// A shared vault is created into the session first, because a session that has only ever been unlocked
+ /// offline holds one personal vault — and the personal vault draws none of what this screen is for. It
+ /// is created through the real CreateTeamVaultAsync rather than poked into the cache, so the row
+ /// being measured is one the application could actually produce.
+ ///
+ ///
+ /// Selected before the second load rather than after it, so the members read is the awaited one: a
+ /// selection assignment starts a read nothing can wait for, and measuring a window while it was still
+ /// in flight would certify a screen with empty lists.
+ ///
///
- private static async Task OnTheTeamsScreenAsync(
- Action arrange,
+ private async Task OnTheVaultsScreenAsync(
+ Action arrange,
Action assert)
{
using var teamServer = new StubTeamServer();
- var teams = new TeamsViewModel(() => teamServer, () => null);
+ await session.CreateTeamVaultAsync(
+ teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
- await teams.LoadAsync(Token);
+ var vaults = new VaultsViewModel(() => teamServer, () => session);
+
+ await vaults.LoadAsync(Token);
+
+ vaults.SelectedVault = vaults.Vaults.First(row => row.IsShared);
+
+ await vaults.LoadAsync(Token);
+
+ vaults.Members.ShouldNotBeEmpty("there is nothing to measure otherwise");
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
- arrange(teams);
+ arrange(vaults);
- var screen = new TeamsScreen { DataContext = teams };
+ var screen = new VaultsScreen { DataContext = vaults };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
@@ -1721,11 +1726,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private Task MeasureVaultAsync(Action> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
- private Task OnTheVaultAsync(Action body) =>
+ private Task OnTheVaultAsync(Action body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
- var screen = new VaultScreen { DataContext = vault };
+ var screen = new KeychainScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/StubTeamServer.cs b/tests/DodoSSH.Client.App.Layout.Tests/StubTeamServer.cs
index b0c1a29..5f60a78 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/StubTeamServer.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/StubTeamServer.cs
@@ -7,15 +7,20 @@ using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Layout.Tests;
///
-/// The least server a TeamsViewModel needs in order to be laid out with something in it.
+/// The least server a VaultsViewModel needs in order to be laid out with something in it.
///
///
///
-/// The teams screen is the one screen in this suite whose content cannot come from an unlocked vault,
-/// because none of it is vault content: a team, its members, its invitations and who holds a key to a
-/// vault are all read from the server on open, and the suite's FakeAccountServer implements
-/// and nothing else. Rather than teach that fake five more interfaces for one
-/// screen, this serves fixed rows and refuses everything a layout test has no business calling.
+/// The vaults screen draws its list from the session and everything under it from the server: who is in a
+/// vault, who has been invited, and who holds a key are all read on open, and the suite's
+/// FakeAccountServer implements and nothing else. Rather than teach that
+/// fake five more interfaces for one screen, this serves fixed rows and refuses everything a layout test
+/// has no business calling.
+///
+///
+/// It does answer , unlike the other writes, because that is how the
+/// suite gets a shared vault into the session at all — an offline layout test has no other way to reach
+/// the state this screen exists to draw.
///
///
/// The rows are deliberately the long ones. A layout suite that measured "Bob" in a column sized
@@ -61,6 +66,9 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
/// The vault whose key holders are listed, so a test can select it.
internal static Guid TeamVaultId => VaultId;
+ /// The membership list behind that vault, so a test can create it in the session.
+ internal static Guid SharedTeamId => TeamId;
+
///
public Task> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult>(
@@ -209,10 +217,36 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
Guid invitationId,
CancellationToken cancellationToken) => throw new NotSupportedException();
- ///
+ ///
+ /// Accepts the vault, so a layout test can put a shared one into the session it is drawing.
+ ///
+ ///
+ /// The client's own id and wrapped key are echoed back, exactly as the real endpoint answers: the key
+ /// was generated on this machine and the session adopts its own copy, so anything else here would be
+ /// either discarded or a vault nobody could open.
+ ///
+ /// It comes back owing a rekey, which is not decoration: that is the longer of the two lines a vault row
+ /// can carry, and this suite exists to measure the long one.
+ ///
+ ///
public Task CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new VaultSummary(
+ request.VaultId,
+ request.Name,
+ IsPersonal: false,
+ TeamId: teamId,
+ KeyGeneration: 1,
+ Permissions: 31,
+ request.WrappedVaultKey,
+ RekeyRequired: true));
+
+ ///
+ public Task RenameVaultAsync(
+ Guid vaultId,
+ UpdateVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
///
diff --git a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.Teams.cs b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.Teams.cs
index ff623cd..a038b58 100644
--- a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.Teams.cs
+++ b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.Teams.cs
@@ -496,6 +496,48 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return Task.FromResult(vault);
}
+ ///
+ ///
+ /// The owning team is renamed with the vault when it owns nothing else, exactly as the real service
+ /// does it — a fake that moved only the vault would let a test pass while the two names disagreed,
+ /// which is the state the server code goes out of its way to avoid.
+ ///
+ public Task RenameVaultAsync(
+ Guid vaultId,
+ UpdateVaultRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (personalVault is { } personal && personal.VaultId == vaultId)
+ {
+ personalVault = personal with { Name = request.Name };
+
+ return Task.FromResult(personalVault);
+ }
+
+ if (!teamVaults.TryGetValue(vaultId, out var vault))
+ {
+ throw new DodoSshApiException(
+ System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such vault.");
+ }
+
+ var renamed = vault with { Name = request.Name };
+
+ teamVaults[vaultId] = renamed;
+
+ if (renamed.TeamId is { } teamId
+ && !teamVaults.Values.Any(other => other.TeamId == teamId && other.VaultId != vaultId))
+ {
+ var index = teams.FindIndex(team => team.TeamId == teamId);
+
+ if (index >= 0)
+ {
+ teams[index] = teams[index] with { Name = request.Name };
+ }
+ }
+
+ return Task.FromResult(renamed);
+ }
+
///
public Task> LookupByEmailAsync(
string email,
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 35093b0..b4169dc 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -623,11 +623,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
- shell.ShowScreenCommand.Execute(ShellScreen.Vault);
+ shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.IsTerminalShowing.ShouldBeFalse();
shell.IsShowingPages.ShouldBeTrue();
- shell.IsVaultShowing.ShouldBeTrue();
+ shell.IsKeychainShowing.ShouldBeTrue();
// The session is untouched. Navigating away from a terminal is not a way to end one; only closing
// its tab is.
@@ -671,7 +671,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
- shell.ShowScreenCommand.Execute(ShellScreen.Vault);
+ shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.IsTerminalShowing.ShouldBeFalse();
shell.ShowTerminalCommand.Execute(null);
@@ -681,7 +681,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
// The page underneath is remembered, not reset. Going to the terminal and back is navigation, and
// navigation that forgets where you were is how a four-button bar becomes annoying.
- shell.Screen.ShouldBe(ShellScreen.Vault);
+ shell.Screen.ShouldBe(ShellScreen.Keychain);
}
///
@@ -934,7 +934,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
- shell.ShowScreenCommand.Execute(ShellScreen.Vault);
+ shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.OpenConnectSheetCommand.Execute(null);
@@ -1253,7 +1253,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
// Navigating away during the connection, which is the case this most exists for: the connection goes
// on, the tab stays selected, and nothing in the strip claims to be on screen.
- shell.ShowScreenCommand.Execute(ShellScreen.Vault);
+ shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
tab.IsShowing.ShouldBeFalse();
tab.IsSelected.ShouldBeTrue("navigating away is not deselecting");
@@ -1346,16 +1346,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectCommand.ExecuteAsync(null);
LitEntries().ShouldBe(0);
- shell.ShowScreenCommand.Execute(ShellScreen.Vault);
+ shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
LitEntries().ShouldBe(1);
- shell.IsVaultShowing.ShouldBeTrue();
+ shell.IsKeychainShowing.ShouldBeTrue();
int LitEntries() => new[]
{
shell.IsHostsShowing,
shell.IsTransfersShowing,
- shell.IsVaultShowing,
- shell.IsTeamShowing,
+ shell.IsKeychainShowing,
+ shell.IsVaultsShowing,
shell.IsPreferencesShowing,
}.Count(lit => lit);
}
@@ -2129,7 +2129,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// rather than four separate lists.
///
[Fact]
- public async Task TheVaultScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
+ public async Task TheKeychainScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -2223,7 +2223,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// nothing on screen to say it is there.
///
[Fact]
- public async Task SwitchingSectionIsRefusedWhileAVaultScreenEditorIsOpen()
+ public async Task SwitchingSectionIsRefusedWhileAKeychainScreenEditorIsOpen()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -2294,7 +2294,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// can see, because it is holding their private key.
///
[Fact]
- public async Task TheHostEditorAndAVaultScreenEditorCanBeOpenTogether()
+ public async Task TheHostEditorAndAKeychainScreenEditorCanBeOpenTogether()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -2317,7 +2317,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// the host editor, which does not.
///
[Fact]
- public async Task OnlyOneVaultScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
+ public async Task OnlyOneKeychainScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -2349,7 +2349,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
[Fact]
- public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherVaultScreenEditorToo()
+ public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherKeychainScreenEditorToo()
{
// The Edit commands are a second door into the same screen, and guarding only the Add ones would
// leave it wide open.
@@ -3055,7 +3055,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// longer the host editor, which is a different screen and has nothing to lose by the rail moving.
///
[Fact]
- public async Task TheCredentialEditorGuardsTheVaultScreensRail()
+ public async Task TheCredentialEditorGuardsTheKeychainScreensRail()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -3195,7 +3195,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
shell.IsKnownHostsShowing.ShouldBeTrue();
- shell.IsVaultShowing.ShouldBeFalse();
+ shell.IsKeychainShowing.ShouldBeFalse();
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
diff --git a/tests/DodoSSH.Client.App.Tests/TeamSharingTests.cs b/tests/DodoSSH.Client.App.Tests/TeamSharingTests.cs
deleted file mode 100644
index cec2c17..0000000
--- a/tests/DodoSSH.Client.App.Tests/TeamSharingTests.cs
+++ /dev/null
@@ -1,687 +0,0 @@
-using DodoSSH.Client.Session;
-// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
-// see the csproj for why it is shared rather than reimplemented.
-using DodoSSH.Client.Session.Tests;
-using DodoSSH.Client.Shell.ViewModels;
-using DodoSSH.Client.Ssh;
-using DodoSSH.Client.Storage;
-using DodoSSH.Client.Terminal;
-using DodoSSH.Contracts;
-using DodoSSH.Crypto;
-
-namespace DodoSSH.Client.App.Tests;
-
-///
-/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
-///
-///
-///
-/// The reason this suite exists rather than leaving teams to the server's own tests is that the
-/// interesting half is not on the server. Adding a member is a row; sharing is a decision the client
-/// makes about whether to trust a public key the server just handed it, and that decision is what
-/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
-/// lookup with a key of their own.
-///
-///
-/// So the fake server keeps a real key log — chained with the same KeyLogChain the server uses —
-/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
-/// sharing works, not that verification does.
-///
-///
-public sealed class TeamSharingTests : IAsyncLifetime
-{
- private const string Passphrase = "a sufficiently long passphrase";
-
- private static readonly Argon2Profile CheapProfile =
- Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
-
- private readonly FakeVaultServer server = new();
- private readonly FakeSshConnectionFactory ssh = new();
-
- private string directory = null!;
- private ClientCacheFactory caches = null!;
- private TerminalWorkspace workspace = null!;
- private VaultKnownHostStore knownHosts = null!;
- private FakeDeviceKeyStore deviceKeys = null!;
- private MainWindowViewModel shell = null!;
-
- private static CancellationToken Token => TestContext.Current.CancellationToken;
-
- ///
- public ValueTask InitializeAsync()
- {
- directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
-
- var paths = new ClientPaths(directory);
-
- caches = ClientCacheFactory.ForFile(paths.CacheFile);
- knownHosts = new VaultKnownHostStore();
- deviceKeys = new FakeDeviceKeyStore();
-
- workspace = new TerminalWorkspace(
- new InMemoryTerminalAssetProvider(
- new Dictionary(StringComparer.Ordinal)),
- ssh,
- TimeProvider.System);
-
- shell = new MainWindowViewModel(
- paths,
- caches,
- workspace,
- knownHosts,
- deviceKeys,
- (_, _) => Task.FromResult(server),
- TimeProvider.System,
- NSubstitute.Substitute.For(),
- CheapProfile);
-
- return ValueTask.CompletedTask;
- }
-
- ///
- public async ValueTask DisposeAsync()
- {
- await shell.DisposeAsync();
- knownHosts.Close();
- await workspace.DisposeAsync();
- caches.Dispose();
-
- try
- {
- Directory.Delete(directory, recursive: true);
- }
- catch (IOException)
- {
- // A cache file the process has not finished releasing. The directory is under the temp path
- // and named per run, so leaving it costs a few kilobytes and never collides.
- }
- }
-
- ///
- /// The whole point of a team, in one test. Note what the status line says after the add and before
- /// the share: adding somebody grants them nothing readable, and the interface has to say so rather
- /// than let a user believe the credential is already with their colleague.
- ///
- [Fact]
- public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
- var colleague = server.AddAccount("bob@example.com", "Bob Example");
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- await CreateVaultAsync(teams, "Platform secrets");
- teams.Vaults.Count.ShouldBe(1, teams.Status);
-
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.Members.Count.ShouldBe(2, teams.Status);
- teams.Status.ShouldContain("cannot read anything yet");
-
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
- teams.SelectedVault = teams.Vaults[0];
-
- await teams.ShareVaultCommand.ExecuteAsync(null);
-
- var vaultId = teams.Vaults[0].VaultId;
-
- server.IssuedGrants.ShouldContainKey((vaultId, colleague));
- teams.Status.ShouldContain("Shared");
-
- // The one thing verification cannot promise, said in the same breath as the success.
- teams.Status.ShouldContain("fingerprint", Case.Insensitive);
- }
-
- ///
- ///
- /// The test this whole design exists for. A server that wants to read a team's vault only has to
- /// answer one directory lookup with a key it holds the private half of — so the client reads the
- /// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
- /// offered is in there unchanged.
- ///
- ///
- /// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
- /// worse than no check at all, because the interface would have said it was verified.
- ///
- ///
- [Fact]
- public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
- var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
-
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
-
- teams.InviteEmail = "mallory@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
- teams.SelectedVault = teams.Vaults[0];
-
- server.CorruptKeyLog = true;
-
- await teams.ShareVaultCommand.ExecuteAsync(null);
-
- server.IssuedGrants.ShouldBeEmpty();
- teams.Status.ShouldContain("Did not share");
- teams.Status.ShouldContain("key log");
- }
-
- ///
- /// A vault created here is usable here, without a relock. The key was generated in this process, so
- /// making the user lock and unlock to reach the vault they just made would be asking them to work
- /// around bookkeeping.
- ///
- [Fact]
- public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
-
- var vaultId = teams.Vaults[0].VaultId;
- var session = shell.Vault!.Session;
-
- session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
-
- // And it is offered as somewhere to file a new item, which is what makes it worth having.
- await shell.Vault.LoadAsync(Token);
-
- shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
- shell.Vault.HasVaultChoice.ShouldBeTrue();
- }
-
- ///
- /// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
- /// one: an editor that read the picker at save time rather than at open time, so changing the picker
- /// with a half-typed host on screen would move it.
- ///
- [Fact]
- public async Task AHostFiledIntoATeamVault_StaysThere()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
-
- var vault = shell.Vault!;
- var teamVaultId = teams.Vaults[0].VaultId;
-
- await vault.LoadAsync(Token);
-
- vault.SelectedTargetVault =
- vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
-
- vault.NewHostCommand.Execute(null);
- vault.EditorLabel = "prod-db";
- vault.EditorHostname = "db.internal";
- vault.EditorUsername = "deploy";
-
- // Moved back after the editor opened. The host must still land in the team's vault.
- vault.SelectedTargetVault =
- vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
-
- await vault.SaveHostCommand.ExecuteAsync(null);
-
- var row = vault.Hosts.Single(
- host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
- row.VaultId.ShouldBe(teamVaultId);
- }
-
- ///
- /// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
- /// team vault has to stay there, because hosts are read across every readable vault and so come back.
- /// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
- /// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
- /// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
- ///
- [Fact]
- public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
-
- var teamVaultId = teams.Vaults[0].VaultId;
- var vault = shell.Vault!;
-
- await vault.LoadAsync(Token);
-
- vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
-
- vault.SelectedTargetVault = vault.TargetVaults.Single(
- choice => choice.VaultId == teamVaultId);
-
- vault.NewTagCommand.Execute(null);
- vault.TagEditorLabel = "eu-west-1";
- await vault.SaveTagCommand.ExecuteAsync(null);
-
- vault.Tags.ShouldHaveSingleItem().Label
- .ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
- }
-
- ///
- /// The screen's answer to "who can actually open this", which until now it could not give at all —
- /// the endpoint existed and nothing called it. Asserted after a share rather than before, because
- /// an empty list proves nothing about whether the call was made.
- ///
- [Fact]
- public async Task SelectingATeamVault_ListsWhoHoldsAKeyToIt()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
- var colleague = server.AddAccount("bob@example.com", "Bob Example");
-
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
-
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
- teams.SelectedVault = teams.Vaults[0];
-
- await teams.ShareVaultCommand.ExecuteAsync(null);
-
- // Selecting the vault again is what drives the read; the share above happened after the
- // previous selection had already loaded an empty list.
- teams.SelectedVault = null;
- teams.SelectedVault = teams.Vaults[0];
-
- var holder = teams.Grants.ShouldHaveSingleItem();
-
- holder.UserId.ShouldBe(colleague);
- holder.IsLive.ShouldBeTrue(teams.Status);
- holder.State.ShouldBe("holds a key");
- }
-
- ///
- /// A role change is authorization only. The status line has to say so, because the obvious reading
- /// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
- /// key they were already wrapped. Withdrawing that is a separate act.
- ///
- [Fact]
- public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
- var colleague = server.AddAccount("bob@example.com", "Bob Example");
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
-
- await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
-
- teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
- teams.Status.ShouldContain("does not withdraw a vault key");
- }
-
- ///
- /// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
- /// itself rather than letting the server do it: a button that produced a server error would be
- /// reporting a rule the screen already knew.
- ///
- [Fact]
- public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
- var colleague = server.AddAccount("bob@example.com", "Bob Example");
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
-
- await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
-
- teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
- teams.Status.ShouldContain("HAND OVER");
- }
-
- ///
- ///
- /// Both halves, because a transfer that only promoted the recipient would leave the team owned
- /// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
- /// a single transaction to make impossible, so the client test asserts the same pair.
- ///
- ///
- /// It also goes through the armed confirmation rather than calling the command directly, since
- /// arming and confirming are where the target id is carried — and carrying it on the selection
- /// instead is how a confirmation ends up applied to whatever was clicked last.
- ///
- ///
- [Fact]
- public async Task HandingOverATeam_MakesThemTheOwnerAndTheCallerAnAdmin()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
- var colleague = server.AddAccount("bob@example.com", "Bob Example");
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
-
- teams.TransferOwnershipCommand.Execute(null);
-
- teams.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
- teams.ShowsTeamActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
-
- await teams.ConfirmActionCommand.ExecuteAsync(null);
-
- teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
- teams.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
- teams.IsConfirming.ShouldBeFalse();
- }
-
- ///
- /// Archiving is refused while the team owns a vault, and the refusal has to reach the screen. The
- /// failure this guards is the quiet one: a client that swallowed the 409 and reloaded would show a
- /// team that is still there with no explanation of why nothing happened.
- ///
- [Fact]
- public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndSaysWhy()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
-
- teams.ArchiveTeamCommand.Execute(null);
- await teams.ConfirmActionCommand.ExecuteAsync(null);
-
- teams.Teams.ShouldContain(team => team.Slug == "platform");
- teams.Status.ShouldContain("holding a key");
- }
-
- ///
- /// An empty team can go, and this is the only operation on the screen that removes something from
- /// everybody's list at once.
- ///
- [Fact]
- public async Task ArchivingAnEmptyTeam_RemovesIt()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.ArchiveTeamCommand.Execute(null);
- await teams.ConfirmActionCommand.ExecuteAsync(null);
-
- teams.Teams.ShouldNotContain(team => team.Slug == "platform");
- teams.Status.ShouldContain("Archived");
- }
-
- ///
- /// Renaming leaves the slug alone, and the status line says so unprompted — somebody who assumed
- /// otherwise would find out from a URL much later, which is the worst moment to find out.
- ///
- [Fact]
- public async Task RenamingATeam_LeavesItsSlugAlone()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.RenameTeamCommand.Execute(null);
- teams.EditTeamName = "Platform Engineering";
-
- await teams.SaveTeamCommand.ExecuteAsync(null);
-
- var team = teams.Teams.ShouldHaveSingleItem();
-
- team.Name.ShouldBe("Platform Engineering");
- team.Slug.ShouldBe("platform");
- teams.Status.ShouldContain("slug is still 'platform'");
- }
-
- ///
- ///
- /// The address the directory does not know used to be a dead end — the screen said they had to sign
- /// in first and stopped. It invites them instead, from the same button, because which of the two
- /// applies is a fact about the server's account table rather than about what the user is doing.
- ///
- ///
- /// The status assertion is the point of the test. Nothing is sent, and an interface that said
- /// "invited" without saying that would leave somebody waiting for an email that is never coming.
- ///
- ///
- [Fact]
- public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.InviteEmail = "newcomer@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
-
- var invitation = teams.Invitations.ShouldHaveSingleItem();
-
- invitation.Email.ShouldBe("newcomer@example.com");
- invitation.IsPending.ShouldBeTrue();
- invitation.State.ShouldContain("Nothing was sent");
-
- teams.Status.ShouldContain("cannot send mail");
- }
-
- ///
- ///
- /// The regression this whole path was rewritten for. An account exists from its owner's first
- /// authenticated request and publishes no key until they choose a passphrase on their own machine,
- /// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
- /// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD MEMBER
- /// quietly issued an invitation instead: the members list did not change, the screen said they had
- /// no account here, and they only actually joined on the next hourly sweep.
- ///
- ///
- /// So the assertion is that they are a member, not an invitation, and that the row says
- /// what is true of them — no key, so nothing can be shared with them yet.
- ///
- ///
- [Fact]
- public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
- var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.InviteEmail = "carol@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
-
- teams.Members.Count.ShouldBe(2, teams.Status);
-
- var member = teams.Members.Single(row => row.UserId == colleague);
-
- member.Email.ShouldBe("carol@example.com");
-
- // The label the user asked to see, and the reason SHARE KEY is not the next step.
- member.KeyState.ShouldContain("no key yet");
-
- teams.Status.ShouldContain("Added");
- teams.Status.ShouldContain("no key yet");
- }
-
- ///
- /// The other half of the pair above: an address with no account at all still falls through to an
- /// invitation. It is the server that decides which, so this proves the fall-through survived being
- /// moved behind it rather than being replaced by an error.
- ///
- [Fact]
- public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.InviteEmail = "stranger@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
- teams.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
- }
-
- ///
- /// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
- /// disappeared would read as never having been sent, which is the same thing the screen looks like
- /// before anybody does anything.
- ///
- [Fact]
- public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await CreateTeamAsync(teams, "Platform", "platform");
-
- teams.InviteEmail = "newcomer@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.SelectedInvitation = teams.Invitations.ShouldHaveSingleItem();
-
- await teams.RevokeInvitationCommand.ExecuteAsync(null);
-
- teams.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
- teams.Status.ShouldContain("Withdrew the invitation");
- }
-
- ///
- ///
- /// A reload rebuilds the team list and reselects, so a reload that changed the selection — creating
- /// the first team is exactly that — used to leave two reads of the same team in flight: the one the
- /// reload awaits, and one the selection handler started on its own. Both clear the member list and
- /// then both append to it, so every member was drawn twice. On a team nobody has been added to yet,
- /// whose only member is its owner, that read as the owner being in the team twice.
- ///
- ///
- /// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
- /// memory each read finishes before the next begins, so the duplicate never appears and the bug
- /// survives the test. Holding the read open is what makes this behave like a server.
- ///
- ///
- [Fact]
- public async Task CreatingATeam_ReadsItsMembersOnce()
- {
- await UnlockedAsync();
-
- var teams = shell.Teams;
-
- await teams.LoadAsync(Token);
-
- teams.NewTeamCommand.Execute(null);
- teams.NewTeamName = "Platform";
- teams.NewTeamSlug = "platform";
-
- var gate = new TaskCompletionSource();
-
- server.MemberReadGate = gate;
-
- var create = teams.CreateTeamCommand.ExecuteAsync(null);
-
- // Asserted while the read is still in flight: that is the only moment at which a second read
- // started by the selection handler is distinguishable from the reload's own.
- server.MemberReads.ShouldBe(1, "a reload reads the selected team's members once");
-
- gate.SetResult();
-
- await create;
-
- teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
- }
-
- ///
- /// Through the form rather than straight at the command, because the name is what the form is for: a
- /// vault used to be named after its team, which gave a team with three of them three vaults called the
- /// same thing.
- ///
- private async Task CreateVaultAsync(TeamsViewModel teams, string name)
- {
- teams.NewVaultCommand.Execute(null);
- teams.NewVaultName = name;
-
- await teams.CreateVaultCommand.ExecuteAsync(null);
-
- teams.IsCreatingVault.ShouldBeFalse(teams.Status);
- }
-
- private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
- {
- await teams.LoadAsync(Token);
-
- teams.NewTeamCommand.Execute(null);
- teams.NewTeamName = name;
- teams.NewTeamSlug = slug;
-
- await teams.CreateTeamCommand.ExecuteAsync(null);
-
- teams.SelectedTeam.ShouldNotBeNull(teams.Status);
- }
-
- ///
- /// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
- /// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
- /// that entry is what the client verifies its own directory answer against.
- ///
- private async Task UnlockedAsync()
- {
- await shell.StartAsync(Token);
- await shell.SignInCommand.ExecuteAsync(null);
-
- shell.Passphrase = Passphrase;
- shell.ConfirmPassphrase = Passphrase;
- await shell.EnrollCommand.ExecuteAsync(null);
-
- shell.RecoveryCodeWrittenDown = true;
- shell.ConfirmRecoveryCodeCommand.Execute(null);
-
- shell.Passphrase = Passphrase;
- await shell.UnlockCommand.ExecuteAsync(null);
-
- shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
- }
-}
diff --git a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
new file mode 100644
index 0000000..cef6876
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
@@ -0,0 +1,767 @@
+using DodoSSH.Client.Session;
+// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
+// see the csproj for why it is shared rather than reimplemented.
+using DodoSSH.Client.Session.Tests;
+using DodoSSH.Client.Shell.ViewModels;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// Vaults, from the side that holds the keys: make one, add somebody, and wrap its key to them.
+///
+///
+///
+/// The reason this suite exists rather than leaving sharing to the server's own tests is that the
+/// interesting half is not on the server. Adding a member is a row; sharing is a decision the client
+/// makes about whether to trust a public key the server just handed it, and that decision is what
+/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
+/// lookup with a key of their own.
+///
+///
+/// So the fake server keeps a real key log — chained with the same KeyLogChain the server uses —
+/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
+/// sharing works, not that verification does.
+///
+///
+/// It was TeamSharingTests, and the screen it drives stopped being about teams: a vault is what
+/// gets made and named, and the membership list behind it is made with it. The team is still what the
+/// server authorises against, which is why the assertions about roles, hand-over and invitations are all
+/// still here — they are the same operations, reached through the vault they apply to.
+///
+///
+public sealed class VaultSharingTests : IAsyncLifetime
+{
+ private const string Passphrase = "a sufficiently long passphrase";
+
+ private static readonly Argon2Profile CheapProfile =
+ Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
+
+ private readonly FakeVaultServer server = new();
+ private readonly FakeSshConnectionFactory ssh = new();
+
+ private string directory = null!;
+ private ClientCacheFactory caches = null!;
+ private TerminalWorkspace workspace = null!;
+ private VaultKnownHostStore knownHosts = null!;
+ private FakeDeviceKeyStore deviceKeys = null!;
+ private MainWindowViewModel shell = null!;
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ ///
+ public ValueTask InitializeAsync()
+ {
+ directory = Path.Combine(Path.GetTempPath(), $"dodossh-vaults-{Guid.CreateVersion7():N}");
+
+ var paths = new ClientPaths(directory);
+
+ caches = ClientCacheFactory.ForFile(paths.CacheFile);
+ knownHosts = new VaultKnownHostStore();
+ deviceKeys = new FakeDeviceKeyStore();
+
+ workspace = new TerminalWorkspace(
+ new InMemoryTerminalAssetProvider(
+ new Dictionary(StringComparer.Ordinal)),
+ ssh,
+ TimeProvider.System);
+
+ shell = new MainWindowViewModel(
+ paths,
+ caches,
+ workspace,
+ knownHosts,
+ deviceKeys,
+ (_, _) => Task.FromResult(server),
+ TimeProvider.System,
+ NSubstitute.Substitute.For(),
+ CheapProfile);
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await shell.DisposeAsync();
+ knownHosts.Close();
+ await workspace.DisposeAsync();
+ caches.Dispose();
+
+ try
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ catch (IOException)
+ {
+ // A cache file the process has not finished releasing. The directory is under the temp path
+ // and named per run, so leaving it costs a few kilobytes and never collides.
+ }
+ }
+
+ ///
+ /// The whole point of a shared vault, in one test. Note what the status line says after the add and
+ /// before the share: adding somebody grants them nothing readable, and the interface has to say so
+ /// rather than let a user believe the credential is already with their colleague.
+ ///
+ [Fact]
+ public async Task CreatingAVaultAndSharingIt_WrapsTheKeyToTheOtherMember()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ var colleague = server.AddAccount("bob@example.com", "Bob Example");
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.Members.Count.ShouldBe(2, vaults.Status);
+ vaults.Status.ShouldContain("cannot read anything yet");
+
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
+
+ await vaults.ShareVaultCommand.ExecuteAsync(null);
+
+ var vaultId = vaults.SelectedVault!.VaultId;
+
+ server.IssuedGrants.ShouldContainKey((vaultId, colleague));
+ vaults.Status.ShouldContain("Shared");
+
+ // The one thing verification cannot promise, said in the same breath as the success.
+ vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
+ }
+
+ ///
+ ///
+ /// The test this whole design exists for. A server that wants to read a shared vault only has to
+ /// answer one directory lookup with a key it holds the private half of — so the client reads the
+ /// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
+ /// offered is in there unchanged.
+ ///
+ ///
+ /// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
+ /// worse than no check at all, because the interface would have said it was verified.
+ ///
+ ///
+ [Fact]
+ public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "mallory@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
+
+ server.CorruptKeyLog = true;
+
+ await vaults.ShareVaultCommand.ExecuteAsync(null);
+
+ server.IssuedGrants.ShouldBeEmpty();
+ vaults.Status.ShouldContain("Did not share");
+ vaults.Status.ShouldContain("key log");
+ }
+
+ ///
+ /// A vault created here is usable here, without a relock. The key was generated in this process, so
+ /// making the user lock and unlock to reach the vault they just made would be asking them to work
+ /// around bookkeeping.
+ ///
+ [Fact]
+ public async Task AVaultCreatedHere_IsImmediatelyReadableAndWritable()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vaultId = vaults.SelectedVault!.VaultId;
+ var session = shell.Vault!.Session;
+
+ session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
+
+ // And it is offered as somewhere to file a new item, which is what makes it worth having.
+ await shell.Vault.LoadAsync(Token);
+
+ shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
+ shell.Vault.HasVaultChoice.ShouldBeTrue();
+ }
+
+ ///
+ /// Making a vault makes exactly one membership list, and this is the assertion that the two-step create
+ /// has not started leaking them: the screen no longer offers to make one on its own, so a second one
+ /// per vault would be invisible in the interface and visible only to an operator.
+ ///
+ [Fact]
+ public async Task CreatingAVault_MakesOneMembershipListWithTheCallerAsItsOwner()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ server.TeamCreates.ShouldBe(1);
+
+ var row = vaults.Vaults.Single(
+ vault => string.Equals(vault.Name, "Platform secrets", StringComparison.Ordinal));
+
+ row.IsShared.ShouldBeTrue("a vault made here is one other people can be added to");
+ row.IsOwned.ShouldBeTrue(vaults.Status);
+ row.SharedWithOtherVaults.ShouldBe(0, "it was made with a membership list of its own");
+
+ vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
+ }
+
+ ///
+ /// The personal vault is in the list, is marked as the one thing it is, and offers nothing to share:
+ /// the server refuses a grant on one outright, so a screen that let somebody try would be sending them
+ /// at a refusal.
+ ///
+ [Fact]
+ public async Task ThePersonalVault_IsListedAndCannotBeSharedWithAnybody()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await vaults.LoadAsync(Token);
+
+ var personal = vaults.Vaults.ShouldHaveSingleItem();
+
+ personal.IsPersonal.ShouldBeTrue();
+ personal.IsShared.ShouldBeFalse();
+ personal.RoleLabel.ShouldBe("PERSONAL");
+
+ vaults.SelectedVault = personal;
+ vaults.SelectedIsShared.ShouldBeFalse();
+
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.Members.ShouldBeEmpty();
+ vaults.Status.ShouldContain("cannot be shared");
+ }
+
+ ///
+ /// Filing into a shared vault has to be chosen and has to stick. The bug this guards is the obvious
+ /// one: an editor that read the picker at save time rather than at open time, so changing the picker
+ /// with a half-typed host on screen would move it.
+ ///
+ [Fact]
+ public async Task AHostFiledIntoASharedVault_StaysThere()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ vault.SelectedTargetVault =
+ vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+ vault.EditorUsername = "deploy";
+
+ // Moved back after the editor opened. The host must still land in the shared vault: the keychain
+ // screen's picker seeds the editor's and stops mattering from there.
+ vault.SelectedTargetVault =
+ vault.TargetVaults.First(choice => choice.VaultId != sharedVaultId);
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ var row = vault.Hosts.Single(
+ host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
+ row.VaultId.ShouldBe(sharedVaultId);
+ }
+
+ ///
+ ///
+ /// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
+ /// on the form the host is being typed into, rather than through a standing preference on another
+ /// screen.
+ ///
+ ///
+ /// It is asserted from the editor's own selection rather than the keychain screen's, because the two
+ /// are deliberately separate — moving one must not move the other.
+ ///
+ ///
+ [Fact]
+ public async Task TheHostEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ vault.NewHostCommand.Execute(null);
+
+ vault.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
+
+ var personal = vault.SelectedTargetVault!;
+
+ vault.EditorSelectedVault =
+ vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.Hosts
+ .Single(host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal))
+ .VaultId
+ .ShouldBe(sharedVaultId);
+
+ vault.SelectedTargetVault.ShouldBe(
+ personal, "the editor's picker is the host's, not the screen's standing preference");
+ }
+
+ ///
+ /// An existing host is not offered the picker at all. Moving an item between vaults is a delete and a
+ /// retype — they are encrypted under different keys — so a control that appeared to offer it would be
+ /// offering something no layer below can do.
+ ///
+ [Fact]
+ public async Task EditingAnExistingHost_DoesNotOfferToMoveItBetweenVaults()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+
+ await vault.LoadAsync(Token);
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.SelectedHost = vault.Hosts.Single(
+ host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
+
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.IsEditing.ShouldBeTrue(vault.Status);
+ vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
+ }
+
+ ///
+ /// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
+ /// shared vault has to stay there, because hosts are read across every readable vault and so come back.
+ /// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
+ /// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
+ /// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
+ ///
+ [Fact]
+ public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+ var vault = shell.Vault!;
+
+ await vault.LoadAsync(Token);
+
+ vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
+
+ vault.SelectedTargetVault = vault.TargetVaults.Single(
+ choice => choice.VaultId == sharedVaultId);
+
+ vault.NewTagCommand.Execute(null);
+ vault.TagEditorLabel = "eu-west-1";
+ await vault.SaveTagCommand.ExecuteAsync(null);
+
+ vault.Tags.ShouldHaveSingleItem().Label
+ .ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
+ }
+
+ ///
+ /// The screen's answer to "who can actually open this". Asserted after a share rather than before,
+ /// because an empty list proves nothing about whether the call was made.
+ ///
+ [Fact]
+ public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ var colleague = server.AddAccount("bob@example.com", "Bob Example");
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
+
+ await vaults.ShareVaultCommand.ExecuteAsync(null);
+
+ var holder = vaults.Grants.ShouldHaveSingleItem();
+
+ holder.UserId.ShouldBe(colleague);
+ holder.IsLive.ShouldBeTrue(vaults.Status);
+ holder.State.ShouldBe("holds a key");
+ }
+
+ ///
+ /// A role change is authorization only. The status line has to say so, because the obvious reading
+ /// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
+ /// key they were already wrapped. Withdrawing that is a separate act.
+ ///
+ [Fact]
+ public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ var colleague = server.AddAccount("bob@example.com", "Bob Example");
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
+
+ await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
+
+ vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
+ vaults.Status.ShouldContain("does not withdraw a vault key");
+ }
+
+ ///
+ /// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
+ /// itself rather than letting the server do it: a button that produced a server error would be
+ /// reporting a rule the screen already knew.
+ ///
+ [Fact]
+ public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ var colleague = server.AddAccount("bob@example.com", "Bob Example");
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
+
+ await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
+
+ vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
+ vaults.Status.ShouldContain("HAND OVER");
+ }
+
+ ///
+ ///
+ /// Both halves, because a transfer that only promoted the recipient would leave the vault owned
+ /// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
+ /// a single transaction to make impossible, so the client test asserts the same pair.
+ ///
+ ///
+ /// It also goes through the armed confirmation rather than calling the command directly, since
+ /// arming and confirming are where the target ids are carried — and carrying them on the selection
+ /// instead is how a confirmation ends up applied to whatever was clicked last.
+ ///
+ ///
+ [Fact]
+ public async Task HandingOverAVault_MakesThemTheOwnerAndTheCallerAnAdmin()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ var colleague = server.AddAccount("bob@example.com", "Bob Example");
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
+
+ vaults.HandOverCommand.Execute(null);
+
+ vaults.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
+ vaults.ShowsVaultActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
+
+ await vaults.ConfirmActionCommand.ExecuteAsync(null);
+
+ vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
+ vaults.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
+ vaults.IsConfirming.ShouldBeFalse();
+ }
+
+ ///
+ /// Renaming reaches the rest of the shell, which is the half a client can get wrong quietly: the name
+ /// is drawn on the badge of every host card in a session holding more than one vault, in the
+ /// file-this-into picker, and in the tab strip's menu.
+ ///
+ [Fact]
+ public async Task RenamingAVault_ReachesTheKeychainScreensPickerToo()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vaultId = vaults.SelectedVault!.VaultId;
+
+ vaults.RenameVaultCommand.Execute(null);
+ vaults.EditVaultName = "Platform";
+
+ await vaults.SaveVaultNameCommand.ExecuteAsync(null);
+
+ vaults.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
+ vaults.Status.ShouldContain("re-encrypted");
+
+ await shell.Vault!.LoadAsync(Token);
+
+ shell.Vault.TargetVaults
+ .Single(choice => choice.VaultId == vaultId)
+ .Name
+ .ShouldBe("Platform");
+ }
+
+ ///
+ ///
+ /// The address the directory does not know used to be a dead end — the screen said they had to sign
+ /// in first and stopped. It invites them instead, from the same button, because which of the two
+ /// applies is a fact about the server's account table rather than about what the user is doing.
+ ///
+ ///
+ /// The status assertion is the point of the test. Nothing is sent, and an interface that said
+ /// "invited" without saying that would leave somebody waiting for an email that is never coming.
+ ///
+ ///
+ [Fact]
+ public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "newcomer@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
+
+ var invitation = vaults.Invitations.ShouldHaveSingleItem();
+
+ invitation.Email.ShouldBe("newcomer@example.com");
+ invitation.IsPending.ShouldBeTrue();
+ invitation.State.ShouldContain("Nothing was sent");
+
+ vaults.Status.ShouldContain("cannot send mail");
+ }
+
+ ///
+ ///
+ /// The regression this whole path was rewritten for. An account exists from its owner's first
+ /// authenticated request and publishes no key until they choose a passphrase on their own machine,
+ /// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
+ /// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD quietly
+ /// issued an invitation instead: the members list did not change, the screen said they had no
+ /// account here, and they only actually joined on the next hourly sweep.
+ ///
+ ///
+ /// So the assertion is that they are a member, not an invitation, and that the row says
+ /// what is true of them — no key, so nothing can be shared with them yet.
+ ///
+ ///
+ [Fact]
+ public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "carol@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
+
+ vaults.Members.Count.ShouldBe(2, vaults.Status);
+
+ var member = vaults.Members.Single(row => row.UserId == colleague);
+
+ member.Email.ShouldBe("carol@example.com");
+
+ // The label the user asked to see, and the reason SHARE KEY is not the next step.
+ member.KeyState.ShouldContain("no key yet");
+
+ vaults.Status.ShouldContain("Added");
+ vaults.Status.ShouldContain("no key yet");
+ }
+
+ ///
+ /// The other half of the pair above: an address with no account at all still falls through to an
+ /// invitation. It is the server that decides which, so this proves the fall-through survived being
+ /// moved behind it rather than being replaced by an error.
+ ///
+ [Fact]
+ public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "stranger@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
+ vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
+ }
+
+ ///
+ /// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
+ /// disappeared would read as never having been sent, which is the same thing the screen looks like
+ /// before anybody does anything.
+ ///
+ [Fact]
+ public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ vaults.InviteEmail = "newcomer@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
+
+ vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
+
+ await vaults.RevokeInvitationCommand.ExecuteAsync(null);
+
+ vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
+ vaults.Status.ShouldContain("Withdrew the invitation");
+ }
+
+ ///
+ ///
+ /// A reload rebuilds the vault list and reselects, so a reload that changed the selection — creating
+ /// the first shared vault is exactly that — used to leave two reads of the same membership list in
+ /// flight: the one the reload awaits, and one the selection handler started on its own. Both clear the
+ /// member list and then both append to it, so every member was drawn twice. On a vault nobody has been
+ /// added to yet, whose only member is its owner, that read as the owner being in it twice.
+ ///
+ ///
+ /// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
+ /// memory each read finishes before the next begins, so the duplicate never appears and the bug
+ /// survives the test. Holding the read open is what makes this behave like a server.
+ ///
+ ///
+ [Fact]
+ public async Task CreatingAVault_ReadsItsMembersOnce()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await vaults.LoadAsync(Token);
+
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = "Platform secrets";
+
+ var gate = new TaskCompletionSource();
+
+ server.MemberReadGate = gate;
+
+ var create = vaults.CreateVaultCommand.ExecuteAsync(null);
+
+ // Asserted while the read is still in flight: that is the only moment at which a second read
+ // started by the selection handler is distinguishable from the reload's own.
+ server.MemberReads.ShouldBe(1, "a reload reads the selected vault's members once");
+
+ gate.SetResult();
+
+ await create;
+
+ vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
+ }
+
+ ///
+ /// Through the form rather than straight at the command, because the name is what the form is for —
+ /// and because the form is now the only way in: there is no separate "make a team" step behind it.
+ ///
+ private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
+ {
+ await vaults.LoadAsync(Token);
+
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = name;
+
+ await vaults.CreateVaultCommand.ExecuteAsync(null);
+
+ vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
+ vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
+ vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
+ }
+
+ ///
+ /// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
+ /// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
+ /// that entry is what the client verifies its own directory answer against.
+ ///
+ private async Task UnlockedAsync()
+ {
+ await shell.StartAsync(Token);
+ await shell.SignInCommand.ExecuteAsync(null);
+
+ shell.Passphrase = Passphrase;
+ shell.ConfirmPassphrase = Passphrase;
+ await shell.EnrollCommand.ExecuteAsync(null);
+
+ shell.RecoveryCodeWrittenDown = true;
+ shell.ConfirmRecoveryCodeCommand.Execute(null);
+
+ shell.Passphrase = Passphrase;
+ await shell.UnlockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/VaultVisibilityTests.cs b/tests/DodoSSH.Client.App.Tests/VaultVisibilityTests.cs
index 158c771..dff6e75 100644
--- a/tests/DodoSSH.Client.App.Tests/VaultVisibilityTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/VaultVisibilityTests.cs
@@ -6,6 +6,7 @@ using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
+using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
@@ -90,28 +91,31 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
///
/// The whole feature in one test. A name is all that is asked for, and what comes back is a vault this
- /// machine can already write to inside a team this account owns — which is what makes the rest of the
- /// screen, members and roles and key holders, apply to it.
+ /// machine can already write to, with a membership list this account owns — which is what makes the
+ /// rest of the screen, members and roles and key holders, apply to it.
///
[Fact]
- public async Task CreatingAVaultByNameAlone_MakesATeamForItAndOwnsIt()
+ public async Task CreatingAVaultByNameAlone_MakesTheMembershipListForItAndOwnsIt()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await teams.LoadAsync(Token);
+ await vaults.LoadAsync(Token);
await CreateVaultAsync("Platform secrets");
- var team = teams.Teams.ShouldHaveSingleItem();
+ // Read from the server rather than off the screen: the membership list behind a vault is not a
+ // thing this screen shows any more, and that is exactly why it is worth asserting on directly.
+ var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
team.Name.ShouldBe("Platform secrets");
team.Slug.ShouldBe("platform-secrets", "the slug is derived rather than asked for");
- team.Role.ShouldBe("OWNER");
+ team.Role.ShouldBe(TeamMemberRole.Owner);
- var vault = teams.Vaults.ShouldHaveSingleItem();
+ var vault = vaults.Vaults.Single(
+ row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
- vault.Name.ShouldBe("Platform secrets");
+ vault.IsOwned.ShouldBeTrue(vaults.Status);
shell.Vault!.Session.ReadableVaults
.Select(row => row.VaultId)
.ShouldContain(vault.VaultId, "a vault made here is usable here, without a relock");
@@ -122,81 +126,109 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
/// next thing anybody making a shared vault wants is the people, and the people are here.
///
[Fact]
- public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheTeamsScreen()
+ public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheVaultsScreen()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await teams.LoadAsync(Token);
+ await vaults.LoadAsync(Token);
await CreateVaultAsync("Platform secrets");
- teams.SelectedTeam.ShouldNotBeNull(teams.Status);
- teams.SelectedTeam.Name.ShouldBe("Platform secrets");
- teams.SelectedVault.ShouldNotBeNull(teams.Status);
- teams.SelectedVault.Name.ShouldBe("Platform secrets");
+ vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
+ vaults.SelectedVault.Name.ShouldBe("Platform secrets");
+ vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
///
- /// The failure between the two calls. The team is real and stays — a client that archived it because a
- /// later step failed is a client that will one day archive a team somebody has just been added to — so
- /// the sentence has to carry the whole state rather than "creating the vault failed".
+ /// The failure between the two calls. The membership list is real and is kept for the retry — the
+ /// sentence has to carry the whole state rather than "creating the vault failed", because pressing
+ /// CREATE again is what finishes the job and cancelling is what undoes it.
///
[Fact]
- public async Task AVaultCreateThatFailsAfterTheTeam_KeepsTheTeamAndSaysSo()
+ public async Task AVaultCreateThatFailsAfterTheMembershipList_KeepsItAndSaysSo()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await teams.LoadAsync(Token);
+ await vaults.LoadAsync(Token);
server.VaultCreateFailures = 1;
- teams.NewVaultInItsOwnTeamCommand.Execute(null);
- teams.NewVaultName = "Platform secrets";
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = "Platform secrets";
- await teams.CreateVaultCommand.ExecuteAsync(null);
+ await vaults.CreateVaultCommand.ExecuteAsync(null);
- teams.Teams.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
- teams.Vaults.ShouldBeEmpty();
+ (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
+ vaults.Vaults.ShouldNotContain(
+ row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
- teams.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
- teams.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
+ vaults.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
+ vaults.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
- teams.Status.ShouldContain("was created, but its vault was not");
- teams.Status.ShouldContain("Press CREATE again");
+ vaults.Status.ShouldContain("was not created");
+ vaults.Status.ShouldContain("Press CREATE again");
}
///
- /// The retry, and the reason the team id is generated once and held rather than per attempt. A second
- /// team would leave somebody with two identically named ones and no way to tell which is which.
+ /// The retry, and the reason the id is generated once and held rather than per attempt. A second
+ /// membership list would be one nothing on this screen could show and nobody could remove.
///
[Fact]
- public async Task RetryingAfterTheVaultCreateFailed_ReusesTheTeamRatherThanMakingASecond()
+ public async Task RetryingAfterTheVaultCreateFailed_ReusesTheMembershipListRatherThanMakingASecond()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await teams.LoadAsync(Token);
+ await vaults.LoadAsync(Token);
server.VaultCreateFailures = 1;
- teams.NewVaultInItsOwnTeamCommand.Execute(null);
- teams.NewVaultName = "Platform secrets";
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = "Platform secrets";
- await teams.CreateVaultCommand.ExecuteAsync(null);
+ await vaults.CreateVaultCommand.ExecuteAsync(null);
- var teamId = teams.Teams.ShouldHaveSingleItem().TeamId;
+ var teamId = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId;
// Pressed again on the form that is still open, which is exactly what the message tells the user
// to do.
- await teams.CreateVaultCommand.ExecuteAsync(null);
+ await vaults.CreateVaultCommand.ExecuteAsync(null);
- teams.Teams.ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
- teams.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
- teams.IsCreatingVault.ShouldBeFalse(teams.Status);
+ (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
+ vaults.Vaults.ShouldContain(
+ row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
+ vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
+ }
+
+ ///
+ /// Cancelling takes the half-made membership list with it, which is the one place this application
+ /// tidies up on the user's behalf. The reason is that nothing on the screen can reach it: a membership
+ /// list with no vault has no row, so leaving it would leave something the user can neither see nor
+ /// remove.
+ ///
+ [Fact]
+ public async Task CancellingAfterTheVaultCreateFailed_TakesTheMembershipListWithIt()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await vaults.LoadAsync(Token);
+
+ server.VaultCreateFailures = 1;
+
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = "Platform secrets";
+
+ await vaults.CreateVaultCommand.ExecuteAsync(null);
+ await vaults.CancelNewVaultCommand.ExecuteAsync(null);
+
+ (await server.Teams.ListTeamsAsync(Token))
+ .ShouldBeEmpty("the membership list nobody was shown is not left behind");
}
///
@@ -209,15 +241,15 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await teams.LoadAsync(Token);
+ await vaults.LoadAsync(Token);
server.TakenSlugs.Add("platform-secrets");
await CreateVaultAsync("Platform secrets");
- var team = teams.Teams.ShouldHaveSingleItem();
+ var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
team.Name.ShouldBe("Platform secrets", "the name is what the user typed");
team.Slug.ShouldStartWith("platform-secrets-");
@@ -226,20 +258,20 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
///
/// A name written in a script with no a-z or 0-9 in it leaves nothing to slugify. It still has to be a
- /// vault a person can make, so the fallback is the team's own id rather than a refusal pointing at a
- /// field that does not exist.
+ /// vault a person can make, so the fallback is an id rather than a refusal pointing at a field that
+ /// does not exist.
///
[Fact]
public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await teams.LoadAsync(Token);
+ await vaults.LoadAsync(Token);
await CreateVaultAsync("διαχείριση");
- var team = teams.Teams.ShouldHaveSingleItem();
+ var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
team.Name.ShouldBe("διαχείριση");
team.Slug.ShouldStartWith("vault-");
@@ -455,7 +487,9 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
shell.HasVaultSwitches.ShouldBeTrue();
shell.VaultToggles.Count.ShouldBe(2);
shell.VaultToggles[0].IsPersonal.ShouldBeTrue();
- shell.VaultToggles[1].Display.ShouldBe("Platform secrets · TEAM");
+ // SHARED rather than TEAM: a team is no longer something the person reading this menu has been
+ // shown, so the word names what the switch is actually about.
+ shell.VaultToggles[1].Display.ShouldBe("Platform secrets · SHARED");
}
// ---- Helpers ----
@@ -475,22 +509,22 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
/// Names a vault, from the form the tab strip's menu opens.
private async Task CreateVaultAsync(string name)
{
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- teams.NewVaultInItsOwnTeamCommand.Execute(null);
- teams.NewVaultName = name;
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = name;
- await teams.CreateVaultCommand.ExecuteAsync(null);
+ await vaults.CreateVaultCommand.ExecuteAsync(null);
- teams.IsCreatingVault.ShouldBeFalse(teams.Status);
+ vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
- return teams.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
+ return vaults.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
.VaultId;
}
private async Task VaultWithAHostAsync(string vaultName, string hostLabel)
{
- await shell.Teams.LoadAsync(Token);
+ await shell.Vaults.LoadAsync(Token);
var vaultId = await CreateVaultAsync(vaultName);
@@ -501,7 +535,7 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
private async Task VaultWithAKeyAsync(string vaultName, string keyLabel)
{
- await shell.Teams.LoadAsync(Token);
+ await shell.Vaults.LoadAsync(Token);
var vaultId = await CreateVaultAsync(vaultName);
var vault = shell.Vault!;