diff --git a/README.md b/README.md
index c898a5c..5aaa367 100644
--- a/README.md
+++ b/README.md
@@ -231,18 +231,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.
@@ -251,14 +263,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
@@ -270,9 +282,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.
@@ -280,50 +292,46 @@ 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 rotate every team vault, 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 rotate the vault, 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.
+Six limits, stated rather than discovered:
-Four limits, stated rather than discovered:
-
-- **Removing a member is not retroactive.** It revokes their grants, rotates every team vault your machine
- can open, and hands each new key to the members who are left — so nothing written from that point on is
- readable to them. Everything they already pulled is still 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, rotates every vault behind that
+ membership list your machine can open, and hands each new key to the members who are left — so nothing
+ written from that point on is readable to them. Everything they already pulled is still 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 rotation moves the vault's contents too, and says so when it could not.** The key changes first, in
one server transaction; then every item already stored is re-sealed under it, so the key somebody left
with opens nothing that is still here. Existing items keep working throughout — everybody still in the
- team holds the older keys as well as the new one, which is what stops a half-finished rotation making a
+ vault holds the older keys as well as the new one, which is what stops a half-finished rotation making a
vault unreadable, and what makes the pass safe to interrupt and run again. An item somebody else was
editing at that moment is left for the next pass, and the message tells you which of the two you got.
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
-- **Adding a member shares the vaults you can open, including their history.** Membership is still one act
+- **Adding somebody shares the vaults you can open, including their history.** Membership is still one act
and a key is still another — nothing changed about that — but the client now performs the second one for
you, wrapping every generation it holds so the new member can read the vault back to its first item. A
vault your machine holds no key to is skipped and says so; somebody who holds it has to share that one.
-- **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
@@ -331,9 +339,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
@@ -345,7 +358,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
@@ -397,9 +410,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
@@ -603,15 +616,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.* 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
+- **M3 — shared vaults**, sharing, ACLs. *Done.* 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 395e406..3e4b44a 100644
--- a/docs/adr/0009-team-access-model.md
+++ b/docs/adr/0009-team-access-model.md
@@ -121,6 +121,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 6eda9a5..9cf65d9 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 59b1121..a33fdd8 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,18 +1147,22 @@ 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.
+ opens. This is where an invitation differs from an add and the difference is not a defect: adding an
+ account the directory already knows wraps the key on the spot, because the adding machine has it and
+ the recipient has a published key to wrap to. An invitation has neither at the moment it is issued —
+ there is no account yet — and the claim happens on Bob's machine, which holds nothing. So the key is
+ still owed, and somebody has to hand it over.
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
@@ -1171,15 +1175,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 +1192,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 +1231,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 a9fd041..076dee0 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 d7859f1..b62730c 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 9bd7756..3f2b67e 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.
///
/// One row per holder, not one per grant. A rotated vault holds several grants per member — one per
diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
index fa8464c..7ddbd54 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);
@@ -574,6 +587,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 569cb74..5d6e1da 100644
--- a/src/DodoSSH.Client.Session/VaultSharing.cs
+++ b/src/DodoSSH.Client.Session/VaultSharing.cs
@@ -151,6 +151,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 5141703..3e2e71b 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 59%
rename from src/DodoSSH.Client.Shell/ViewModels/TeamsViewModel.cs
rename to src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
index 8f258ad..b05f8c2 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,72 +535,351 @@ 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()
- {
- NewTeamName = string.Empty;
- NewTeamSlug = string.Empty;
- IsCreatingTeam = true;
- 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.
+ /// Fills the vault list from the session, with the server's membership facts laid over it.
///
- /// 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.
+ /// 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)
+ {
+ 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 async Task CreateTeamAsync(CancellationToken cancellationToken)
+ private void NewVault()
{
- if (connection() is not { } server)
+ // 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 name-a-vault form.
+ ///
+ ///
+ /// 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 CancelNewVaultAsync(CancellationToken cancellationToken)
+ {
+ IsCreatingVault = false;
+ Status = string.Empty;
+
+ if (pendingVaultTeamId is not { } orphan)
{
- Status = "Offline. Creating a team needs a connection.";
return;
}
- var name = NewTeamName.Trim();
- var slug = NewTeamSlug.Trim().ToLowerInvariant();
+ pendingVaultTeamId = null;
- if (name.Length == 0 || slug.Length == 0)
+ if (connection() is not { } server)
{
- Status = "A team needs a name and a slug.";
+ 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 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 it, so they are all there the moment the vault is.
+ ///
+ ///
+ /// 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]
+ private async Task CreateVaultAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server)
+ {
+ Status = "Offline. Creating a vault needs a connection.";
+ return;
+ }
+
+ if (session() is not { } open)
+ {
+ Status = "Unlock your keychain first: a vault's key is generated on this machine.";
+ return;
+ }
+
+ var name = NewVaultName.Trim();
+
+ if (name.Length == 0)
+ {
+ Status = "A vault needs a name.";
+ return;
+ }
+
+ await RunAsync(() => AddVaultAsync(server, open, name, cancellationToken)).ConfigureAwait(true);
+ }
+
+ /// The two calls behind , once its arguments are known good.
+ private async Task AddVaultAsync(
+ IVaultServer server,
+ VaultSession open,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ var teamId = await EnsureTeamForVaultAsync(server, name, cancellationToken).ConfigureAwait(true);
+
+ StoredVault vault;
+
+ try
+ {
+ vault = await open
+ .CreateTeamVaultAsync(server.Teams, teamId, name, cancellationToken)
+ .ConfigureAwait(true);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ // 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;
+ }
+
+ IsCreatingVault = false;
+ pendingVaultTeamId = null;
+ NewVaultName = string.Empty;
+
+ 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.
+ 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 beside it, then press SHARE KEY.";
+ }
+
+ /// 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
+ /// a fact the first one already contains.
+ ///
+ private async Task EnsureTeamForVaultAsync(
+ IVaultServer server,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ 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 one.
+ pendingVaultTeamId = teamId;
+
+ var slug = Slugify(name, teamId);
+
+ try
+ {
+ await server.Teams
+ .CreateTeamAsync(new CreateTeamRequest(teamId, name, slug, null), cancellationToken)
+ .ConfigureAwait(true);
+ }
+ catch (DodoSshApiException exception)
+ when (string.Equals(exception.Code, ProblemCodes.TeamSlugTaken, StringComparison.Ordinal))
+ {
+ // Once, and not in a loop. A second collision on a suffixed slug means something other than
+ // "somebody already has this name", and a client that kept trying would be hammering a server
+ // that is refusing for a reason retrying cannot fix.
+ await server.Teams
+ .CreateTeamAsync(
+ new CreateTeamRequest(teamId, name, Disambiguate(slug, teamId), null),
+ cancellationToken)
+ .ConfigureAwait(true);
+ }
+
+ return teamId;
+ }
+
+ ///
+ /// Turns a vault name into a slug the server will accept.
+ ///
+ ///
+ /// 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 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)
+ {
+ var slug = new StringBuilder(name.Length);
+
+ foreach (var character in name.ToLowerInvariant())
+ {
+ if (character is >= 'a' and <= 'z' or >= '0' and <= '9')
+ {
+ slug.Append(character);
+ }
+ else if (slug.Length > 0 && slug[^1] != '-')
+ {
+ slug.Append('-');
+ }
+ }
+
+ var trimmed = slug.ToString().Trim('-');
+
+ if (trimmed.Length > MaximumSlugLength)
+ {
+ trimmed = trimmed[..MaximumSlugLength].TrimEnd('-');
+ }
+
+ return trimmed.Length > 0 ? trimmed : Disambiguate("vault", teamId);
+ }
+
+ /// 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];
+ var room = MaximumSlugLength - suffix.Length;
+
+ return (slug.Length > room ? slug[..room].TrimEnd('-') : slug) + suffix;
+ }
+
+ /// Opens the rename form for the selected vault.
+ [RelayCommand]
+ private void RenameVault()
+ {
+ if (SelectedVault is not { } vault)
+ {
+ return;
+ }
+
+ EditVaultName = vault.Name;
+ IsRenamingVault = true;
+ IsCreatingVault = false;
+ Status = string.Empty;
+ }
+
+ /// 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 created = await server.Teams
- .CreateTeamAsync(
- new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
+ var renamed = await open
+ .RenameVaultAsync(server.Grants, vault.VaultId, name, cancellationToken)
.ConfigureAwait(true);
- IsCreatingTeam = false;
+ IsRenamingVault = 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);
+ await ReloadAsync(cancellationToken, select: vault.VaultId).ConfigureAwait(true);
+ await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true);
- Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
- + "whoever needs it.";
+ // 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 a member, by looking their address up in the directory first.
+ /// Adds somebody to the selected vault, by looking their address up in the directory first.
///
///
///
@@ -560,13 +900,11 @@ internal sealed partial class TeamsViewModel(
[RelayCommand]
private async Task AddMemberAsync(CancellationToken cancellationToken)
{
- if (connection() is not { } server || SelectedTeam is not { } team)
+ 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 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.";
+ Status = WhyNobodyCanBeAdded();
return;
}
@@ -578,54 +916,74 @@ internal sealed partial class TeamsViewModel(
return;
}
- await RunAsync(async () =>
+ await RunAsync(() => AddOrInviteAsync(server, teamId, email, cancellationToken))
+ .ConfigureAwait(true);
+ }
+
+ /// Which of the three reasons the ADD button had nothing to act on.
+ private string WhyNobodyCanBeAdded() => 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.";
+
+ /// The calls behind , once its arguments are known good.
+ private async Task AddOrInviteAsync(
+ IVaultServer server,
+ Guid teamId,
+ string email,
+ CancellationToken cancellationToken)
+ {
+ 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
{
- var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
+ 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;
+ }
- var request = found.Count > 0
- ? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
- : new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
+ InviteEmail = string.Empty;
- TeamMemberSummary member;
+ // Before the reload, so the vault list this screen redraws already shows what they can open. The
+ // sharing is what makes the membership worth anything, and doing it here rather than leaving a
+ // SHARE KEY button to be pressed is the difference between adding a colleague and adding a
+ // colleague who then waits for somebody to notice.
+ var shared = await ShareWithAsync(server, teamId, member, cancellationToken).ConfigureAwait(true);
- 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;
- }
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
- InviteEmail = string.Empty;
-
- // Before the reload, so the vault list this screen redraws already shows what they can
- // open. The sharing is what makes the membership worth anything, and doing it here rather
- // than leaving a SHARE KEY button to be pressed is the difference between adding a
- // colleague and adding a colleague who then waits for somebody to notice.
- var shared = await ShareWithAsync(server, team, member, cancellationToken)
- .ConfigureAwait(true);
-
- await ReloadAsync(cancellationToken).ConfigureAwait(true);
-
- Status = Describe(member, shared);
- }).ConfigureAwait(true);
+ Status = Describe(member, shared);
}
///
- /// Wraps every team vault this machine can open to somebody who has just been added.
+ /// Wraps every vault behind this membership list that this machine can open to somebody just added.
///
/// What to tell the user about the keys, or null when there was nothing to say.
///
///
+ /// The membership list rather than the one vault, and that is not a slip: adding somebody is a
+ /// change to the list, so it is every vault the list carries that they can now fetch. This screen
+ /// makes lists that carry one vault, so the sentence names one — and where it does not, naming them
+ /// all is the honest report of what just happened.
+ ///
+ ///
/// Skipped outright for an account with no identity key: there is nothing to wrap to, and a
/// refusal per vault would bury that one fact under a list. Their row says so, and adding them was
/// still worth doing.
@@ -633,12 +991,12 @@ internal sealed partial class TeamsViewModel(
///
/// A failure here is reported and never thrown. The membership has already been recorded on the
/// server and is not undone by a key that could not be wrapped — so the honest outcome is "they are
- /// in the team, and this vault still needs sharing", which is a state somebody can act on.
+ /// in it, and this vault still needs sharing", which is a state somebody can act on.
///
///
private async Task ShareWithAsync(
IVaultServer server,
- TeamRowViewModel team,
+ Guid teamId,
TeamMemberSummary member,
CancellationToken cancellationToken)
{
@@ -657,7 +1015,7 @@ internal sealed partial class TeamsViewModel(
var reports = await open
.ShareTeamVaultsAsync(
- server.Grants, server.Directory, team.TeamId, member.UserId, cancellationToken)
+ server.Grants, server.Directory, teamId, member.UserId, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
@@ -686,6 +1044,24 @@ internal sealed partial class TeamsViewModel(
+ $"Could not share {Join(reasons)}.";
}
+ /// "1 vault" or "3 vaults", for a sentence that has to read either way.
+ private static string VaultCount(int count) =>
+ string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}");
+
+ /// Joins names into a phrase a person would say, rather than a comma-separated list.
+ private static string Join(IEnumerable parts)
+ {
+ var list = parts.ToList();
+
+ return list.Count switch
+ {
+ 0 => string.Empty,
+ 1 => list[0],
+ 2 => $"{list[0]} and {list[1]}",
+ _ => string.Join(", ", list.Take(list.Count - 1)) + " and " + list[^1],
+ };
+ }
+
///
/// What just happened to the account that was added, and what is still owed them.
///
@@ -708,33 +1084,15 @@ internal sealed partial class TeamsViewModel(
if (!member.IsEnrolled)
{
- return $"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.";
+ return $"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.";
}
return shared is null
- ? $"Added {who} as a member. This machine holds no team vault key to give them — select a "
- + "vault below and press SHARE KEY from one that does."
- : $"Added {who} as a member. {shared}";
- }
-
- /// "1 vault" or "3 vaults", for a sentence that has to read either way.
- private static string VaultCount(int count) =>
- string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}");
-
- /// Joins names into a phrase a person would say, rather than a comma-separated list.
- private static string Join(IEnumerable parts)
- {
- var list = parts.ToList();
-
- return list.Count switch
- {
- 0 => string.Empty,
- 1 => list[0],
- 2 => $"{list[0]} and {list[1]}",
- _ => string.Join(", ", list.Take(list.Count - 1)) + " and " + list[^1],
- };
+ ? $"Added {who}. This machine holds no key to give them — press SHARE KEY from one that "
+ + "does."
+ : $"Added {who}. {shared}";
}
///
@@ -748,11 +1106,6 @@ internal sealed partial class TeamsViewModel(
/// 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.
@@ -760,13 +1113,13 @@ internal sealed partial class TeamsViewModel(
///
private async Task InviteAsync(
IVaultServer server,
- TeamRowViewModel team,
+ Guid teamId,
string email,
CancellationToken cancellationToken)
{
var invitation = await server.Teams
.CreateTeamInvitationAsync(
- team.TeamId,
+ teamId,
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
cancellationToken)
.ConfigureAwait(true);
@@ -776,7 +1129,7 @@ internal sealed partial class TeamsViewModel(
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 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.";
}
@@ -786,7 +1139,7 @@ internal sealed partial class TeamsViewModel(
private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
- || SelectedTeam is not { } team
+ || SelectedVault?.TeamId is not { } teamId
|| SelectedInvitation is not { } invitation)
{
return;
@@ -795,14 +1148,14 @@ internal sealed partial class TeamsViewModel(
await RunAsync(async () =>
{
var revoked = await server.Teams
- .RevokeTeamInvitationAsync(team.TeamId, invitation.InvitationId, cancellationToken)
+ .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 team."
+ + "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);
@@ -822,7 +1175,7 @@ internal sealed partial class TeamsViewModel(
private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken)
{
if (connection() is not { } server
- || SelectedTeam is not { } team
+ || SelectedVault?.TeamId is not { } teamId
|| SelectedMember is not { } member)
{
return;
@@ -843,7 +1196,7 @@ internal sealed partial class TeamsViewModel(
{
var changed = await server.Teams
.ChangeTeamMemberRoleAsync(
- team.TeamId,
+ teamId,
member.UserId,
new ChangeTeamMemberRoleRequest(role),
cancellationToken)
@@ -861,106 +1214,27 @@ internal sealed partial class TeamsViewModel(
}).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()
+ private void HandOver()
{
- if (SelectedTeam is not { } team || SelectedMember is not { } member)
+ if (SelectedVault is not { TeamId: { } teamId } vault || SelectedMember is not { } member)
{
return;
}
if (member.IsSelf)
{
- Status = "You already own this team.";
+ Status = "You already own this vault.";
return;
}
- PendingAction = new TeamActionRequest(
- team.TeamId,
+ PendingAction = new VaultActionRequest(
+ 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.");
+ $"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.
@@ -986,24 +1260,6 @@ internal sealed partial class TeamsViewModel(
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,
@@ -1013,13 +1269,13 @@ internal sealed partial class TeamsViewModel(
await ReloadAsync(cancellationToken).ConfigureAwait(true);
- Status = "Handed over. You are an admin of this team now, and only its new owner can hand "
+ Status = "Handed over. You are an admin of this vault now, and only its new owner can hand "
+ "it on again.";
}).ConfigureAwait(true);
}
///
- /// Removes a member, revoking their grants and rotating the vaults they could read.
+ /// Removes somebody, revoking their grants and rotating the vaults they could read.
///
///
/// The removal and the rotation are separate acts and only the first is the server's. Nothing here
@@ -1030,7 +1286,7 @@ internal sealed partial class TeamsViewModel(
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
- || SelectedTeam is not { } team
+ || SelectedVault?.TeamId is not { } teamId
|| SelectedMember is not { } member)
{
return;
@@ -1046,10 +1302,10 @@ internal sealed partial class TeamsViewModel(
await RunAsync(async () =>
{
await server.Teams
- .RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
+ .RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken)
.ConfigureAwait(true);
- var rotated = await RotateAfterRemovalAsync(server, team, remaining, cancellationToken)
+ var rotated = await RotateAfterRemovalAsync(server, teamId, remaining, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -1064,17 +1320,20 @@ internal sealed partial class TeamsViewModel(
}
///
- /// Rotates every team vault this machine can open, handing each new key to the members who remain.
+ /// Rotates every vault behind this membership list that this machine can open, handing each new key
+ /// to the members who remain.
///
/// What to tell the user about the keys. Never null — something always happened.
///
- /// A vault this machine cannot open is not rotated and is not counted as a failure here: its key
- /// belongs to somebody else, the server has flagged it as owing a rekey, and the vault row says so
- /// until one of them does it.
+ /// The list rather than the one vault, for the reason gives: removing
+ /// somebody is a change to the list, so every vault it carries is one they have just lost. A vault
+ /// this machine cannot open is not rotated and is not counted as a failure: its key belongs to
+ /// somebody else, the server has flagged it as owing a rekey, and its row says so until one of them
+ /// does it.
///
private async Task RotateAfterRemovalAsync(
IVaultServer server,
- TeamRowViewModel team,
+ Guid teamId,
IReadOnlyList remaining,
CancellationToken cancellationToken)
{
@@ -1086,13 +1345,13 @@ internal sealed partial class TeamsViewModel(
var reports = await open
.RekeyTeamVaultsAsync(
- server.Grants, server.Directory, server.Sync, team.TeamId, remaining, cancellationToken)
+ server.Grants, server.Directory, server.Sync, teamId, remaining, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
{
return "Their key grants are withdrawn, so they can fetch nothing more. This machine holds "
- + "no key to any of this team's vaults, so there was nothing here to rotate.";
+ + "no key to any of it, so there was nothing here to rotate.";
}
var rotated = reports.Where(report => report.Rotated).ToList();
@@ -1133,10 +1392,10 @@ internal sealed partial class TeamsViewModel(
+ "key; the rest still hold items under the old one and will be picked up next time. "
+ "Rotate the credentials that mattered either way.";
- // The members who did not get the new key. They are still in the team and can still write, but
- // until somebody wraps it to them they will find the vault stops updating. Distinct by id
- // rather than by name, because two accounts can share a display name and collapsing them would
- // tell somebody one person is owed a key when two are.
+ // The members who did not get the new key. They are still in the vault and can still write, but
+ // until somebody wraps it to them they will find it stops updating. Distinct by id rather than
+ // by name, because two accounts can share a display name and collapsing them would tell
+ // somebody one person is owed a key when two are.
var missed = rotated
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
.Distinct()
@@ -1154,226 +1413,6 @@ internal sealed partial class TeamsViewModel(
private string Name(Guid userId) =>
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
- /// 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()
- {
- IsCreatingVault = false;
- pendingVaultTeamId = null;
- Status = string.Empty;
- }
-
- ///
- /// Creates a vault, and the team to own it where there is not one already.
- ///
- ///
- ///
- /// 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.
- ///
- ///
- /// 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.
- ///
- ///
- [RelayCommand]
- private async Task CreateVaultAsync(CancellationToken cancellationToken)
- {
- if (connection() is not { } server)
- {
- Status = "Offline. Creating a vault needs a connection.";
- return;
- }
-
- if (session() is not { } open)
- {
- Status = "Unlock your keychain first: a vault's key is generated on this machine.";
- return;
- }
-
- var name = NewVaultName.Trim();
-
- if (name.Length == 0)
- {
- Status = "A vault needs a name.";
- return;
- }
-
- await RunAsync(() => AddVaultAsync(server, open, name, cancellationToken)).ConfigureAwait(true);
- }
-
- /// The two calls behind , once its arguments are known good.
- private async Task AddVaultAsync(
- IVaultServer server,
- VaultSession open,
- string name,
- CancellationToken cancellationToken)
- {
- var teamId = newVaultTeamId
- ?? await EnsureTeamForVaultAsync(server, name, cancellationToken).ConfigureAwait(true);
-
- StoredVault vault;
-
- try
- {
- vault = await open
- .CreateTeamVaultAsync(server.Teams, teamId, name, cancellationToken)
- .ConfigureAwait(true);
- }
- catch (Exception exception) when (pendingVaultTeamId is not null
- && 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.";
- return;
- }
-
- IsCreatingVault = false;
- pendingVaultTeamId = null;
- NewVaultName = string.Empty;
-
- await ReloadAsync(cancellationToken, select: teamId).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);
- }
-
- 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.";
- }
-
- /// Makes the team a new vault will belong to, or returns the one a retry already 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
- /// a fact the first one already contains.
- ///
- private async Task EnsureTeamForVaultAsync(
- IVaultServer server,
- string name,
- CancellationToken cancellationToken)
- {
- 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.
- pendingVaultTeamId = teamId;
-
- var slug = Slugify(name, teamId);
-
- try
- {
- await server.Teams
- .CreateTeamAsync(new CreateTeamRequest(teamId, name, slug, null), cancellationToken)
- .ConfigureAwait(true);
- }
- catch (DodoSshApiException exception)
- when (string.Equals(exception.Code, ProblemCodes.TeamSlugTaken, StringComparison.Ordinal))
- {
- // Once, and not in a loop. A second collision on a suffixed slug means something other than
- // "somebody already has this name", and a client that kept trying would be hammering a server
- // that is refusing for a reason retrying cannot fix.
- await server.Teams
- .CreateTeamAsync(
- new CreateTeamRequest(teamId, name, Disambiguate(slug, teamId), null),
- cancellationToken)
- .ConfigureAwait(true);
- }
-
- return teamId;
- }
-
- ///
- /// Turns a vault name into a slug the server will accept.
- ///
- ///
- /// 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.
- ///
- private static string Slugify(string name, Guid teamId)
- {
- var slug = new StringBuilder(name.Length);
-
- foreach (var character in name.ToLowerInvariant())
- {
- if (character is >= 'a' and <= 'z' or >= '0' and <= '9')
- {
- slug.Append(character);
- }
- else if (slug.Length > 0 && slug[^1] != '-')
- {
- slug.Append('-');
- }
- }
-
- var trimmed = slug.ToString().Trim('-');
-
- if (trimmed.Length > MaximumSlugLength)
- {
- trimmed = trimmed[..MaximumSlugLength].TrimEnd('-');
- }
-
- 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.
- private static string Disambiguate(string slug, Guid teamId)
- {
- var suffix = "-" + teamId.ToString("N", CultureInfo.InvariantCulture)[..8];
- var room = MaximumSlugLength - suffix.Length;
-
- 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)
- {
- // 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;
-
- newVaultTeamId = teamId;
- NewVaultName = string.Empty;
- IsCreatingVault = true;
- Status = string.Empty;
-
- OnPropertyChanged(nameof(NewVaultDestination));
- }
-
///
/// Wraps the selected vault's key to the selected member.
///
@@ -1417,6 +1456,8 @@ internal sealed partial class TeamsViewModel(
Status = outcome.Shared
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}{history}"
: $"Did not share '{vault.Name}': {outcome.Message}";
+
+ await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
@@ -1446,14 +1487,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)
{
@@ -1461,15 +1502,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)
@@ -1479,59 +1522,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)
@@ -1541,11 +1595,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)
@@ -1555,51 +1609,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 23f3b6c..e368c1c 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(RekeyVaultRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
index 234be38..b1b291e 100644
--- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
+++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
@@ -679,6 +679,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
@@ -867,6 +874,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!
@@ -960,6 +970,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 b0c7ea6..d41e5f2 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 dd97948..cc6a6c1 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 9d7264a..d40c360 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 ----
///
@@ -1284,6 +1412,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 bc47f94..d2e26f0 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 99bb796..d08ee0e 100644
--- a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.Teams.cs
+++ b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.Teams.cs
@@ -541,6 +541,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/VaultSharingTests.cs
similarity index 50%
rename from tests/DodoSSH.Client.App.Tests/TeamSharingTests.cs
rename to tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
index b0fc77e..3435493 100644
--- a/tests/DodoSSH.Client.App.Tests/TeamSharingTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
@@ -12,11 +12,11 @@ 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.
+/// 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 teams to the server's own tests is that the
+/// 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
@@ -27,8 +27,14 @@ namespace DodoSSH.Client.App.Tests;
/// 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 TeamSharingTests : IAsyncLifetime
+public sealed class VaultSharingTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
@@ -50,7 +56,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
///
public ValueTask InitializeAsync()
{
- directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
+ directory = Path.Combine(Path.GetTempPath(), $"dodossh-vaults-{Guid.CreateVersion7():N}");
var paths = new ClientPaths(directory);
@@ -98,35 +104,64 @@ public sealed class TeamSharingTests : IAsyncLifetime
}
///
- /// The whole point of a team, in one test. Adding somebody wraps every team vault this machine can
- /// open to them, so the status line names what they were given rather than what is still owed —
- /// and the grant is on the server before the add has finished reporting.
+ /// The whole point of a shared vault, in one test. Adding somebody wraps the vault to them, so the
+ /// status line names what they were given rather than what is still owed — and the grant is on the
+ /// server before the add has finished reporting.
///
[Fact]
- public async Task AddingAMember_WrapsEveryTeamVaultThisMachineHoldsToThem()
+ public async Task AddingSomebody_WrapsTheVaultToThemStraightAway()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- await CreateVaultAsync(teams, "Platform secrets");
- teams.Vaults.Count.ShouldBe(1, teams.Status);
+ var vaultId = vaults.SelectedVault!.VaultId;
- var vaultId = teams.Vaults[0].VaultId;
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
-
- teams.Members.Count.ShouldBe(2, teams.Status);
+ vaults.Members.Count.ShouldBe(2, vaults.Status);
server.IssuedGrants.ShouldContainKey(
(vaultId, colleague),
- "adding somebody to a team is what shares its vaults with them");
+ "adding somebody to a vault is what shares it with them");
- teams.Status.ShouldContain("Platform secrets");
+ vaults.Status.ShouldContain("Platform secrets");
+ }
+
+ ///
+ /// The manual path still works and is still worth having: a vault whose key this machine did not
+ /// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
+ /// somebody who already holds the key is the same call, and the server replaces the row rather than
+ /// adding a second one.
+ ///
+ [Fact]
+ public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
+ {
+ 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 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);
}
///
@@ -141,38 +176,37 @@ public sealed class TeamSharingTests : IAsyncLifetime
///
///
[Fact]
- public async Task RemovingAMember_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
+ public async Task RemovingSomebody_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var leaving = server.AddAccount("bob@example.com", "Bob Example");
var staying = server.AddAccount("carol@example.com", "Carol Example");
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
- var vaultId = teams.Vaults[0].VaultId;
+ var vaultId = vaults.SelectedVault!.VaultId;
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
{
- teams.InviteEmail = address;
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = address;
+ await vaults.AddMemberCommand.ExecuteAsync(null);
}
- teams.Members.Count.ShouldBe(3, teams.Status);
+ vaults.Members.Count.ShouldBe(3, vaults.Status);
- teams.SelectedMember = teams.Members.Single(member => member.UserId == leaving);
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == leaving);
- await teams.RemoveMemberCommand.ExecuteAsync(null);
+ await vaults.RemoveMemberCommand.ExecuteAsync(null);
- teams.Status.ShouldContain("Rotated", customMessage: teams.Status);
- teams.Status.ShouldContain("Platform secrets");
+ vaults.Status.ShouldContain("Rotated", customMessage: vaults.Status);
+ vaults.Status.ShouldContain("Platform secrets");
// The last act of a rotation is moving what is already stored onto the new key. Proven by the
// bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
// and says which of the two guarantees the user has ended up with.
- teams.Status.ShouldContain("re-sealed under the new key", customMessage: teams.Status);
+ vaults.Status.ShouldContain("re-sealed under the new key", customMessage: vaults.Status);
// Gone entirely, at every generation. A revocation that left the history behind would leave them
// able to read everything written before they went, from a copy of the ciphertext.
@@ -189,70 +223,35 @@ public sealed class TeamSharingTests : IAsyncLifetime
/// reads as corruption rather than as the missing grant it is.
///
[Fact]
- public async Task AddingAMemberToARotatedVault_HandsThemItsHistoryAsWell()
+ public async Task AddingSomebodyToARotatedVault_HandsThemItsHistoryAsWell()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var first = server.AddAccount("bob@example.com", "Bob Example");
var second = server.AddAccount("carol@example.com", "Carol Example");
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
- var vaultId = teams.Vaults[0].VaultId;
+ var vaultId = vaults.SelectedVault!.VaultId;
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
// with a history rather than one that has only ever had a single key.
- teams.SelectedMember = teams.Members.Single(member => member.UserId == first);
- await teams.RemoveMemberCommand.ExecuteAsync(null);
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
+ await vaults.RemoveMemberCommand.ExecuteAsync(null);
- teams.InviteEmail = "carol@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "carol@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], teams.Status);
- }
-
- ///
- /// The manual path still works and is still worth having: a vault whose key this machine did not
- /// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
- /// somebody who already holds the key is the same call, and the server replaces the row rather than
- /// adding a second one.
- ///
- [Fact]
- public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
- {
- 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);
-
- 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);
+ server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
}
///
///
- /// The test this whole design exists for. A server that wants to read a team's vault only has to
+ /// 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.
@@ -267,33 +266,31 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
// Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
// that corrupted the log afterwards would be asserting about the second one only.
server.CorruptKeyLog = true;
- teams.InviteEmail = "mallory@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "mallory@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- var vaultId = teams.Vaults[0].VaultId;
+ var vaultId = vaults.SelectedVault!.VaultId;
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
- teams.Status.ShouldContain("Could not share");
- teams.Status.ShouldContain("key log");
+ vaults.Status.ShouldContain("Could not share");
+ vaults.Status.ShouldContain("key log");
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
- teams.SelectedVault = teams.Vaults[0];
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
- await teams.ShareVaultCommand.ExecuteAsync(null);
+ await vaults.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
- teams.Status.ShouldContain("Did not share");
- teams.Status.ShouldContain("key log");
+ vaults.Status.ShouldContain("Did not share");
+ vaults.Status.ShouldContain("key log");
}
///
@@ -302,16 +299,15 @@ public sealed class TeamSharingTests : IAsyncLifetime
/// around bookkeeping.
///
[Fact]
- public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
+ public async Task AVaultCreatedHere_IsImmediatelyReadableAndWritable()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
- var vaultId = teams.Vaults[0].VaultId;
+ var vaultId = vaults.SelectedVault!.VaultId;
var session = shell.Vault!.Session;
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
@@ -324,47 +320,183 @@ public sealed class TeamSharingTests : IAsyncLifetime
}
///
- /// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
+ /// 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 AHostFiledIntoATeamVault_StaysThere()
+ public async Task AHostFiledIntoASharedVault_StaysThere()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
- var teamVaultId = teams.Vaults[0].VaultId;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
- vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
+ 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 team's vault.
+ // 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 != teamVaultId);
+ 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(teamVaultId);
+ 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
- /// team vault has to stay there, because hosts are read across every readable vault and so come back.
+ /// 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.
@@ -374,12 +506,11 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
- var teamVaultId = teams.Vaults[0].VaultId;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
var vault = shell.Vault!;
await vault.LoadAsync(Token);
@@ -387,7 +518,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
vault.SelectedTargetVault = vault.TargetVaults.Single(
- choice => choice.VaultId == teamVaultId);
+ choice => choice.VaultId == sharedVaultId);
vault.NewTagCommand.Execute(null);
vault.TagEditorLabel = "eu-west-1";
@@ -398,42 +529,30 @@ public sealed class TeamSharingTests : IAsyncLifetime
}
///
- /// 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.
+ /// The screen's answer to "who can actually open this". Asserted after somebody has been added
+ /// rather than before, because an empty list proves nothing about whether the call was made.
///
[Fact]
- public async Task SelectingATeamVault_ListsWhoHoldsAKeyToIt()
+ public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
- teams.SelectedVault = teams.Vaults[0];
+ // Two, and the creator is the other: their own self-grant is what makes a vault they just made
+ // readable at all, so a list that left it out would show the one person who can certainly open
+ // this vault as somebody who cannot.
+ vaults.Grants.Count.ShouldBe(2, vaults.Status);
- await teams.ShareVaultCommand.ExecuteAsync(null);
+ var holder = vaults.Grants.Single(row => row.UserId == colleague);
- // 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];
-
- // Two, and the second one matters: the creator's own grant is recorded when the vault is made,
- // so a list that showed only the people it was shared with would be describing a vault its
- // owner cannot open.
- teams.Grants.Count.ShouldBe(2, teams.Status);
-
- var holder = teams.Grants.Single(row => row.UserId == colleague);
-
- holder.IsLive.ShouldBeTrue(teams.Status);
+ holder.IsLive.ShouldBeTrue(vaults.Status);
holder.State.ShouldBe("holds a key");
}
@@ -447,20 +566,20 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
- await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
+ await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
- teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
- teams.Status.ShouldContain("does not withdraw a vault key");
+ vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
+ vaults.Status.ShouldContain("does not withdraw a vault key");
}
///
@@ -473,126 +592,91 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
- await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
+ await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
- teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
- teams.Status.ShouldContain("HAND OVER");
+ 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 team owned
+ /// 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 id is carried — and carrying it on the selection
+ /// 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 HandingOverATeam_MakesThemTheOwnerAndTheCallerAnAdmin()
+ public async Task HandingOverAVault_MakesThemTheOwnerAndTheCallerAnAdmin()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "bob@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "bob@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
+ vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
- teams.TransferOwnershipCommand.Execute(null);
+ vaults.HandOverCommand.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");
+ 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 teams.ConfirmActionCommand.ExecuteAsync(null);
+ await vaults.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();
+ vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
+ vaults.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
+ vaults.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.
+ /// 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 ArchivingATeamThatOwnsAVault_IsRefusedAndSaysWhy()
+ public async Task RenamingAVault_ReachesTheKeychainScreensPickerToo()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await CreateTeamAsync(teams, "Platform", "platform");
- await CreateVaultAsync(teams, "Platform secrets");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.ArchiveTeamCommand.Execute(null);
- await teams.ConfirmActionCommand.ExecuteAsync(null);
+ var vaultId = vaults.SelectedVault!.VaultId;
- teams.Teams.ShouldContain(team => team.Slug == "platform");
- teams.Status.ShouldContain("holding a key");
- }
+ vaults.RenameVaultCommand.Execute(null);
+ vaults.EditVaultName = "Platform";
- ///
- /// 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();
+ await vaults.SaveVaultNameCommand.ExecuteAsync(null);
- var teams = shell.Teams;
+ vaults.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
+ vaults.Status.ShouldContain("re-encrypted");
- await CreateTeamAsync(teams, "Platform", "platform");
+ await shell.Vault!.LoadAsync(Token);
- 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'");
+ shell.Vault.TargetVaults
+ .Single(choice => choice.VaultId == vaultId)
+ .Name
+ .ShouldBe("Platform");
}
///
@@ -611,22 +695,22 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "newcomer@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "newcomer@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
+ vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
- var invitation = teams.Invitations.ShouldHaveSingleItem();
+ var invitation = vaults.Invitations.ShouldHaveSingleItem();
invitation.Email.ShouldBe("newcomer@example.com");
invitation.IsPending.ShouldBeTrue();
invitation.State.ShouldContain("Nothing was sent");
- teams.Status.ShouldContain("cannot send mail");
+ vaults.Status.ShouldContain("cannot send mail");
}
///
@@ -634,9 +718,9 @@ public sealed class TeamSharingTests : IAsyncLifetime
/// 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.
+ /// 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
@@ -648,27 +732,27 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "carol@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "carol@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
+ vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
- teams.Members.Count.ShouldBe(2, teams.Status);
+ vaults.Members.Count.ShouldBe(2, vaults.Status);
- var member = teams.Members.Single(row => row.UserId == colleague);
+ 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");
- teams.Status.ShouldContain("Added");
- teams.Status.ShouldContain("no key yet");
+ vaults.Status.ShouldContain("Added");
+ vaults.Status.ShouldContain("no key yet");
}
///
@@ -681,15 +765,15 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "stranger@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "stranger@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
- teams.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
+ vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
+ vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
}
///
@@ -702,28 +786,28 @@ public sealed class TeamSharingTests : IAsyncLifetime
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await CreateTeamAsync(teams, "Platform", "platform");
+ await CreateVaultAsync(vaults, "Platform secrets");
- teams.InviteEmail = "newcomer@example.com";
- await teams.AddMemberCommand.ExecuteAsync(null);
+ vaults.InviteEmail = "newcomer@example.com";
+ await vaults.AddMemberCommand.ExecuteAsync(null);
- teams.SelectedInvitation = teams.Invitations.ShouldHaveSingleItem();
+ vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
- await teams.RevokeInvitationCommand.ExecuteAsync(null);
+ await vaults.RevokeInvitationCommand.ExecuteAsync(null);
- teams.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
- teams.Status.ShouldContain("Withdrew the invitation");
+ vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
+ vaults.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.
+ /// 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
@@ -732,61 +816,50 @@ public sealed class TeamSharingTests : IAsyncLifetime
///
///
[Fact]
- public async Task CreatingATeam_ReadsItsMembersOnce()
+ public async Task CreatingAVault_ReadsItsMembersOnce()
{
await UnlockedAsync();
- var teams = shell.Teams;
+ var vaults = shell.Vaults;
- await teams.LoadAsync(Token);
+ await vaults.LoadAsync(Token);
- teams.NewTeamCommand.Execute(null);
- teams.NewTeamName = "Platform";
- teams.NewTeamSlug = "platform";
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = "Platform secrets";
var gate = new TaskCompletionSource();
server.MemberReadGate = gate;
- var create = teams.CreateTeamCommand.ExecuteAsync(null);
+ 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 team's members once");
+ server.MemberReads.ShouldBe(1, "a reload reads the selected vault's members once");
gate.SetResult();
await create;
- teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
+ vaults.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.
+ /// 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 async Task CreateVaultAsync(TeamsViewModel teams, string name)
+ private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{
- teams.NewVaultCommand.Execute(null);
- teams.NewVaultName = name;
+ await vaults.LoadAsync(Token);
- await teams.CreateVaultCommand.ExecuteAsync(null);
+ vaults.NewVaultCommand.Execute(null);
+ vaults.NewVaultName = name;
- teams.IsCreatingVault.ShouldBeFalse(teams.Status);
- }
+ await vaults.CreateVaultCommand.ExecuteAsync(null);
- 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);
+ vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
+ vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
+ vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
}
///
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!;