Make the vault the thing you share, and ask a host which one it lives in

The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.

Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.

The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.

Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — 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, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.

What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on 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 — and 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 cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.

The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.

That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.

Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.

1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
This commit is contained in:
2026-08-04 12:22:29 +02:00
parent 6ae1912c34
commit 8707629a6c
47 changed files with 3204 additions and 2313 deletions
+71 -58
View File
@@ -226,18 +226,30 @@ What is not here: transferring a directory, dragging between the panes, and rout
bastion — the last needs jump hosts the connection layer has not got. All three are in 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). [`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 **VAULTS** in the nav rail lists every vault you can see, makes new ones, adds and invites people to one,
archives one. One distinction runs through the whole screen and is worth having before you use it. 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 **A vault is the thing you make, and the group of people is behind it.** The server authorises through a
the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in *team*`VaultAccessService` resolves a shared vault through `team_membership`, and every membership call
their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's names a team id — but nothing asks you to make one: naming a vault makes the membership list that carries
public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has it, named after the vault and owned by you. So the thing you came to share is the thing you create, and
the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed "which team is this in" stops being a question you need an answer to before you can share four servers with
over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so two colleagues. Renaming the vault renames that membership list with it, as long as it carries nothing else.
rather than implying the server handed anything out.
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 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. from the first entry, and refuses unless the key the directory just offered appears in that log unchanged.
@@ -246,14 +258,14 @@ published in a log every other client also reads. **It does not prove the key is
Compare the fingerprint with them over something this server does not carry; that is the only step that 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. 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 **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 and it does whichever of the two applies, because which one applies is a fact about the server's account
account table rather than about what you are trying to do. If the directory knows the address, that account table rather than about what you are trying to do. If the directory knows the address, that account is added
is added straight away. If it does not, the address is invited instead, and the status line says which straight away. If it does not, the address is invited instead, and the status line says which happened,
happened, because the difference decides what you do next. 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 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 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 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 invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a
@@ -265,9 +277,9 @@ you the public key you are about to verify and wrap a vault to, and an invitatio
there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful 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. 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 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, 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. rather than waiting for a first sign-in that has already happened.
@@ -275,39 +287,34 @@ rather than waiting for a first sign-in that has already happened.
to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or 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 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 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 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 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 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. 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 **Ownership is sole, and handing a vault over is one act.** Transferring names an existing active member:
become owner and you become an admin, in a single transaction. Not two role changes — promoting first 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 leaves it owned twice, demoting first leaves it owned by nobody, and there is nobody with the authority to
authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep your vault
your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody key grants; removing you would revoke them and flag the vault for rekey, and somebody handing a vault over
handing over a team is usually staying in it. 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 Five limits, stated rather than discovered:
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.
Four limits, stated rather than discovered: - **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
- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey, archiving it would take the vault away from everybody holding a key, including you, quietly and all at
and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials once. The screen says so where you would otherwise go looking for the button.
that matter — that is the actual remediation, and it is why there is no button labelled anything stronger. - **Removing a member is not retroactive.** It revokes their grants and flags the vault for rekey, and
blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials that
matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
- **The rekey is flagged, never performed.** See the milestone note above. - **The rekey is flagged, never performed.** See the milestone note above.
- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used - **Host key trust stays in your personal vault.** A pin approved for a shared vault's host is recorded and
from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will used from your own vault, not the shared one, so a colleague cannot pre-approve a fingerprint that your
then trust silently for a host you defined. The cost is that each member approves a team host's key once client will then trust silently for a host you defined. The cost is that each member approves a shared
on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what host's key once on each of their machines. Shared vaults' pins are still *listed* on the Pins screen, so
has been trusted. 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 - **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 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 asked at — whether somebody is still using this deployment — and writing it on every request would put an
@@ -315,9 +322,14 @@ Four limits, stated rather than discovered:
as roughly-when rather than to the minute, because showing it to the minute would be reading a precision 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. 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 Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two
vault is visible to everybody in that team and moving it back means deleting and retyping. 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 ### The Android head
@@ -329,7 +341,7 @@ fit 360dp.
Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the 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, 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 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 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 tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see
@@ -369,9 +381,9 @@ confirmation — without offering to change it. What this head does make, it mak
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's 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 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 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 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 put in a have anticipated: an invitation is claimed by signing in, so somebody being told they have been added to a
team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on 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. 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 **Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
@@ -570,15 +582,16 @@ keychain plus a terminal — and the spike that gates all of it.
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from 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 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. ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
- **M3 — teams**, sharing, ACLs. *Done, except rekey.* Teams with roles, a public-key directory, the - **M3 — shared vaults**, sharing, ACLs. *Done, except rekey.* Membership with roles, a public-key
append-only key log served for clients to verify against, team-owned vaults, and vault key grants directory, the append-only key log served for clients to verify against, shared vaults, and vault key
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team 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 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, every vault it holds a key for, and a real VAULTS screen replaces the placeholder. The screen is
handed to another member, and archived once it owns no vaults; a member row carries when that account was vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the
last here; and an address with no account on this deployment can be invited, joining the moment somebody team is behind the vault rather than a thing anybody has to create first. A vault can be renamed and
signs in with it. See handed to another member; a member row carries when that account was last here; and an address with no
[Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth 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 knowing before you rely on it; the reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md). [ADR 0009](docs/adr/0009-team-access-model.md).
+1 -1
View File
@@ -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 | | 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.)* | | 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. | | 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. | | 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. | | 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. |
+30
View File
@@ -115,6 +115,36 @@ Three decisions inside it belong here, because each had a more convenient altern
an address already belonging to a member of *this* team is refused, and that is a fact the caller can 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. 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 ## Consequences
The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds
+29 -17
View File
@@ -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`; > 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. > 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, > 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 > 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 > 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. > they cannot see. It runs over the same view model the desktop screen drives, like the other four.
> >
> | v2 element | What ships instead | > | 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. | > | `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. | > | `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. | > | 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. | > | **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. | > | 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. | > | 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 > ## 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. | > | **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. | > | **+ 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 **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. | > | **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. | > | **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. | > | 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 **~~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 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 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 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. 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 | | 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. | | `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. | | `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". | | `⌘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, 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 | | 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. | | `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. | | "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 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. | | `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. | | `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. | | `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. | | `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. | | `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. | | 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. | | — | — | — | **`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 **Built in M3, and reshaped since.** The screen ships: a vault list, a members table with a real
invitations standing against addresses that have no account here yet, the team's vaults, and the two buttons last-active column, the invitations standing against addresses that have no account here yet, who holds a
the whole design was really about — add a member, and share a vault key. A team can also be renamed, handed key, and the two buttons the whole design was really about — add somebody, and share a vault key. A vault
to another member, and archived, the last only while it owns no vaults. What follows is what it still does can also be renamed and handed to another member.
not do, and one thing this document got wrong before it was built.
**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 **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 `VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. That is now the one
+47 -43
View File
@@ -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** ### 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. 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 **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 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 vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
@@ -1147,16 +1147,16 @@ follows is about what happens the first time it does.
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care** ### 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. 1. Sign in as `alice`, make a vault on the VAULTS screen, and select it.
2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** 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. 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. 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. 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. 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 5. **Pass:** the vault is in Bob's list the first time he looks, at Member, with nothing further pressed
either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing, on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
and Bob is now in the members table. 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 waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
opens. opens.
@@ -1171,15 +1171,15 @@ reached that machine by a route this architecture says does not exist.
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in. 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. 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 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 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 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. 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 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 **access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name. under some other name.
@@ -1188,27 +1188,27 @@ under some other name.
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address. 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 **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 an ordinary account in no shared vault. Revoking one that has *already* been accepted answers that there
nothing to withdraw. was nothing to withdraw.
**Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An **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 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. should quietly do.
### 12.4 An address already in the team is refused; an address that merely has an account is not ### 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 **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 **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. 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 **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 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** ### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
@@ -1227,49 +1227,53 @@ impossible to offer honestly before.
As the owner, transfer ownership to another active member, then read both rows. 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 **Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grant is intact
and the team's vaults have not come back flagged for rekey. Then try to transfer to somebody who is not a and the vault has not come back flagged for rekey. Then try to hand it to somebody who is not a member, and
member, and to yourself. to yourself.
**Pass:** both refused, and the message says which. **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 **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 either one leaves a vault that no client can administer back into shape. If your grant was revoked or the
vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them. 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 **Pass:** there is none, and the VAULTS screen says why in a sentence: nothing in this product removes a
in this product. The team is still in everybody's list afterwards and its vaults still open. 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 **Failure means:** a delete that worked would take the vault out of the list of everybody holding a key —
everybody holding a key — including the person who pressed it, quietly, and with nothing in the product including the person who pressed it, quietly, and with nothing in the product able to put them back. A
able to put them back. 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 **Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain
joins nothing. A new team can be created under the archived one's slug. 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 **Failure means:** a name that moved in one place and not another is the shape this rename is most likely to
invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in fail in, because several screens read it separately from a cached vault row. A vault that stops opening
nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason after a rename would be far worse, and means that cached row was replaced by the server's answer rather
an archived team is only restorable by an operator who checks that first. 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 **Pass:** it is not drawn. Adding a host to that vault still works, which is what makes this a boundary
unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed. rather than a broken role.
**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that **Failure means:** a name is what everybody in the vault sees it called, so a member renaming it out from
archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented under the people who share it is an administrative act reached without the role for it. The server refuses
data — `team` has no updated-at column, so there is nothing behind it. it too — this is the interface not offering what the server would turn down.
--- ---
+10
View File
@@ -50,6 +50,16 @@ internal static partial class TeamLog
internal static partial void TeamVaultCreated( internal static partial void TeamVaultCreated(
ILogger logger, Guid vaultId, Guid teamId, Guid actorId); ILogger logger, Guid vaultId, Guid teamId, Guid actorId);
/// <remarks>
/// 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.
/// </remarks>
[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( [LoggerMessage(
EventId = 2106, EventId = 2106,
Level = LogLevel.Information, Level = LogLevel.Information,
@@ -50,6 +50,78 @@ internal sealed class ListVaultGrantsEndpoint(
} }
} }
/// <summary>Renames a vault.</summary>
/// <remarks>
/// <para>
/// Admin rather than Write, and the line is the one <c>UpdateTeamEndpoint</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal sealed class RenameVaultEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
VaultGrantService grants)
: Endpoint<UpdateVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
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"));
}
/// <inheritdoc />
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
UpdateVaultRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var access = await vaultAccess
.ResolveAsync(user.Id, Route<Guid>("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);
}
}
}
/// <summary>Wraps this vault's key to another member.</summary> /// <summary>Wraps this vault's key to another member.</summary>
/// <remarks> /// <remarks>
/// The one call in this API whose body the server can neither produce nor check. It stores a sealed /// The one call in this API whose body the server can neither produce nor check. It stores a sealed
@@ -151,6 +151,71 @@ internal sealed class VaultGrantService(
: name; : name;
} }
/// <summary>
/// Renames a vault, and the team behind it where that team exists to carry this vault alone.
/// </summary>
/// <remarks>
/// <para>
/// <b>The team is renamed with it, and only when it owns nothing else.</b> 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.
/// </para>
/// <para>
/// The slug never moves, exactly as <c>UpdateTeamRequest</c> records: it is unique only among live
/// teams, so a rename that changed it could take one an archived team is still holding.
/// </para>
/// </remarks>
internal async Task<VaultSummary> 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);
}
/// <summary>Lists who can open a vault.</summary> /// <summary>Lists who can open a vault.</summary>
internal async Task<VaultGrantsResponse> ListGrantsAsync( internal async Task<VaultGrantsResponse> ListGrantsAsync(
Vault vault, Vault vault,
@@ -56,6 +56,7 @@ internal static class EndpointRegistration
typeof(CreateTeamInvitationEndpoint), typeof(CreateTeamInvitationEndpoint),
typeof(RevokeTeamInvitationEndpoint), typeof(RevokeTeamInvitationEndpoint),
typeof(CreateTeamVaultEndpoint), typeof(CreateTeamVaultEndpoint),
typeof(RenameVaultEndpoint),
typeof(ListVaultGrantsEndpoint), typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint), typeof(IssueVaultGrantEndpoint),
typeof(RevokeVaultGrantEndpoint), typeof(RevokeVaultGrantEndpoint),
@@ -260,6 +260,28 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<!--
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
one vault that can be written to, exactly as on the desktop — an existing host's vault cannot
change, because the two are encrypted under different keys and moving an item is a delete and a
retype. Above GROUP rather than below it because it decides what GROUP can offer: a group is an
item in one vault, so choosing a vault refills that list with that vault's groups.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}"
HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="A host in a shared vault is readable by everybody holding that vault's key, and it cannot be moved out afterwards." />
</StackPanel>
<TextBlock Classes="label" Text="GROUP" Margin="0,4,0,0" /> <TextBlock Classes="label" Text="GROUP" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding EditorGroupChoices}" <ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}" SelectedItem="{Binding EditorSelectedGroup}"
@@ -48,7 +48,7 @@
product surface leaked the implementation's word. product surface leaked the implementation's word.
--> -->
<Button Classes="row" Command="{Binding ShowScreenCommand}" <Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}"> CommandParameter="{x:Static vm:ShellScreen.Keychain}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14" <TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" /> Width="22" VerticalAlignment="Center" />
@@ -128,22 +128,22 @@
</Button> </Button>
<!-- <!--
Teams, which the v2 design has no row for — it is a shipped screen the design had no slot for Vaults, which the v2 design has no row for — it is a shipped screen the design had no slot for
rather than a drawn one with nothing behind it. It is on the phone because an invitation is rather than a drawn one with nothing behind it. It is on the phone because an invitation is
claimed by signing in, and somebody being invited is at least as likely to be holding a phone. claimed by signing in, and somebody being invited is at least as likely to be holding a phone.
◎ rather than a glyph of its own. The desktop rail already draws teams with it, and two heads ◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and
giving one destination two marks is how a user learns the wrong one. two heads giving one destination two marks is how a user learns the wrong one.
--> -->
<Button Classes="row" Command="{Binding ShowScreenCommand}" <Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Team}"> CommandParameter="{x:Static vm:ShellScreen.Vaults}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14" <TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" /> Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Teams" /> <TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Vaults" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" <TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Who shares a keychain with you, and who holds its key." /> Text="Your vaults, who is in each one, and who holds its key." />
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15" <TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
@@ -121,7 +121,7 @@
<Panel IsVisible="{Binding IsHostsShowing}"> <Panel IsVisible="{Binding IsHostsShowing}">
<views:HostsScreen DataContext="{Binding Vault}" /> <views:HostsScreen DataContext="{Binding Vault}" />
</Panel> </Panel>
<Panel IsVisible="{Binding IsVaultShowing}"> <Panel IsVisible="{Binding IsKeychainShowing}">
<views:KeychainScreen DataContext="{Binding Vault}" /> <views:KeychainScreen DataContext="{Binding Vault}" />
</Panel> </Panel>
<!-- <!--
@@ -153,11 +153,15 @@
<!-- <!--
The sixth destination behind MORE, and the one v2 never drew — see the comment on the screen The sixth destination behind MORE, and the one v2 never drew — see the comment on the screen
itself. Wrapped like its neighbours even though Teams is not nullable: the reason for the wrapper itself. Wrapped like its neighbours even though Vaults is not nullable: the reason for the
is the data context, not the null. IsTeamShowing is the shell's and Teams is not the shell. wrapper is the data context, not the null. IsVaultsShowing is the shell's and Vaults is not the
shell.
Vaults, not Vault: this one is the vaults themselves and the people in them, where the other is
one vault's contents and is what the hosts and keychain screens draw.
--> -->
<Panel IsVisible="{Binding IsTeamShowing}"> <Panel IsVisible="{Binding IsVaultsShowing}">
<views:TeamsScreen DataContext="{Binding Teams}" /> <views:VaultsScreen DataContext="{Binding Vaults}" />
</Panel> </Panel>
<!-- <!--
@@ -347,8 +347,8 @@ internal sealed partial class PhoneShell : UserControl
switch (current.Screen) switch (current.Screen)
{ {
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Team or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults
or ShellScreen.Vault: or ShellScreen.Keychain:
current.ShowScreenCommand.Execute(ShellScreen.More); current.ShowScreenCommand.Execute(ShellScreen.More);
e.Handled = true; e.Handled = true;
break; break;
@@ -1,367 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.TeamsScreen"
x:DataType="vm:TeamsViewModel"
Background="{StaticResource Canvas}">
<!--
TEAMS, under MORE — and the one screen behind that hub the v2 phone design never drew.
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because 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 server has just put somebody into, visible only
on a head they may never have installed, is a membership they cannot see.
That argument is also why the invited list is drawn here and not treated as an administrator's detail:
the people on it are the ones who cannot yet see the team, and the row says out loud that no mail was
sent.
So there is no mock-up to depart from. What this departs from instead is the desktop screen over the
same view model, and every difference below is a phone difference rather than a second opinion.
**The desktop's two columns are one.** A 268-pixel team list beside a members-and-vaults table does
not exist at 360dp, so the three lists stack in one scrolling column with the teams at the top. That
is the same thing HOSTS does with the desktop's sidebar and its connect column, and for the same
reason.
**Nothing scrolls inside anything.** The desktop caps its members and vaults lists at 240 and 200
pixels so the two can sit above each other in one pane. Here every list is sized to its content and
the screen's own ScrollViewer does all of the scrolling: a list that scrolls inside a page is a region
a thumb has to find the edges of, and three of them on one screen is three ways to get stuck.
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
machine that already holds the key, and this phone is one — so a teams screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE MEMBER,
WITHDRAW KEY and REVOKE INVITATION are the other half of that, and each of them acts on the first
press: the view model's armed-confirmation state covers archiving a team and handing one over, and
those three are not armed by it. The desktop guards them with a tooltip instead, which is a control a
touch screen has no way to show. An irreversible revocation under a thumb with its explanation missing
is the wrong trade, so all three stay on the desktop — where the sentence beside them is visible.
Archiving and hand-over are not drawn either, for a plainer reason: they decide whether a team goes on
existing and who controls it, which is not a thing to do while walking.
**ADD MEMBER is not drawn either**, and it is the operation this screen least needs. It is an address
typed into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding
somebody did *not* do — and since invitations arrived the ordinary way into a team is one the server
claims at sign-in, which is what put this screen on the phone at all. Creating a team is here, because
a team is where those invitations are sent from and it is two short fields.
**The key-holder list under a vault is not drawn.** It is a fourth list, it belongs to the selected
vault rather than to the team, and the view model publishes no flag saying whether it has anything in
it — so a heading for it would sit over nothing whenever nobody holds a key, which is exactly the
empty state this head insists comes from the view model rather than from markup. What the phone can
answer about a vault is on the vault's own row: whether *this* machine can open it.
**↻ and `+` both, because this screen has more reason to re-read than any other.** Nothing here is
cached — it is all read from the server on arrival and again at the end of every command — so the one
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
from a colleague's desktop, or a team they have this moment been invited into. On the desktop the
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Team),
which would set Screen to the value it already holds, raise nothing and reload nothing.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Teams" Margin="4,0" />
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="icon accent" Content="+" Command="{Binding NewTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
<!-- ============ a new team ============ -->
<!--
Above the list rather than in place of it, which is the opposite of what the host and snippet
editors do — and the difference is what the form is about. Those two edit a row that is on screen,
so a card stacked over the list hides the thing being changed. This one is about a team that does
not exist yet, and the teams that do are exactly the useful thing to be able to see while naming it:
the slug has to be unique on this server, and the near misses are right underneath.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding IsCreatingTeam}">
<StackPanel Spacing="10">
<TextBlock Classes="label" Text="NEW TEAM" />
<TextBox Classes="field" Text="{Binding NewTeamName}" PlaceholderText="name" />
<TextBox Classes="field" Text="{Binding NewTeamSlug}" PlaceholderText="slug-for-urls" />
<TextBlock Classes="body"
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server. It is fixed once the team exists — a team can be renamed and its slug cannot." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
Command="{Binding CreateTeamCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelNewTeamCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
Status, and it is the empty state as well: the view model writes "you are not in a team yet" into
the same property it writes an offline notice and every command's outcome into. A literal here would
be a second voice saying the same thing slightly differently.
-->
<TextBlock Grid.Row="2" Classes="detail" Margin="18,2,18,6" TextWrapping="Wrap"
Text="{Binding Status}"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- ============ the column ============ -->
<ScrollViewer Grid.Row="3">
<StackPanel Margin="0,0,0,18">
<TextBlock Classes="section" Text="TEAMS" Margin="18,4,18,4" />
<!--
Rows as cards, filled when chosen, which is what HOSTS settled on in v2 and what the radius
ladder calls a card: one item, one rule, one thing you act on. The fill is on the item rather
than on a Border inside it so the rounding the theme draws for selection is the row's own.
-->
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
IsVisible="{Binding HasTeams}" Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Detail}" />
</StackPanel>
<!-- The caller's own role in this team, which is what says why some of it is read-only. -->
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- ============ the chosen team ============ -->
<StackPanel IsVisible="{Binding HasSelection}">
<TextBlock Classes="section" Text="MEMBERS" Margin="18,18,18,4" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<!--
◆ The one fact on this row that decides whether the button at the foot of the screen
can do anything: an account with no published identity key has nothing for a vault
key to be wrapped to. One sentence, from the view model, painted twice rather than
written twice — the warning colour is the whole of the difference, and a converter
for it would hide that the two are the same string.
The published case is quiet rather than green. Green on this head means a shell is
open right now, and a published key is a durable fact about an account — borrowing
the status colour for it would be the second meaning that makes the first
unreadable. Only the missing key is coloured, because only it needs answering.
-->
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding KeyState}"
IsVisible="{Binding Member.IsEnrolled}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding KeyState}"
IsVisible="{Binding !Member.IsEnrolled}" />
<!--
A date to the day, or that they have never been here at all. The view model writes
both, and neither is a guess: the server records the account's last authenticated
request at most once an hour, which is what makes a day the honest unit.
-->
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextFaint}"
Text="{Binding LastActive}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a team is what lets the server hand somebody this team's vaults. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
<!-- ============ ◆ who has been asked and has not arrived ============ -->
<!--
The section this screen exists for, and the one the desktop had nothing to draw until
invitations were built. Read-only here: withdrawing one is a control that acts on the first
press, which is the line drawn at the top of this file.
So these are cards rather than the flat rows above them, and the shape is the difference: a row
that fills when you touch it is one of several you are choosing between, and there is nothing
to choose here. An ItemsControl rather than a ListBox for the same reason — a list with a
selection nothing reads would be a control offering something it cannot do.
Gated on the view model's own count rather than left to stand over an empty list, because a
team with nobody outstanding is the ordinary case and a permanent empty heading would make it
look like a section that had failed to load.
The waiting row carries the whole mechanism in its own sentence — no mail was sent, and they
join when they first sign in here. That is the sentence somebody has to read, because every
other product's version of this word means an email is on its way.
-->
<StackPanel IsVisible="{Binding HasInvitations}">
<TextBlock Classes="section" Text="INVITED" Margin="18,18,18,4" />
<ItemsControl ItemsSource="{Binding Invitations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel">
<Border Classes="card" Margin="12,3">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding !IsPending}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding IsPending}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Classes="section" Text="VAULTS" Margin="18,18,18,4" />
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
<StackPanel Spacing="3" MinHeight="54" Margin="14,11" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<!--
Whether *this* phone can open it, which is a property of its keyring rather than
anything the server could answer. Painted the same two ways as the member's key
state above, because it is the same question asked from the other end.
-->
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding IsReadable}" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding !IsReadable}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="A vault listed here that this phone has no key to stays listed and stays shut. That is the ordinary case rather than a fault: somebody has been added to the team and nobody has wrapped the key to them yet." />
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ============ ◆ giving somebody the key ============ -->
<!--
Raised over the column when both halves of the act have been chosen, as HOSTS raises its connect bar
and SNIPPETS its insert bar, and for the reason written there: there is no second column to put it
in, so it names what it will do rather than relying on a selection being visible beside the button.
Two wrappers rather than one condition. Sharing needs a member *and* a vault, and a binding cannot
say `SelectedMember is not null && SelectedVault is not null` without a converter that does not
exist — the log screen makes the same trade for the same reason. It also gets the halves in the
right order: choosing who comes first, and until a vault is chosen there is nothing to offer them.
-->
<Panel Grid.Row="4" IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}">
<Border IsVisible="{Binding SelectedVault, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="9">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="THE KEY TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedVault.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="FOR" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedMember.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<Button Classes="primary" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
<!--
The sentence the desktop hangs off a tooltip, which a phone cannot show — so it is body text
under the button, where it is read before the tap rather than after it. It is not decoration:
the key-log check proves this server has been consistent with itself and nothing more.
-->
<TextBlock Classes="body"
Text="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. That proves the server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
</Border>
</Panel>
</Grid>
</UserControl>
@@ -1,10 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Teams, under MORE — who is in one, and which of its vaults this phone can open.</summary>
internal sealed partial class TeamsScreen : UserControl
{
public TeamsScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -0,0 +1,347 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.VaultsScreen"
x:DataType="vm:VaultsViewModel"
Background="{StaticResource Canvas}">
<!--
VAULTS, under MORE — and the one screen behind that hub the v2 phone design never drew.
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because 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 vault the server has just put somebody into, visible only
on a head they may never have installed, is a membership they cannot see.
That argument is also why the invited list is drawn here and not treated as an administrator's detail:
the people on it are the ones who cannot yet see the vault, and the row says out loud that no mail was
sent.
── IT LISTED TEAMS UNTIL THE SCREEN STOPPED BEING ABOUT THEM. ───────────────────────────────────────
The rows are vaults now, and the members under one are the people that vault is shared with. Nothing
about the server changed — it still authorises through a team — and what went is the step where
somebody had to make one before they could share anything. See VaultsViewModel.
So there is no mock-up to depart from. What this departs from instead is the desktop screen over the
same view model, and every difference below is a phone difference rather than a second opinion.
**The desktop's two columns are one.** A 268-pixel vault list beside a members table does not exist at
360dp, so the lists stack in one scrolling column with the vaults at the top. That is the same thing
HOSTS does with the desktop's sidebar and its connect column, and for the same reason.
**Nothing scrolls inside anything.** The desktop caps its members list at 240 pixels so several can sit
above each other in one pane. Here every list is sized to its content and the screen's own ScrollViewer
does all of the scrolling: a list that scrolls inside a page is a region a thumb has to find the edges
of, and three of them on one screen is three ways to get stuck.
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
machine that already holds the key, and this phone is one — so a vaults screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE, WITHDRAW
KEY and WITHDRAW INVITATION are the other half of that, and each of them acts on the first press: the
view model's armed-confirmation state covers handing a vault over and nothing else. The desktop guards
them with a tooltip instead, which is a control a touch screen has no way to show. An irreversible
revocation under a thumb with its explanation missing is the wrong trade, so all three stay on the
desktop — where the sentence beside them is visible. Handing a vault over is not drawn either, for a
plainer reason: it decides who controls the vault, which is not a thing to do while walking. Nor is
renaming, which is a keyboard on a screen that is otherwise all reading.
**ADD is not drawn either**, and it is the operation this screen least needs. It is an address typed
into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding somebody
did *not* do — and since invitations arrived the ordinary way into a vault is one the server claims at
sign-in, which is what put this screen on the phone at all. Making a vault is here, because it is one
field and because it is what a person carrying a phone can usefully start.
**The key-holder list is not drawn.** It is a third list, and what the phone can answer about a vault
is the more useful half of the same question and is on the vault's own row: whether *this* machine can
open it.
**↻ and `+` both, because this screen has more reason to re-read than any other.** Who is in a vault is
not cached — it is read from the server on arrival and again at the end of every command — so the one
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
from a colleague's desktop, or a vault they have this moment been invited into. On the desktop the
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Vaults),
which would set Screen to the value it already holds, raise nothing and reload nothing.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Vaults" Margin="4,0" />
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="icon accent" Content="+" Command="{Binding NewVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
<!-- ============ a new vault ============ -->
<!--
Above the list rather than in place of it, which is the opposite of what the host and snippet
editors do — and the difference is what the form is about. Those two edit a row that is on screen,
so a card stacked over the list hides the thing being changed. This one is about a vault that does
not exist yet, and the vaults that do are exactly the useful thing to be able to see while naming it.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding IsCreatingVault}">
<StackPanel Spacing="10">
<TextBlock Classes="label" Text="NEW VAULT" />
<TextBox Classes="field" Text="{Binding NewVaultName}" PlaceholderText="name" />
<TextBlock Classes="body"
Text="Its key is made on this phone and nobody else has it. Add people to it once it exists, then share the key with them." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
Command="{Binding CreateVaultCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelNewVaultCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
Status, and it is the empty state as well: the view model writes "locked" and every command's
outcome into the same property. A literal here would be a second voice saying the same thing
slightly differently.
-->
<TextBlock Grid.Row="2" Classes="detail" Margin="18,2,18,6" TextWrapping="Wrap"
Text="{Binding Status}"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- ============ the column ============ -->
<ScrollViewer Grid.Row="3">
<StackPanel Margin="0,0,0,18">
<TextBlock Classes="section" Text="VAULTS" Margin="18,4,18,4" />
<!--
Rows as cards, filled when chosen, which is what HOSTS settled on in v2 and what the radius
ladder calls a card: one item, one rule, one thing you act on. The fill is on the item rather
than on a Border inside it so the rounding the theme draws for selection is the row's own.
-->
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
IsVisible="{Binding HasVaults}" Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Detail}" />
<!--
Whether *this* phone can open it, and whether a rekey is owed. Coloured because both
states need somebody to act; drawn at all only when there is one, so an ordinary vault
carries no line rather than a reassurance nobody reads.
-->
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding HasState}" />
</StackPanel>
<!-- The caller's own role here, which is what says why some of it is read-only. -->
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding RoleLabel}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- ============ the chosen vault ============ -->
<StackPanel IsVisible="{Binding HasSelection}">
<!--
The personal vault has no members and never will, so the members heading is gated on the vault
being one that can have them rather than left standing over an empty list.
-->
<TextBlock Classes="body" Margin="18,18,18,0" IsVisible="{Binding SelectedIsPersonal}"
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright. Make a vault for the things you want to share, and put them in it." />
<StackPanel IsVisible="{Binding SelectedIsShared}">
<TextBlock Classes="section" Text="MEMBERS" Margin="18,18,18,4" />
<TextBlock Classes="body" Margin="18,0,18,4" IsVisible="{Binding HasSharedMembershipWarning}"
Text="{Binding SharedMembershipWarning}" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<!--
◆ The one fact on this row that decides whether the button at the foot of the screen
can do anything: an account with no published identity key has nothing for a vault
key to be wrapped to. One sentence, from the view model, painted twice rather than
written twice — the warning colour is the whole of the difference, and a converter
for it would hide that the two are the same string.
The published case is quiet rather than green. Green on this head means a shell is
open right now, and a published key is a durable fact about an account — borrowing
the status colour for it would be the second meaning that makes the first
unreadable. Only the missing key is coloured, because only it needs answering.
-->
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding KeyState}"
IsVisible="{Binding Member.IsEnrolled}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding KeyState}"
IsVisible="{Binding !Member.IsEnrolled}" />
<!--
A date to the day, or that they have never been here at all. The view model writes
both, and neither is a guess: the server records the account's last authenticated
request at most once an hour, which is what makes a day the honest unit.
-->
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextFaint}"
Text="{Binding LastActive}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a vault is what lets the server hand somebody its rows. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
<!-- ============ ◆ who has been asked and has not arrived ============ -->
<!--
The section this screen exists for, and the one the desktop had nothing to draw until
invitations were built. Read-only here: withdrawing one is a control that acts on the first
press, which is the line drawn at the top of this file.
So these are cards rather than the flat rows above them, and the shape is the difference: a
row that fills when you touch it is one of several you are choosing between, and there is
nothing to choose here. An ItemsControl rather than a ListBox for the same reason — a list
with a selection nothing reads would be a control offering something it cannot do.
Gated on the view model's own count rather than left to stand over an empty list, because a
vault with nobody outstanding is the ordinary case and a permanent empty heading would make
it look like a section that had failed to load.
The waiting row carries the whole mechanism in its own sentence — no mail was sent, and they
join when they first sign in here. That is the sentence somebody has to read, because every
other product's version of this word means an email is on its way.
-->
<StackPanel IsVisible="{Binding HasInvitations}">
<TextBlock Classes="section" Text="INVITED" Margin="18,18,18,4" />
<ItemsControl ItemsSource="{Binding Invitations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultInvitationRowViewModel">
<Border Classes="card" Margin="12,3">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding !IsPending}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding IsPending}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ============ ◆ giving somebody the key ============ -->
<!--
Raised over the column when somebody has been chosen, as HOSTS raises its connect bar and SNIPPETS
its insert bar, and for the reason written there: there is no second column to put it in, so it names
what it will do rather than relying on a selection being visible beside the button.
One condition where there used to be two. Sharing needs a member and a vault, and the vault is now
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
only half left to make.
-->
<Border Grid.Row="4"
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="9">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="THE KEY TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedVault.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="FOR" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedMember.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<Button Classes="primary" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
<!--
The sentence the desktop hangs off a tooltip, which a phone cannot show — so it is body text
under the button, where it is read before the tap rather than after it. It is not decoration:
the key-log check proves this server has been consistent with itself and nothing more.
-->
<TextBlock Classes="body"
Text="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. That proves the server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Vaults, under MORE — who is in each, and which of them this phone can open.</summary>
internal sealed partial class VaultsScreen : UserControl
{
public VaultsScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -189,6 +189,19 @@ public interface IDirectoryApi
/// </remarks> /// </remarks>
public interface IVaultGrantApi public interface IVaultGrantApi
{ {
/// <summary>
/// Renames a vault.
/// </summary>
/// <remarks>
/// Here rather than on <see cref="ITeamApi"/> 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.
/// </remarks>
Task<VaultSummary> RenameVaultAsync(
Guid vaultId,
UpdateVaultRequest request,
CancellationToken cancellationToken);
/// <summary>Lists who holds a key to this vault.</summary> /// <summary>Lists who holds a key to this vault.</summary>
Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken); Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
@@ -559,6 +572,18 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken); HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
} }
/// <inheritdoc />
public Task<VaultSummary> 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);
/// <inheritdoc /> /// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync( public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId, Guid vaultId,
@@ -8,7 +8,7 @@ namespace DodoSSH.Client.App.Views;
/// <remarks> /// <remarks>
/// Its data context is the <c>VaultViewModel</c>, in all three of the places it is shown, so every binding /// Its data context is the <c>VaultViewModel</c>, in all three of the places it is shown, so every binding
/// in the markup is a property of the vault. See <see cref="HostDrawer"/>, <see cref="HostsScreen"/> and /// in the markup is a property of the vault. See <see cref="HostDrawer"/>, <see cref="HostsScreen"/> and
/// <see cref="VaultScreen"/>. /// <see cref="KeychainScreen"/>.
/// </remarks> /// </remarks>
internal sealed partial class ConfirmDeleteCard : UserControl internal sealed partial class ConfirmDeleteCard : UserControl
{ {
+42 -7
View File
@@ -49,11 +49,16 @@
hosts screen is a sibling of the WebView. See MainWindow.axaml's occlusion rule. 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 ───────────────────────────────────────────── ── 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 Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", and the backspace-key mapping row. Four
picker's chevron. Five controls with nothing behind them: sharing is per vault and not per item, every controls with nothing behind them: sharing is per vault and not per item, every session here is an SSH
session here is an SSH channel, there are no identity or certificate item types, nothing carries a channel, there are no identity or certificate item types, and nothing carries a terminal setting to the
terminal setting to the renderer, and an item cannot be moved between vaults at all. They are listed in renderer. They are listed in docs/design-import-gaps.md with what ships instead, and none of them is
docs/design-import-gaps.md with what ships instead, and none of them is drawn disabled. 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.
--> -->
<Border Width="304" Background="{StaticResource Sidebar}" <Border Width="304" Background="{StaticResource Sidebar}"
@@ -66,10 +71,11 @@
One row for all three panels, which is why what it says is on the view model rather than repeated One row for all three panels, which is why what it says is on the view model rather than repeated
three times here. See VaultViewModel.DrawerTitle. three times here. See VaultViewModel.DrawerTitle.
The subtitle is the keychain this host is filed in, and the design's chevron beside it is not drawn: The subtitle is the vault this host is filed in, and the design's chevron beside it is not drawn:
an item cannot be moved between vaults — the two are encrypted under different keys, so moving one an item cannot be moved between vaults — the two are encrypted under different keys, so moving one
is a delete and a retype — and a picker offering the move would be offering something no layer below is a delete and a retype — and a picker offering the move would be offering something no layer below
this can do. this can do. Choosing the vault at the moment a host is created is a different question and does
have an answer; it is in the editor, beside the name.
--> -->
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}" <Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
@@ -358,6 +364,35 @@
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" /> <TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it is the one decision on this
form that cannot be changed afterwards: the vaults are encrypted under different keys, so
moving an item between them is a delete and a retype. It is a field of the host rather
than the keychain screen's standing "new items go to" preference, and it is a separate
selection from it — moving this one does not move that one, and a click over there cannot
move a host half-typed here.
Shown only while adding, and only where there is more than one vault that can be written
to. An existing host's row is not drawn at all rather than drawn disabled; the drawer's
header already says where the host is filed. See VaultViewModel.ShowsEditorVaultChoice.
The group picker below follows it: a group is an item in one vault, so choosing a vault
refills that list with that vault's groups and clears what was chosen from another's.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
<ComboBox ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A host in a shared vault is readable by everybody holding that vault's key, and it cannot be moved out afterwards." />
</StackPanel>
<!-- <!--
Which group this host is filed under. Inside the encrypted payload like everything else Which group this host is filed under. Inside the encrypted payload like everything else
here, so the server learns nothing about how the estate is organised — and a group the here, so the server learns nothing about how the estate is organised — and a group the
@@ -17,7 +17,7 @@ namespace DodoSSH.Client.App.Views;
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>. /// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
/// </para> /// </para>
/// <para> /// <para>
/// Its data context is the <c>VaultViewModel</c>, as <see cref="VaultScreen"/>'s is, so every binding in the /// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer /// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
/// beside the grid inherits the same one. /// beside the grid inherits the same one.
/// </para> /// </para>
@@ -3,7 +3,7 @@
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views" xmlns:views="using:DodoSSH.Client.App.Views"
xmlns:ssh="using:DodoSSH.Client.Ssh" xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.App.Views.VaultScreen" x:Class="DodoSSH.Client.App.Views.KeychainScreen"
x:DataType="vm:VaultViewModel"> x:DataType="vm:VaultViewModel">
<!-- <!--
@@ -136,7 +136,7 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="An item filed into a team's vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own." /> Text="An item filed into a shared vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own. A host is asked separately, in its own editor." />
</StackPanel> </StackPanel>
<!-- <!--
@@ -11,9 +11,9 @@ namespace DodoSSH.Client.App.Views;
/// one vault rather than two view models, because the two show different projections of the same four lists /// one vault rather than two view models, because the two show different projections of the same four lists
/// and a second view model would have to keep a copy of them in step. /// and a second view model would have to keep a copy of them in step.
/// </remarks> /// </remarks>
internal sealed partial class VaultScreen : UserControl internal sealed partial class KeychainScreen : UserControl
{ {
public VaultScreen() => InitializeComponent(); public KeychainScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary> /// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks> /// <remarks>
+12 -9
View File
@@ -152,11 +152,11 @@
<!-- <!--
Wrapped rather than bound directly, for the reason the vault column always was: this Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsVaultScreen does on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
not exist. not exist.
--> -->
<Panel IsVisible="{Binding IsVaultScreen}"> <Panel IsVisible="{Binding IsKeychainScreen}">
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" /> <views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel> </Panel>
<!-- ============ HOST KEYS ============ --> <!-- ============ HOST KEYS ============ -->
@@ -180,14 +180,17 @@
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" /> <views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel> </Panel>
<!-- ============ TEAM ============ --> <!-- ============ VAULTS ============ -->
<!-- <!--
Wrapped, for the reason the vault and transfers screens are: the visibility is the shell's Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
business and the data context is the teams view model, and both on one element would resolve business and the data context is the vaults view model, and both on one element would resolve
IsTeamScreen against a type that does not have it. IsVaultsScreen against a type that does not have it.
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
is one vault's contents and is what the keychain and hosts screens above draw.
--> -->
<Panel IsVisible="{Binding IsTeamScreen}"> <Panel IsVisible="{Binding IsVaultsScreen}">
<views:TeamsScreen DataContext="{Binding Teams}" /> <views:VaultsScreen DataContext="{Binding Vaults}" />
</Panel> </Panel>
<!-- ============ PREFERENCES ============ --> <!-- ============ PREFERENCES ============ -->
@@ -112,7 +112,7 @@ internal sealed partial class MainWindow : Window
private IInputElement KeyboardHome => shell switch private IInputElement KeyboardHome => shell switch
{ {
{ IsTerminalShowing: true } => Terminal, { IsTerminalShowing: true } => Terminal,
{ Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget, { Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget, { Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget, { Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget, { Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
+22 -13
View File
@@ -33,10 +33,10 @@
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
counting buckets is going anyway. 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 The last entry was TEAMS and is now VAULTS, which is a change of subject rather than of destination: the
rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
says plainly what is missing, and a list that quietly had fewer entries would make sharing look like a VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
change of product rather than the next milestone. FILES was the other one until M2 built it. 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 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 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 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. this screen now count the same things.
--> -->
<Button Classes="flat nav" Classes.active="{Binding IsVaultShowing}" <Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}" CommandParameter="{x:Static vm:ShellScreen.Keychain}"
ToolTip.Tip="Your keychain: SSH keys and stored passwords"> ToolTip.Tip="Your keychain: SSH keys and stored passwords">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="⚿" /> <TextBlock Grid.Column="0" Classes="navicon" Text="⚿" />
@@ -125,18 +125,27 @@
</Grid> </Grid>
</Button> </Button>
<Button Classes="flat nav" Classes.active="{Binding IsTeamShowing}" <!--
◆ THIS ENTRY SAID Teams UNTIL THE SCREEN BEHIND IT STOPPED BEING ABOUT THEM. A team is still what
the server authorises against; it is no longer something anybody has to make, name or think about,
so the rail names the thing people came for. See VaultsViewModel.
It shares a word with the tab strip's first tab, which is a different level of the window: that
tab is "this application rather than SFTP or S3", and this is one of the nine screens under it.
-->
<Button Classes="flat nav" Classes.active="{Binding IsVaultsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Team}" CommandParameter="{x:Static vm:ShellScreen.Vaults}"
ToolTip.Tip="Shared keychains and the people in them"> ToolTip.Tip="Your vaults, the people in each one, and who holds a key">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="◎" /> <TextBlock Grid.Column="0" Classes="navicon" Text="◎" />
<!-- <!--
No count. Teams are read from the server when the screen is opened, not on unlock, so this No count. The vault list is the session's and could be counted here — but who is in each one
would read 0 until somebody had already been there — which is the one number on this list is read from the server when the screen is opened, not on unlock, and a number naming only
that would be a statement rather than a blank. half of what the screen is about would be the one figure on this list that has to be
explained.
--> -->
<TextBlock Grid.Column="1" Classes="navlabel" Text="Teams" /> <TextBlock Grid.Column="1" Classes="navlabel" Text="Vaults" />
</Grid> </Grid>
</Button> </Button>
@@ -191,7 +191,7 @@
<TextBlock Classes="gap" <TextBlock Classes="gap"
Text="Per-use approval before a key signs — keys are handed to the SSH stack whole at connect time, so there is no per-signature moment to interrupt." /> Text="Per-use approval before a key signs — keys are handed to the SSH stack whole at connect time, so there is no per-signature moment to interrupt." />
<TextBlock Classes="gap" <TextBlock Classes="gap"
Text="SSO and team policy — the server has no team endpoints, so there is no policy for this screen to show." /> Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." />
<TextBlock Classes="gap" <TextBlock Classes="gap"
Text="Keyboard shortcuts — the window binds one chord, and the terminal keeps the rest for the remote." /> Text="Keyboard shortcuts — the window binds one chord, and the terminal keeps the rest for the remote." />
</ItemsControl> </ItemsControl>
@@ -1,9 +0,0 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Teams: who is in one, what they may do, and which vaults they hold a key to.</summary>
internal sealed partial class TeamsScreen : UserControl
{
public TeamsScreen() => InitializeComponent();
}
@@ -162,7 +162,7 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
Content="New vault…" Content="New vault…"
Click="OnNewVaultPressed" 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" />
</StackPanel> </StackPanel>
</Flyout> </Flyout>
@@ -2,104 +2,107 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:contracts="using:DodoSSH.Contracts" xmlns:contracts="using:DodoSSH.Contracts"
x:Class="DodoSSH.Client.App.Views.TeamsScreen" x:Class="DodoSSH.Client.App.Views.VaultsScreen"
x:DataType="vm:TeamsViewModel"> x:DataType="vm:VaultsViewModel">
<!-- <!--
Teams. Vaults, and the people in each of them.
The screen is built around one fact that every other product in this category hides: adding somebody to ── THIS WAS THE TEAMS SCREEN, AND THE TEAM IS NOW BEHIND THE VAULT. ─────────────────────────────────
a team and giving them a vault key are two different acts, and only the first is something a server can The left column used to list teams; a team owned vaults, and sharing meant creating a team, then a
do. The second needs a machine that holds the key, because this server never does. So the members table vault in it, then wrapping a key. Two of those three steps were about a concept nobody came here for.
and the vaults table are side by side, an addition says out loud that it granted nothing readable yet, So the rows are vaults now: naming one makes the membership list that carries it, and everything on
and SHARE KEY is its own button rather than a checkbox on the member row. the right — members, invitations, key holders — is that vault's. The server still authorises against
a team, because that is what VaultAccessService resolves; what went is the requirement that a person
know it exists. The one case where it is still visible is a membership list carrying several vaults,
which this screen cannot make and will not hide: see SharedMembershipWarning.
── THE ONE FACT THE WHOLE SCREEN IS BUILT AROUND ────────────────────────────────────────────────────
Adding somebody to a vault and giving them its key are two different acts, and only the first is
something a server can do. The second needs a machine that holds the key, because this server never
does. So the members list and the key-holders list are both here and are not the same list, an
addition says out loud that it granted nothing readable yet, and SHARE KEY is its own button rather
than a checkbox on the member row.
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
this product) and avatars (no picture is stored anywhere). Invitations and last-active are here, and this product) and avatars (no picture is stored anywhere). Nothing is sent for an invitation — there
both are narrower than the design drew. Nothing is sent — there is no outbound mail path and no token, is no outbound mail path and no token, so an invitation is a standing instruction that the next
so an invitation is a standing instruction that the next account signing in with that address joins the account signing in with that address joins, and there is consequently nothing to resend. Last-active
team, and there is consequently nothing to resend. Last-active is recorded at most once per account per is recorded at most once per account per hour, so it is drawn coarsely. Nor is there a way to delete a
hour, so it is drawn coarsely rather than to the minute. None of it is drawn with invented data. vault: the server has no such call, and the screen says so rather than offering a button that refuses.
--> -->
<Grid ColumnDefinitions="268,*"> <Grid ColumnDefinitions="268,*">
<!-- ============ The team list ============ --> <!-- ============ The vault list ============ -->
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0"> <Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<Grid RowDefinitions="44,*,Auto"> <Grid RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> <Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center"> <Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="12" FontWeight="SemiBold" <TextBlock Grid.Column="0" Classes="mono" Text="VAULTS" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" /> LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="ghost" Content="NEW" <Button Grid.Column="1" Classes="ghost" Content="NEW"
Command="{Binding NewTeamCommand}" IsEnabled="{Binding !IsBusy}" /> 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." />
</Grid> </Grid>
</Border> </Border>
<ScrollViewer Grid.Row="1"> <ScrollViewer Grid.Row="1">
<StackPanel> <StackPanel>
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}" <!--
Read from this machine's own vault list rather than from the server, so the column is right
with no connection. What is missing offline is who is in each one, which is why a row can
say its membership is unknown rather than saying nothing at all.
-->
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0"> Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamRowViewModel"> <DataTemplate x:DataType="vm:VaultRowViewModel">
<StackPanel Spacing="2" Margin="0,3"> <StackPanel Spacing="2" Margin="0,3">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="Medium" <TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" /> Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="10" <TextBlock Grid.Column="1" Classes="mono" Text="{Binding RoleLabel}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" /> Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="8,0,0,0" />
</Grid> </Grid>
<TextBlock Classes="hint" FontSize="11" Text="{Binding Detail}" /> <TextBlock Classes="hint" FontSize="11" Text="{Binding Detail}" />
<!--
Only when there is something to say. A vault waiting for a key and one owing a rekey
are both temporary and both need somebody to act; a permanent "fine" beside them
would teach people to stop reading the line.
-->
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding State}"
TextWrapping="Wrap" IsVisible="{Binding HasState}" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
<TextBlock Classes="hint" FontSize="11" Margin="14,12" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" Margin="14,12" TextWrapping="Wrap"
IsVisible="{Binding !HasTeams}" IsVisible="{Binding !HasVaults}"
Text="No teams yet. A team is what makes a vault shareable: its vaults can be opened by every member you wrap a key to." /> Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
<!-- The create forms, in place rather than in a modal: this window has no idiom for one. -->
<StackPanel Grid.Row="2">
<Border Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsCreatingTeam}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="NEW TEAM" />
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- <!--
The name-a-vault form, and it is in this column rather than beside the VAULTS list it belongs to The name-a-vault form, in place rather than in a modal: this window has no idiom for one. It is
for one reason: that list lives inside a ScrollViewer bound to HasSelection, so with no teams at in this column rather than beside the pane on the right for one reason — that pane is bound to
all it is not on screen and "no teams at all" is exactly the state somebody arrives in from HasSelection, so with no vaults at all it is not on screen, and "no vaults at all" is exactly
the tab strip's New vault entry. Here it is reachable whatever else is true. the state somebody arrives in from the tab strip's New vault entry.
One field. A team is made behind it and named after the vault, and its slug is derived — see One field. The membership list behind it is made with it and named after it, and its slug is
TeamsViewModel.CreateVaultAsync. Asking for a slug as the form above does would be asking for a derived — see VaultsViewModel.CreateVaultAsync. Asking for a URL handle would be asking for one
URL handle from somebody who has not been told they are making a team. from somebody who has not been told they are making anything but a vault.
--> -->
<Border Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" <Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsCreatingVault}"> IsVisible="{Binding IsCreatingVault}">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Classes="label" Text="NEW VAULT" /> <TextBlock Classes="label" Text="NEW VAULT" />
<TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" /> <TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="{Binding NewVaultDestination}" /> Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="Its key is made on this machine and nobody else has it yet. Add people to the team, then press SHARE KEY." />
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}" <Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
@@ -108,35 +111,31 @@
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
<!-- ============ Members and vaults ============ --> <!-- ============ The selected vault: who is in it, and who can open it ============ -->
<Grid Grid.Column="1" RowDefinitions="44,*,Auto"> <Grid Grid.Column="1" RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> <Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center"> <Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="12" <TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedVault.Name}" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}" FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<!-- <!--
The team's own operations. RENAME is an admin's; the other two are the owner's alone, and The vault's own operations. RENAME is an admin's; handing it on is the owner's alone, and
that is the line the server draws as well — an admin the owner promoted must not be able that is the line the server draws as well — an admin the owner promoted must not be able to
to archive the team or take it from them. take the vault from them.
--> -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding ShowsTeamActions}"> IsVisible="{Binding ShowsVaultActions}">
<Button Classes="ghost" Content="RENAME" Command="{Binding RenameTeamCommand}" <Button Classes="ghost" Content="RENAME" Command="{Binding RenameVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" /> IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}"
<Button Classes="ghost" Content="HAND OVER" Command="{Binding TransferOwnershipCommand}" ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted." />
<Button Classes="ghost" Content="HAND OVER" Command="{Binding HandOverCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}" IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
ToolTip.Tip="Hands this team to the selected member. They become the owner and you become an admin; only the new owner can hand it on again." /> 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." />
<Button Classes="danger" Content="ARCHIVE" Command="{Binding ArchiveTeamCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
ToolTip.Tip="Takes the team out of every member's list. Refused while it still owns any vault, and only somebody with database access can bring it back." />
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -146,24 +145,22 @@
<!-- The rename form, in place, exactly as the create form on the left is. --> <!-- The rename form, in place, exactly as the create form on the left is. -->
<Border Padding="12" CornerRadius="4" BorderThickness="1" <Border Padding="12" CornerRadius="4" BorderThickness="1"
BorderBrush="{StaticResource Border}" IsVisible="{Binding IsEditingTeam}"> BorderBrush="{StaticResource Border}" IsVisible="{Binding IsRenamingVault}">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Classes="label" Text="RENAME TEAM" /> <TextBlock Classes="label" Text="RENAME VAULT" />
<TextBox PlaceholderText="Name" Text="{Binding EditTeamName}" /> <TextBox PlaceholderText="Name" Text="{Binding EditVaultName}" />
<TextBox PlaceholderText="What this team is for (optional)"
Text="{Binding EditTeamDescription}" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The slug does not change. It is what URLs and the server's own records use, and it is unique only among live teams — so a rename that moved it could take one an archived team is still holding." /> 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." />
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveTeamCommand}" <Button Classes="accent" Content="SAVE" Command="{Binding SaveVaultNameCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameTeamCommand}" /> <Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameVaultCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- <!--
The armed confirmation, drawn where the buttons that armed it were. The vault screen's The armed confirmation, drawn where the buttons that armed it were. The keychain screen's
idiom, and for the same reason: there is no modal anywhere in this window. idiom, and for the same reason: there is no modal anywhere in this window.
--> -->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}" <Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
@@ -182,15 +179,35 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!--
The personal vault, which is the one vault sharing cannot reach. Said here rather than by
drawing the members section empty: an empty MEMBERS heading over a vault that can never have
any reads as a feature that has not loaded.
-->
<StackPanel Spacing="8" IsVisible="{Binding SelectedIsPersonal}">
<TextBlock Classes="label" Text="YOURS ALONE" />
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright — a key wrapped to somebody it will go on refusing to serve would look like sharing and would not be. Make a vault above for the things you want to share, and put them in it." />
</StackPanel>
<!-- Members --> <!-- Members -->
<StackPanel Spacing="8"> <StackPanel Spacing="8" IsVisible="{Binding SelectedIsShared}">
<TextBlock Classes="label" Text="MEMBERS" /> <TextBlock Classes="label" Text="MEMBERS" />
<!--
Only ever non-empty for a membership list this screen did not make. Adding somebody to one
vault and silently adding them to three others is precisely the fact a vault-shaped screen
is in a position to hide, so it says it instead.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding HasSharedMembershipWarning}"
Text="{Binding SharedMembershipWarning}" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}" <ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0" MaxHeight="240"> Background="Transparent" BorderThickness="0" MaxHeight="240">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel"> <DataTemplate x:DataType="vm:VaultMemberRowViewModel">
<Grid ColumnDefinitions="*,168,Auto" Margin="0,3"> <Grid ColumnDefinitions="*,168,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium" <TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
@@ -215,7 +232,7 @@
key editor and the category rail already make and for the reason they record: a selector key editor and the category rail already make and for the reason they record: a selector
moves its own highlight before anything can refuse, so it can end up showing a role moves its own highlight before anything can refuse, so it can end up showing a role
nobody was given. OWNER is absent because it is not a role that can be assigned — nobody was given. OWNER is absent because it is not a role that can be assigned —
handing the team over is its own act, with its own confirmation. handing the vault over is its own act, with its own confirmation.
--> -->
<StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}"> <StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" /> <TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
@@ -223,27 +240,27 @@
<Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}" <Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May pull this team's vaults and may not push. It does not withdraw a vault key they already hold." /> ToolTip.Tip="May pull this vault and may not push. It does not withdraw a key they already hold." />
<Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}" <Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May read and change this team's vaults." /> ToolTip.Tip="May read and change what is in this vault." />
<Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}" <Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}" CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May also manage members, create vaults and share vault keys." /> ToolTip.Tip="May also add and remove people, rename the vault, and share its key." />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}"> <Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}" <TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
Margin="0,0,6,0" /> Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER" <Button Grid.Column="1" Classes="accent" Content="ADD"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Adds the account with this address, or invites the address if there is no account here yet. Nothing is sent either way — tell them yourself." /> ToolTip.Tip="Adds the account with this address, or invites the address if there is no account here yet. Nothing is sent either way — tell them yourself." />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0" <Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}" Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every vault key they hold from this team. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." /> 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." />
</Grid> </Grid>
<StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}"> <StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}">
@@ -263,12 +280,12 @@
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding CanAdministerSelected}" IsVisible="{Binding CanAdministerSelected}"
Text="Adding somebody lets the server serve them this team's vaults. It does not let them read one: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." /> 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." />
</StackPanel> </StackPanel>
<!-- <!--
Invitations, drawn only when there are any. An empty INVITED heading on every team would be Invitations, drawn only when there are any. An empty INVITED heading on every vault would be
a permanent reminder of a feature most teams never use. a permanent reminder of a feature most people never use.
--> -->
<StackPanel Spacing="8" IsVisible="{Binding HasInvitations}"> <StackPanel Spacing="8" IsVisible="{Binding HasInvitations}">
<Border Height="1" Background="{StaticResource BorderSubtle}" /> <Border Height="1" Background="{StaticResource BorderSubtle}" />
@@ -278,7 +295,7 @@
<ListBox ItemsSource="{Binding Invitations}" SelectedItem="{Binding SelectedInvitation}" <ListBox ItemsSource="{Binding Invitations}" SelectedItem="{Binding SelectedInvitation}"
Background="Transparent" BorderThickness="0" MaxHeight="160"> Background="Transparent" BorderThickness="0" MaxHeight="160">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel"> <DataTemplate x:DataType="vm:VaultInvitationRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,3"> <Grid ColumnDefinitions="*,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Email}" FontSize="13" FontWeight="Medium" <TextBlock Text="{Binding Email}" FontSize="13" FontWeight="Medium"
@@ -297,67 +314,36 @@
<Button Classes="danger" Content="WITHDRAW INVITATION" HorizontalAlignment="Left" <Button Classes="danger" Content="WITHDRAW INVITATION" HorizontalAlignment="Left"
Command="{Binding RevokeInvitationCommand}" IsEnabled="{Binding !IsBusy}" Command="{Binding RevokeInvitationCommand}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanAdministerSelected}" IsVisible="{Binding CanAdministerSelected}"
ToolTip.Tip="Signing in with that address will no longer put them in this team. An invitation already taken up is a membership — remove the member instead." /> 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." />
</StackPanel> </StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" /> <Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Vaults --> <!-- Who holds the key -->
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto"> <StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding SelectedIsShared}">
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
<!--
Opens the form under the team list rather than creating one outright. It used to create a
vault named after the team, which meant a team with three of them held three vaults with
the same name and no way to tell them apart.
-->
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
Command="{Binding NewVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
</Grid>
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0" MaxHeight="200">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding !HasSelection}"
Text="Select a team to see its vaults." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}" <Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Wraps the selected 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="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." />
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}" <Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." /> ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
</StackPanel> </StackPanel>
<!-- <!--
Who can open the selected vault — the design's "shared with" avatars, as names and a Who can open this vault — the design's "shared with" avatars, as names and a state.
state. Under the vault rather than beside the member, because a grant is per vault: a Withdrawn and stale grants stay listed and say which they are, because a list that quietly
count on a member row would imply per-item sharing, which is M5 and does not exist. dropped them would show a departed colleague as merely absent rather than as somebody whose
Withdrawn and stale grants stay listed and say which they are, because a list that key was taken away. The dot is Live and means exactly what it says: this person can open
quietly dropped them would show a departed colleague as merely absent rather than as this vault right now.
somebody whose key was taken away. The dot is Live and means exactly what it says: this
person can open this vault right now.
--> -->
<TextBlock Classes="label" Text="KEY HOLDERS" /> <TextBlock Classes="label" Text="KEY HOLDERS" />
<ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0" <ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0"
MaxHeight="150"> MaxHeight="150">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamGrantRowViewModel"> <DataTemplate x:DataType="vm:VaultGrantRowViewModel">
<Grid ColumnDefinitions="10,*" Margin="0,3"> <Grid ColumnDefinitions="10,*" Margin="0,3">
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center" <Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" /> IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
@@ -372,7 +358,16 @@
</ListBox> </ListBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding SelectedIsShared}"
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." /> Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
<!--
Said once, where somebody would otherwise go looking for a DELETE button. There is no call
for it anywhere in the server, and archiving the membership list behind a vault is refused
while the vault exists — so a button here would be one that always refuses.
-->
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A vault cannot be deleted. Nothing in this product removes one, and the server refuses to archive the membership list behind it while it still exists." />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -380,7 +375,7 @@
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="20" TextWrapping="Wrap" <TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="20" TextWrapping="Wrap"
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}" VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
Text="Create a team on the left, or wait to be added to one. A team owns vaults; a vault's key is what makes its contents readable, and that key is handed out by people rather than by the server." /> 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." />
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" <Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Vaults: which there are, who is in each, and who holds a key to it.</summary>
internal sealed partial class VaultsScreen : UserControl
{
public VaultsScreen() => InitializeComponent();
}
@@ -94,6 +94,53 @@ public sealed partial class VaultSession
} }
} }
/// <summary>
/// Renames a vault, here and on the server.
/// </summary>
/// <param name="api">The vault calls.</param>
/// <param name="vaultId">The vault to rename.</param>
/// <param name="name">What to call it. Plaintext, as all vault names are.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The vault as this machine now holds it.</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>The cached row is edited rather than replaced with the response.</b> 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.
/// </para>
/// </remarks>
public async Task<StoredVault> 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;
}
/// <summary> /// <summary>
/// Wraps a vault's key to another member, after verifying their published key. /// Wraps a vault's key to another member, after verifying their published key.
/// </summary> /// </summary>
@@ -31,11 +31,11 @@ internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPe
{ {
/// <summary>What the switch says.</summary> /// <summary>What the switch says.</summary>
/// <remarks> /// <remarks>
/// 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 /// 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. /// is about is the only thing that tells the two switches apart.
/// </remarks> /// </remarks>
internal string Display => IsPersonal ? Name : $"{Name} · TEAM"; internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
/// <summary>Whether this vault can be switched off.</summary> /// <summary>Whether this vault can be switched off.</summary>
/// <remarks> /// <remarks>
@@ -100,11 +100,22 @@ internal enum ShellScreen
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary> /// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
Transfers = 1, Transfers = 1,
/// <summary>Everything in the vault that is not a host.</summary> /// <summary>Everything in the open vault that is not a host: keys, passwords, buckets, tags.</summary>
Vault = 2, /// <remarks>
/// Named for what the rail calls it rather than for the vault it reads, which is what it was called
/// when <see cref="Vaults"/> 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.
/// </remarks>
Keychain = 2,
/// <summary>Shared vaults and the people in them. Both heads draw it.</summary> /// <summary>The vaults themselves and the people in them. Both heads draw it.</summary>
Team = 3, /// <remarks>
/// Was <c>Team</c>, and the value is unchanged with it: the screen is the same destination, and these
/// numbers are written into <c>NavRail.axaml</c> as <c>x:Static</c> literals. What changed is what the
/// screen is about — see <see cref="VaultsViewModel"/>.
/// </remarks>
Vaults = 3,
/// <summary>Preferences.</summary> /// <summary>Preferences.</summary>
Preferences = 4, Preferences = 4,
@@ -275,7 +286,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks> /// </remarks>
private readonly ConnectionRecorder connectionLog; private readonly ConnectionRecorder connectionLog;
private readonly TeamsViewModel teams; private readonly VaultsViewModel vaults;
/// <summary> /// <summary>
/// The tab standing in for each connection that has been asked for and has not answered yet. /// 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 // 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 // are built from the session's vault list, and neither would otherwise learn that it had grown until
// something else happened to rebuild them. // 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 // 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. // 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; private LogsViewModel? logsScreen;
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a /// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: both of its
/// server rather than a vault, and both of its dependencies are fetched through a function at the /// dependencies are fetched through a function at the moment they are needed. That means a lock does
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have /// not have to tear it down and an unlock does not have to rebuild it, and the list it is showing
/// to rebuild it, and the list it is showing survives both. /// survives both.
/// <para>
/// Distinct from <see cref="Vault"/>, which is one vault's <em>contents</em> — the hosts, keys and
/// passwords the rail's other screens draw. This one is the vaults themselves and the people in them.
/// </para>
/// </remarks> /// </remarks>
internal TeamsViewModel Teams => teams; internal VaultsViewModel Vaults => vaults;
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary> /// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
/// <remarks> /// <remarks>
@@ -759,10 +774,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers; internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
/// <inheritdoc cref="IsHostsScreen" /> /// <inheritdoc cref="IsHostsScreen" />
internal bool IsVaultScreen => Screen is ShellScreen.Vault; internal bool IsKeychainScreen => Screen is ShellScreen.Keychain;
/// <inheritdoc cref="IsHostsScreen" /> /// <inheritdoc cref="IsHostsScreen" />
internal bool IsTeamScreen => Screen is ShellScreen.Team; internal bool IsVaultsScreen => Screen is ShellScreen.Vaults;
/// <inheritdoc cref="IsHostsScreen" /> /// <inheritdoc cref="IsHostsScreen" />
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences; 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 IsTransfersShowing => IsShowingPages && IsTransfersScreen;
/// <inheritdoc cref="IsHostsShowing" /> /// <inheritdoc cref="IsHostsShowing" />
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen; internal bool IsKeychainShowing => IsShowingPages && IsKeychainScreen;
/// <inheritdoc cref="IsHostsShowing" /> /// <inheritdoc cref="IsHostsShowing" />
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen; internal bool IsVaultsShowing => IsShowingPages && IsVaultsScreen;
/// <inheritdoc cref="IsHostsShowing" /> /// <inheritdoc cref="IsHostsShowing" />
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen; 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. /// 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 /// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this. <see cref="ShellScreen.Team"/> is in it for the same reason and no /// the rail and never asks this. <see cref="ShellScreen.Vaults"/> 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 /// 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 /// 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. /// header back over a screen that already has one.
/// ///
@@ -849,7 +864,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsMoreSurface => internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
or ShellScreen.Team or ShellScreen.Vault; or ShellScreen.Vaults or ShellScreen.Keychain;
/// <summary> /// <summary>
/// Whether the terminal's WebView may be on screen at this instant. /// 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. <see cref="IsTransfersShowing"/> and /// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads. /// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
/// </para> /// </para>
/// <para>
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the nine screens underneath this tab.</b> 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".
/// </para>
/// </remarks> /// </remarks>
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen); internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
@@ -1265,19 +1285,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
/// <summary> /// <summary>
/// Goes to the teams screen with the new-vault form open. /// Goes to the vaults screen with the new-vault form open.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A vault gets a team, so the place to make one is the screen that shows teams — where the people, the /// The screen a vault is made on is the one that shows vaults — where the people, the roles and the key
/// roles and the key holders already are, which is the next thing anybody making a shared vault wants. /// holders already are, which is the next thing anybody making a shared vault wants. The form asks for a
/// The form asks for a name and nothing else; see <c>TeamsViewModel.CreateVaultAsync</c> for what is /// name and nothing else; see <c>VaultsViewModel.CreateVaultAsync</c> for the membership list that is
/// made behind it. /// made behind it.
/// </remarks> /// </remarks>
[RelayCommand] [RelayCommand]
private void ShowNewVault() private void ShowNewVault()
{ {
ShowScreen(ShellScreen.Team); ShowScreen(ShellScreen.Vaults);
teams.NewVaultInItsOwnTeamCommand.Execute(null); vaults.NewVaultCommand.Execute(null);
} }
// ---- The phone's connect menu ---- // ---- The phone's connect menu ----
@@ -2964,13 +2984,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ = logs.RefreshCommand.ExecuteAsync(null); _ = logs.RefreshCommand.ExecuteAsync(null);
} }
// Teams are read from the server rather than from the vault, so there is nothing to show until // Who is in each vault is read from the server rather than from the vault itself, so there is
// somebody asks for it — and asking for it on every unlock would be a request per launch for a // nothing to show until somebody asks for it — and asking for it on every unlock would be a request
// screen most people never open. Fire-and-forget because a property change cannot await, and // per launch for a screen most people never open. Fire-and-forget because a property change cannot
// because the view model turns every failure into its own status line rather than throwing. // await, and because the view model turns every failure into its own status line rather than
if (value is ShellScreen.Team) // 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(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen)); OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsVaultScreen)); OnPropertyChanged(nameof(IsKeychainScreen));
OnPropertyChanged(nameof(IsTeamScreen)); OnPropertyChanged(nameof(IsVaultsScreen));
OnPropertyChanged(nameof(IsPreferencesScreen)); OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen)); OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen)); OnPropertyChanged(nameof(IsImportScreen));
@@ -3058,8 +3079,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsVaultsTab)); OnPropertyChanged(nameof(IsVaultsTab));
OnPropertyChanged(nameof(IsHostsShowing)); OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing)); OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsVaultShowing)); OnPropertyChanged(nameof(IsKeychainShowing));
OnPropertyChanged(nameof(IsTeamShowing)); OnPropertyChanged(nameof(IsVaultsShowing));
OnPropertyChanged(nameof(IsPreferencesShowing)); OnPropertyChanged(nameof(IsPreferencesShowing));
OnPropertyChanged(nameof(IsKnownHostsShowing)); OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing)); OnPropertyChanged(nameof(IsSnippetsShowing));
@@ -864,11 +864,12 @@ internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPe
/// What the picker shows. /// What the picker shows.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// 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 /// 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 <c>VaultsViewModel</c>.
/// </remarks> /// </remarks>
internal string Display => IsPersonal ? Name : $"{Name} · TEAM"; internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
} }
internal sealed record VaultItemRowViewModel( internal sealed record VaultItemRowViewModel(
@@ -1064,6 +1065,18 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private Dictionary<Guid, HostGroupSecret> groupsById = []; private Dictionary<Guid, HostGroupSecret> groupsById = [];
/// <summary>
/// Every readable vault's groups, kept apart by the vault they live in.
/// </summary>
/// <remarks>
/// What the host editor's group picker is built from, and it has to be per vault rather than the one
/// list <see cref="Groups"/> 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 <see cref="BuildGroupChoices"/>.
/// </remarks>
private Dictionary<Guid, List<GroupChoice>> groupsByVault = [];
/// <summary> /// <summary>
/// The tags as they came out of the vault, before the host counts are attached. /// The tags as they came out of the vault, before the host counts are attached.
/// </summary> /// </summary>
@@ -1916,10 +1929,50 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
private AuthenticationChoice? editorSelectedAuthentication; private AuthenticationChoice? editorSelectedAuthentication;
/// <summary>What the group picker offers: "no group", then every group.</summary> /// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" /> /// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = []; internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
/// <summary>
/// Which vault a host being created will be filed into.
/// </summary>
/// <remarks>
/// <para>
/// The picker in the host editor itself, and it is a second one rather than the keychain screen's
/// <see cref="TargetVaults"/> 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
/// <see cref="editingHostVaultId"/> was introduced to prevent.
/// </para>
/// <para>
/// Filled from the same source, so what it offers is what the keychain screen offers: vaults this
/// session can both read and write.
/// </para>
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? editorSelectedVault;
/// <summary>
/// Whether the editor should be asking which vault this host goes into.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Hidden at one vault rather than shown with a single option, which is the rule
/// <see cref="HasVaultChoice"/> already applies for the same reason: a control offering one answer is
/// a question nobody was asked.
/// </para>
/// </remarks>
internal bool ShowsEditorVaultChoice => editingEntityId is null && EditorVaultChoices.Count > 1;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))] [NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
[NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))] [NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))]
@@ -2990,6 +3043,8 @@ internal sealed partial class VaultViewModel(
// still this one's. // still this one's.
groupItems = []; groupItems = [];
var perVault = new Dictionary<Guid, List<GroupChoice>>();
foreach (var vault in session.ReadableVaults) foreach (var vault in session.ReadableVaults)
{ {
var listing = await session.HostGroups var listing = await session.HostGroups
@@ -3003,6 +3058,13 @@ internal sealed partial class VaultViewModel(
resolvable[group.EntityId] = group.Secret; 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) if (vault.VaultId == session.ActiveVaultId)
{ {
groupItems = groupItems =
@@ -3011,6 +3073,7 @@ internal sealed partial class VaultViewModel(
} }
groupsById = resolvable; groupsById = resolvable;
groupsByVault = perVault;
return unreadable; return unreadable;
} }
@@ -4118,7 +4181,12 @@ internal sealed partial class VaultViewModel(
} }
editingEntityId = null; 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; editingHostVaultId = TargetVaultId;
EditorLabel = string.Empty; EditorLabel = string.Empty;
EditorHostname = string.Empty; EditorHostname = string.Empty;
@@ -4132,13 +4200,18 @@ internal sealed partial class VaultViewModel(
EditorNewTag = string.Empty; EditorNewTag = string.Empty;
BuildTagChoices(); 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 // 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 // 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 // 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 // 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 // the screen it was created on. Only when that group is in the vault this host is going into,
// decides whether the picker offers to. // though — the grid draws the active vault's groups, and inheriting one into a shared vault would
BuildGroupChoices(GroupTarget?.EntityId); // 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( BuildAuthenticationChoices(
boundKeyId: null, boundKeyId: null,
@@ -4167,7 +4240,13 @@ internal sealed partial class VaultViewModel(
} }
editingEntityId = row.EntityId; 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; editingHostVaultId = row.VaultId;
EditorLabel = row.Host.Label; EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname; EditorHostname = row.Host.Hostname;
@@ -4182,6 +4261,7 @@ internal sealed partial class VaultViewModel(
EditorNewTag = string.Empty; EditorNewTag = string.Empty;
BuildTagChoices(); BuildTagChoices();
BuildEditorVaultChoices(editingHostVaultId);
BuildGroupChoices(row.Host.GroupId); BuildGroupChoices(row.Host.GroupId);
BuildAuthenticationChoices( 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 /// 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. /// naming it, because there is nothing left to read the name off.
/// </remarks> /// </remarks>
/// <summary>Refills the host editor's vault picker, landing on the vault the editor will write to.</summary>
/// <remarks>
/// Filled from <see cref="TargetVaults"/>, which is already the readable-and-writable set and is kept
/// in step with the session by <see cref="RebuildTargetVaults"/>. 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.
/// </remarks>
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));
}
/// <summary>
/// Moves a half-typed host into the vault just chosen for it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>The group, if the vault being written to actually has it; otherwise none.</summary>
private Guid? GroupInEditingVault(Guid? groupId) =>
groupId is { } id
&& groupsByVault.TryGetValue(editingHostVaultId, out var groups)
&& groups.Any(choice => choice.EntityId == id)
? id
: null;
/// <summary>Refills the host editor's group picker for one vault.</summary>
/// <param name="groupId">The group to land on, or null for none.</param>
/// <remarks>
/// <para>
/// The vault's own groups and no others — see <see cref="groupsByVault"/>. 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.
/// </para>
/// <para>
/// A group the vault no longer has keeps a placeholder entry, so that editing a host's port cannot
/// quietly unfile it.
/// </para>
/// </remarks>
private void BuildGroupChoices(Guid? groupId) private void BuildGroupChoices(Guid? groupId)
{ {
EditorGroupChoices.Clear(); EditorGroupChoices.Clear();
EditorGroupChoices.Add(GroupChoice.None); 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)) if (groupId is { } bound && !EditorGroupChoices.Any(choice => choice.EntityId == bound))
@@ -53,6 +53,7 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(TeamInvitationSummary))] [JsonSerializable(typeof(TeamInvitationSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))] [JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))]
[JsonSerializable(typeof(CreateTeamVaultRequest))] [JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(UpdateVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))] [JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))] [JsonSerializable(typeof(VaultGrantsResponse))]
[JsonSerializable(typeof(KeyLogPage))] [JsonSerializable(typeof(KeyLogPage))]
@@ -666,6 +666,13 @@ DodoSSH.Contracts.UpdateTeamRequest.Equals(DodoSSH.Contracts.UpdateTeamRequest?
DodoSSH.Contracts.UpdateTeamRequest.Name.get -> string! DodoSSH.Contracts.UpdateTeamRequest.Name.get -> string!
DodoSSH.Contracts.UpdateTeamRequest.Name.init -> void DodoSSH.Contracts.UpdateTeamRequest.Name.init -> void
DodoSSH.Contracts.UpdateTeamRequest.UpdateTeamRequest(string! Name, string? Description) -> void DodoSSH.Contracts.UpdateTeamRequest.UpdateTeamRequest(string! Name, string? Description) -> void
DodoSSH.Contracts.UpdateVaultRequest
DodoSSH.Contracts.UpdateVaultRequest.<Clone>$() -> 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.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse! DodoSSH.Contracts.VaultGrantsResponse.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse!
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
@@ -840,6 +847,9 @@ override DodoSSH.Contracts.TransferTeamOwnershipRequest.ToString() -> string!
override DodoSSH.Contracts.UpdateTeamRequest.Equals(object? obj) -> bool override DodoSSH.Contracts.UpdateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.UpdateTeamRequest.GetHashCode() -> int override DodoSSH.Contracts.UpdateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.UpdateTeamRequest.ToString() -> string! 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.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string! override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
@@ -928,6 +938,8 @@ static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator !=(DodoSSH.Contra
static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator ==(DodoSSH.Contracts.TransferTeamOwnershipRequest? left, DodoSSH.Contracts.TransferTeamOwnershipRequest? right) -> bool static DodoSSH.Contracts.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.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.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 static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
+21
View File
@@ -369,6 +369,27 @@ public sealed record CreateTeamVaultRequest(
byte[] GrantSignature, byte[] GrantSignature,
DateTimeOffset GrantedAt); DateTimeOffset GrantedAt);
/// <summary>Renames a vault.</summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// It is plaintext, as vault names have always been — a person has to be able to choose a vault before
/// anything is decrypted (<c>docs/crypto.md</c> §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.
/// </para>
/// <para>
/// A whole replacement rather than a patch, for the reason <see cref="UpdateTeamRequest"/> is one: there
/// is a single field, so a repeat is the same vault rather than a second edit.
/// </para>
/// </remarks>
/// <param name="Name">Display name. Required, 1 to 256 characters.</param>
public sealed record UpdateVaultRequest(string Name);
/// <summary>Issues a vault key grant to another member.</summary> /// <summary>Issues a vault key grant to another member.</summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -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 // 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 // sharing graph — and the two writes are gated on Share inside the handler, which this table
// cannot see. See VaultGrantEndpoints. // 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", "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", "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", "DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
@@ -252,6 +252,134 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.Role.ShouldBe(TeamMemberRole.Owner); .Role.ShouldBe(TeamMemberRole.Owner);
} }
// ---- Renaming a vault ----
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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
/// <c>UpdateTeamRequest</c> records.
/// </para>
/// </remarks>
[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<VaultSummary>())!;
renamed.Name.ShouldBe("Platform");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
me.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
var after = listed.Single(row => row.TeamId == team.TeamId);
after.Name.ShouldBe("Platform");
after.Slug.ShouldBe(team.Slug);
}
/// <remarks>
/// 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.
/// </remarks>
[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<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
listed.Single(row => row.TeamId == team.TeamId).Name.ShouldBe("Platform Engineering");
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// An outsider gets 404 rather than 403, which is the rule <c>IVaultAccessService</c> states: a
/// distinct "exists but forbidden" answer is an existence oracle for other tenants' vault ids.
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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 ---- // ---- Archiving ----
/// <remarks> /// <remarks>
@@ -1092,6 +1220,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults"; private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}";
/// <summary>An address no account holds, uniquified because the container is shared.</summary> /// <summary>An address no account holds, uniquified because the container is shared.</summary>
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com"; private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
@@ -547,7 +547,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// ---- The vault screen ---- // ---- The vault screen ----
[Fact] [Fact]
public async Task TheVaultScreenFitsInEveryCategory() public async Task TheKeychainScreenFitsInEveryCategory()
{ {
foreach (var section in new[] 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. /// wide — the narrowest column any form in this application has to fit into.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheVaultScreenFitsWithTheKeyEditorOpen() public async Task TheKeychainScreenFitsWithTheKeyEditorOpen()
{ {
vault.NewKeyCommand.Execute(null); vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue(); vault.IsEditingKey.ShouldBeTrue();
@@ -579,7 +579,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
} }
[Fact] [Fact]
public async Task TheVaultScreenFitsWithThePasswordEditorOpen() public async Task TheKeychainScreenFitsWithThePasswordEditorOpen()
{ {
vault.NewCredentialCommand.Execute(null); vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue(); vault.IsEditingCredential.ShouldBeTrue();
@@ -595,7 +595,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// nobody was told about. /// nobody was told about.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheVaultScreenFitsWithTheGenerateFormOpen() public async Task TheKeychainScreenFitsWithTheGenerateFormOpen()
{ {
vault.NewGeneratedKeyCommand.Execute(null); vault.NewGeneratedKeyCommand.Execute(null);
vault.IsGeneratingKey.ShouldBeTrue(); vault.IsGeneratingKey.ShouldBeTrue();
@@ -820,7 +820,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// application. /// application.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheVaultScreenFitsWithADeletionInQuestion() public async Task TheKeychainScreenFitsWithADeletionInQuestion()
{ {
var keyId = vault.Keys[0].EntityId; var keyId = vault.Keys[0].EntityId;
@@ -1607,13 +1607,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
bytesPerSecond, bytesPerSecond,
failure))); failure)));
/// <summary>Lays the vault screen out at the width it gets once the nav rail has taken its column.</summary> /// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The teams screen had no entry in this suite at all until it grew four sections — a rename form, an /// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
/// armed confirmation, an invitations list and a key-holders list — plus a second line in the member /// takes 190 and the vault list 268, leaving 558 for everything above.
/// 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.
/// </para> /// </para>
/// <para> /// <para>
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see /// 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
/// </para> /// </para>
/// </remarks> /// </remarks>
[Fact] [Fact]
public Task TheTeamsScreen_FitsWithEveryListPopulated() => public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
OnTheTeamsScreenAsync( OnTheVaultsScreenAsync(
teams => { }, vaults => { },
window => LayoutHarness.Unreachable(window) window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with members, invitations and key holders")); .ShouldBeEmpty("the vaults screen with members, invitations and key holders"));
/// <remarks> /// <remarks>
/// The rename form is drawn in place, above the members list, and pushes everything below it down. /// The rename form is drawn in place, above the members list, and pushes everything below it down.
/// </remarks> /// </remarks>
[Fact] [Fact]
public Task TheTeamsScreen_FitsWhileRenamingATeam() => public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
OnTheTeamsScreenAsync( OnTheVaultsScreenAsync(
teams => teams.RenameTeamCommand.Execute(null), vaults => vaults.RenameVaultCommand.Execute(null),
window => LayoutHarness.Unreachable(window) window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with the rename form open")); .ShouldBeEmpty("the vaults screen with the rename form open"));
/// <remarks> /// <remarks>
/// The name-a-vault form is in the left column under the team list, and it is the taller of the two /// The name-a-vault form is in the left column under the vault list. Worth its own case because the
/// forms that can appear there — one field, but two sentences under it. Worth its own case because the /// column is 268 wide and the sentence under the field wraps.
/// column is 268 wide and both sentences wrap.
/// </remarks> /// </remarks>
[Fact] [Fact]
public Task TheTeamsScreen_FitsWithTheNewVaultFormOpen() => public Task TheVaultsScreen_FitsWithTheNewVaultFormOpen() =>
OnTheTeamsScreenAsync( OnTheVaultsScreenAsync(
teams => teams.NewVaultInItsOwnTeamCommand.Execute(null), vaults => vaults.NewVaultCommand.Execute(null),
window => LayoutHarness.Unreachable(window) window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with the new-vault form open")); .ShouldBeEmpty("the vaults screen with the new-vault form open"));
/// <remarks>
/// 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.
/// </remarks>
[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"));
/// <remarks> /// <remarks>
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the /// 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. /// key-holders list off the bottom.
/// </remarks> /// </remarks>
[Fact] [Fact]
public Task TheTeamsScreen_FitsWhileConfirmingAnArchive() => public Task TheVaultsScreen_FitsWhileConfirmingAHandOver() =>
OnTheTeamsScreenAsync( OnTheVaultsScreenAsync(
teams => teams.ArchiveTeamCommand.Execute(null), vaults =>
{
vaults.SelectedMember = vaults.Members.First(member => !member.IsSelf);
vaults.HandOverCommand.Execute(null);
},
window => LayoutHarness.Unreachable(window) window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with the archive confirmation armed")); .ShouldBeEmpty("the vaults screen with the hand-over confirmation armed"));
/// <remarks> /// <remarks>
/// A real <c>TeamsViewModel</c> over a stub server rather than the unlocked vault the rest of this /// <para>
/// suite uses, because nothing on this screen is vault content: it is read from the server on open. /// A real <c>VaultsViewModel</c> over this suite's own unlocked session and a stub server. Both halves
/// The session function answers null, which is the state a member is in before anybody has wrapped /// are needed and they answer different questions: the vault list is the session's, and who is in each
/// them a key — and it is also the one that draws the most text, since every vault row then carries /// vault is the server's.
/// the "waiting for a key" sentence. /// </para>
/// <para>
/// 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 <c>CreateTeamVaultAsync</c> rather than poked into the cache, so the row
/// being measured is one the application could actually produce.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks> /// </remarks>
private static async Task OnTheTeamsScreenAsync( private async Task OnTheVaultsScreenAsync(
Action<TeamsViewModel> arrange, Action<VaultsViewModel> arrange,
Action<Window> assert) Action<Window> assert)
{ {
using var teamServer = new StubTeamServer(); 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( await LayoutHarness.OnTheUiThreadAsync(
() => () =>
{ {
arrange(teams); arrange(vaults);
var screen = new TeamsScreen { DataContext = teams }; var screen = new VaultsScreen { DataContext = vaults };
var window = LayoutHarness.HostAtMinimumSize( var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight); screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
@@ -1721,11 +1726,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) => private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window))); OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
private Task OnTheVaultAsync(Action<VaultScreen, Window> body) => private Task OnTheVaultAsync(Action<KeychainScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync( LayoutHarness.OnTheUiThreadAsync(
() => () =>
{ {
var screen = new VaultScreen { DataContext = vault }; var screen = new KeychainScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize( var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight); screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
@@ -7,15 +7,20 @@ using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Layout.Tests; namespace DodoSSH.Client.App.Layout.Tests;
/// <summary> /// <summary>
/// The least server a <c>TeamsViewModel</c> needs in order to be laid out with something in it. /// The least server a <c>VaultsViewModel</c> needs in order to be laid out with something in it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The teams screen is the one screen in this suite whose content cannot come from an unlocked vault, /// The vaults screen draws its list from the session and everything under it from the server: who is in a
/// because none of it is vault content: a team, its members, its invitations and who holds a key to a /// vault, who has been invited, and who holds a key are all read on open, and the suite's
/// vault are all read from the server on open, and the suite's <c>FakeAccountServer</c> implements /// <c>FakeAccountServer</c> implements <see cref="IAccountApi"/> and nothing else. Rather than teach that
/// <see cref="IAccountApi"/> and nothing else. Rather than teach that fake five more interfaces for one /// fake five more interfaces for one screen, this serves fixed rows and refuses everything a layout test
/// screen, this serves fixed rows and refuses everything a layout test has no business calling. /// has no business calling.
/// </para>
/// <para>
/// It does answer <see cref="CreateTeamVaultAsync"/>, 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.
/// </para> /// </para>
/// <para> /// <para>
/// The rows are deliberately the <em>long</em> ones. A layout suite that measured "Bob" in a column sized /// The rows are deliberately the <em>long</em> ones. A layout suite that measured "Bob" in a column sized
@@ -61,6 +66,9 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
/// <summary>The vault whose key holders are listed, so a test can select it.</summary> /// <summary>The vault whose key holders are listed, so a test can select it.</summary>
internal static Guid TeamVaultId => VaultId; internal static Guid TeamVaultId => VaultId;
/// <summary>The membership list behind that vault, so a test can create it in the session.</summary>
internal static Guid SharedTeamId => TeamId;
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) => public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamSummary>>( Task.FromResult<IReadOnlyList<TeamSummary>>(
@@ -209,10 +217,36 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
Guid invitationId, Guid invitationId,
CancellationToken cancellationToken) => throw new NotSupportedException(); CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc /> /// <summary>
/// Accepts the vault, so a layout test can put a shared one into the session it is drawing.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
public Task<VaultSummary> CreateTeamVaultAsync( public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId, Guid teamId,
CreateTeamVaultRequest request, CreateTeamVaultRequest request,
CancellationToken cancellationToken) =>
Task.FromResult(new VaultSummary(
request.VaultId,
request.Name,
IsPersonal: false,
TeamId: teamId,
KeyGeneration: 1,
Permissions: 31,
request.WrappedVaultKey,
RekeyRequired: true));
/// <inheritdoc />
public Task<VaultSummary> RenameVaultAsync(
Guid vaultId,
UpdateVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException(); CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc /> /// <inheritdoc />
@@ -496,6 +496,48 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return Task.FromResult(vault); return Task.FromResult(vault);
} }
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
public Task<VaultSummary> 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);
}
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync( public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
string email, string email,
@@ -623,11 +623,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null); await vault.ConnectCommand.ExecuteAsync(null);
shell.ShowScreenCommand.Execute(ShellScreen.Vault); shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.IsTerminalShowing.ShouldBeFalse(); shell.IsTerminalShowing.ShouldBeFalse();
shell.IsShowingPages.ShouldBeTrue(); 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 // The session is untouched. Navigating away from a terminal is not a way to end one; only closing
// its tab is. // its tab is.
@@ -671,7 +671,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null); await vault.ConnectCommand.ExecuteAsync(null);
shell.ShowScreenCommand.Execute(ShellScreen.Vault); shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.IsTerminalShowing.ShouldBeFalse(); shell.IsTerminalShowing.ShouldBeFalse();
shell.ShowTerminalCommand.Execute(null); 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 // 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. // navigation that forgets where you were is how a four-button bar becomes annoying.
shell.Screen.ShouldBe(ShellScreen.Vault); shell.Screen.ShouldBe(ShellScreen.Keychain);
} }
/// <remarks> /// <remarks>
@@ -934,7 +934,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null); await vault.ConnectCommand.ExecuteAsync(null);
shell.ShowScreenCommand.Execute(ShellScreen.Vault); shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.OpenConnectSheetCommand.Execute(null); 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 // 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. // 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.IsShowing.ShouldBeFalse();
tab.IsSelected.ShouldBeTrue("navigating away is not deselecting"); tab.IsSelected.ShouldBeTrue("navigating away is not deselecting");
@@ -1346,16 +1346,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectCommand.ExecuteAsync(null); await vault.ConnectCommand.ExecuteAsync(null);
LitEntries().ShouldBe(0); LitEntries().ShouldBe(0);
shell.ShowScreenCommand.Execute(ShellScreen.Vault); shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
LitEntries().ShouldBe(1); LitEntries().ShouldBe(1);
shell.IsVaultShowing.ShouldBeTrue(); shell.IsKeychainShowing.ShouldBeTrue();
int LitEntries() => new[] int LitEntries() => new[]
{ {
shell.IsHostsShowing, shell.IsHostsShowing,
shell.IsTransfersShowing, shell.IsTransfersShowing,
shell.IsVaultShowing, shell.IsKeychainShowing,
shell.IsTeamShowing, shell.IsVaultsShowing,
shell.IsPreferencesShowing, shell.IsPreferencesShowing,
}.Count(lit => lit); }.Count(lit => lit);
} }
@@ -2129,7 +2129,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// rather than four separate lists. /// rather than four separate lists.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheVaultScreenOpensOnEverythingAndTheRailMovesBetweenCategories() public async Task TheKeychainScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
{ {
await UnlockedAsync(); await UnlockedAsync();
var vault = shell.Vault!; var vault = shell.Vault!;
@@ -2223,7 +2223,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// nothing on screen to say it is there. /// nothing on screen to say it is there.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task SwitchingSectionIsRefusedWhileAVaultScreenEditorIsOpen() public async Task SwitchingSectionIsRefusedWhileAKeychainScreenEditorIsOpen()
{ {
await UnlockedAsync(); await UnlockedAsync();
var vault = shell.Vault!; var vault = shell.Vault!;
@@ -2294,7 +2294,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// can see, because it is holding their private key. /// can see, because it is holding their private key.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheHostEditorAndAVaultScreenEditorCanBeOpenTogether() public async Task TheHostEditorAndAKeychainScreenEditorCanBeOpenTogether()
{ {
await UnlockedAsync(); await UnlockedAsync();
var vault = shell.Vault!; var vault = shell.Vault!;
@@ -2317,7 +2317,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// the host editor, which does not. /// the host editor, which does not.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task OnlyOneVaultScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped() public async Task OnlyOneKeychainScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
{ {
await UnlockedAsync(); await UnlockedAsync();
var vault = shell.Vault!; var vault = shell.Vault!;
@@ -2349,7 +2349,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
} }
[Fact] [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 // The Edit commands are a second door into the same screen, and guarding only the Add ones would
// leave it wide open. // 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. /// longer the host editor, which is a different screen and has nothing to lose by the rail moving.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheCredentialEditorGuardsTheVaultScreensRail() public async Task TheCredentialEditorGuardsTheKeychainScreensRail()
{ {
await UnlockedAsync(); await UnlockedAsync();
var vault = shell.Vault!; var vault = shell.Vault!;
@@ -3195,7 +3195,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts); shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
shell.IsKnownHostsShowing.ShouldBeTrue(); shell.IsKnownHostsShowing.ShouldBeTrue();
shell.IsVaultShowing.ShouldBeFalse(); shell.IsKeychainShowing.ShouldBeFalse();
await knownHosts.TrustAsync( await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
@@ -1,687 +0,0 @@
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
/// </summary>
/// <remarks>
/// <para>
/// The reason this suite exists rather than leaving teams to the server's own tests is that the
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
/// lookup with a key of their own.
/// </para>
/// <para>
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
/// sharing works, not that verification does.
/// </para>
/// </remarks>
public sealed class TeamSharingTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
private readonly FakeSshConnectionFactory ssh = new();
private string directory = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
var paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
knownHosts = new VaultKnownHostStore();
deviceKeys = new FakeDeviceKeyStore();
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
ssh,
TimeProvider.System);
shell = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
(_, _) => Task.FromResult<IVaultServer>(server),
TimeProvider.System,
NSubstitute.Substitute.For<ISftpSessionFactory>(),
CheapProfile);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
caches.Dispose();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// A cache file the process has not finished releasing. The directory is under the temp path
// and named per run, so leaving it costs a few kilobytes and never collides.
}
}
/// <remarks>
/// The whole point of a team, in one test. Note what the status line says after the add and before
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
/// than let a user believe the credential is already with their colleague.
/// </remarks>
[Fact]
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
teams.Vaults.Count.ShouldBe(1, teams.Status);
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.Count.ShouldBe(2, teams.Status);
teams.Status.ShouldContain("cannot read anything yet");
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
await teams.ShareVaultCommand.ExecuteAsync(null);
var vaultId = teams.Vaults[0].VaultId;
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
teams.Status.ShouldContain("Shared");
// The one thing verification cannot promise, said in the same breath as the success.
teams.Status.ShouldContain("fingerprint", Case.Insensitive);
}
/// <remarks>
/// <para>
/// The test this whole design exists for. A server that wants to read a team's vault only has to
/// answer one directory lookup with a key it holds the private half of — so the client reads the
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
/// offered is in there unchanged.
/// </para>
/// <para>
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
/// worse than no check at all, because the interface would have said it was verified.
/// </para>
/// </remarks>
[Fact]
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
teams.InviteEmail = "mallory@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
server.CorruptKeyLog = true;
await teams.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldBeEmpty();
teams.Status.ShouldContain("Did not share");
teams.Status.ShouldContain("key log");
}
/// <remarks>
/// A vault created here is usable here, without a relock. The key was generated in this process, so
/// making the user lock and unlock to reach the vault they just made would be asking them to work
/// around bookkeeping.
/// </remarks>
[Fact]
public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
var vaultId = teams.Vaults[0].VaultId;
var session = shell.Vault!.Session;
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
// And it is offered as somewhere to file a new item, which is what makes it worth having.
await shell.Vault.LoadAsync(Token);
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
shell.Vault.HasVaultChoice.ShouldBeTrue();
}
/// <remarks>
/// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
/// with a half-typed host on screen would move it.
/// </remarks>
[Fact]
public async Task AHostFiledIntoATeamVault_StaysThere()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
var vault = shell.Vault!;
var teamVaultId = teams.Vaults[0].VaultId;
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
// Moved back after the editor opened. The host must still land in the team's vault.
vault.SelectedTargetVault =
vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
await vault.SaveHostCommand.ExecuteAsync(null);
var row = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
row.VaultId.ShouldBe(teamVaultId);
}
/// <remarks>
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
/// team vault has to stay there, because hosts are read across every readable vault and so come back.
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
/// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
/// </remarks>
[Fact]
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
var teamVaultId = teams.Vaults[0].VaultId;
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
vault.SelectedTargetVault = vault.TargetVaults.Single(
choice => choice.VaultId == teamVaultId);
vault.NewTagCommand.Execute(null);
vault.TagEditorLabel = "eu-west-1";
await vault.SaveTagCommand.ExecuteAsync(null);
vault.Tags.ShouldHaveSingleItem().Label
.ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
}
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public async Task SelectingATeamVault_ListsWhoHoldsAKeyToIt()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
await teams.ShareVaultCommand.ExecuteAsync(null);
// Selecting the vault again is what drives the read; the share above happened after the
// previous selection had already loaded an empty list.
teams.SelectedVault = null;
teams.SelectedVault = teams.Vaults[0];
var holder = teams.Grants.ShouldHaveSingleItem();
holder.UserId.ShouldBe(colleague);
holder.IsLive.ShouldBeTrue(teams.Status);
holder.State.ShouldBe("holds a key");
}
/// <remarks>
/// A role change is authorization only. The status line has to say so, because the obvious reading
/// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
/// key they were already wrapped. Withdrawing that is a separate act.
/// </remarks>
[Fact]
public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
teams.Status.ShouldContain("does not withdraw a vault key");
}
/// <remarks>
/// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
/// itself rather than letting the server do it: a button that produced a server error would be
/// reporting a rule the screen already knew.
/// </remarks>
[Fact]
public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
teams.Status.ShouldContain("HAND OVER");
}
/// <remarks>
/// <para>
/// Both halves, because a transfer that only promoted the recipient would leave the team owned
/// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
/// a single transaction to make impossible, so the client test asserts the same pair.
/// </para>
/// <para>
/// It also goes through the armed confirmation rather than calling the command directly, since
/// arming and confirming are where the target id is carried — and carrying it on the selection
/// instead is how a confirmation ends up applied to whatever was clicked last.
/// </para>
/// </remarks>
[Fact]
public async Task HandingOverATeam_MakesThemTheOwnerAndTheCallerAnAdmin()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.TransferOwnershipCommand.Execute(null);
teams.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
teams.ShowsTeamActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
teams.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
teams.IsConfirming.ShouldBeFalse();
}
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndSaysWhy()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldContain(team => team.Slug == "platform");
teams.Status.ShouldContain("holding a key");
}
/// <remarks>
/// An empty team can go, and this is the only operation on the screen that removes something from
/// everybody's list at once.
/// </remarks>
[Fact]
public async Task ArchivingAnEmptyTeam_RemovesIt()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldNotContain(team => team.Slug == "platform");
teams.Status.ShouldContain("Archived");
}
/// <remarks>
/// 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.
/// </remarks>
[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'");
}
/// <remarks>
/// <para>
/// The address the directory does not know used to be a dead end — the screen said they had to sign
/// in first and stopped. It invites them instead, from the same button, because which of the two
/// applies is a fact about the server's account table rather than about what the user is doing.
/// </para>
/// <para>
/// The status assertion is the point of the test. Nothing is sent, and an interface that said
/// "invited" without saying that would leave somebody waiting for an email that is never coming.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
var invitation = teams.Invitations.ShouldHaveSingleItem();
invitation.Email.ShouldBe("newcomer@example.com");
invitation.IsPending.ShouldBeTrue();
invitation.State.ShouldContain("Nothing was sent");
teams.Status.ShouldContain("cannot send mail");
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
/// what is true of them — no key, so nothing can be shared with them yet.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "carol@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
teams.Members.Count.ShouldBe(2, teams.Status);
var member = teams.Members.Single(row => row.UserId == colleague);
member.Email.ShouldBe("carol@example.com");
// The label the user asked to see, and the reason SHARE KEY is not the next step.
member.KeyState.ShouldContain("no key yet");
teams.Status.ShouldContain("Added");
teams.Status.ShouldContain("no key yet");
}
/// <remarks>
/// The other half of the pair above: an address with no account at all still falls through to an
/// invitation. It is the server that decides which, so this proves the fall-through survived being
/// moved behind it rather than being replaced by an error.
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "stranger@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
teams.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
}
/// <remarks>
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
/// disappeared would read as never having been sent, which is the same thing the screen looks like
/// before anybody does anything.
/// </remarks>
[Fact]
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedInvitation = teams.Invitations.ShouldHaveSingleItem();
await teams.RevokeInvitationCommand.ExecuteAsync(null);
teams.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
teams.Status.ShouldContain("Withdrew the invitation");
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
/// survives the test. Holding the read open is what makes this behave like a server.
/// </para>
/// </remarks>
[Fact]
public async Task CreatingATeam_ReadsItsMembersOnce()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
teams.NewTeamCommand.Execute(null);
teams.NewTeamName = "Platform";
teams.NewTeamSlug = "platform";
var gate = new TaskCompletionSource();
server.MemberReadGate = gate;
var create = teams.CreateTeamCommand.ExecuteAsync(null);
// Asserted while the read is still in flight: that is the only moment at which a second read
// started by the selection handler is distinguishable from the reload's own.
server.MemberReads.ShouldBe(1, "a reload reads the selected team's members once");
gate.SetResult();
await create;
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
/// <remarks>
/// 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.
/// </remarks>
private async Task CreateVaultAsync(TeamsViewModel teams, string name)
{
teams.NewVaultCommand.Execute(null);
teams.NewVaultName = name;
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);
teams.NewTeamCommand.Execute(null);
teams.NewTeamName = name;
teams.NewTeamSlug = slug;
await teams.CreateTeamCommand.ExecuteAsync(null);
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
}
/// <remarks>
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
/// that entry is what the client verifies its own directory answer against.
/// </remarks>
private async Task UnlockedAsync()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.Passphrase = Passphrase;
shell.ConfirmPassphrase = Passphrase;
await shell.EnrollCommand.ExecuteAsync(null);
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
}
@@ -0,0 +1,767 @@
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Vaults, from the side that holds the keys: make one, add somebody, and wrap its key to them.
/// </summary>
/// <remarks>
/// <para>
/// 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; <b>sharing is a decision the client
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
/// lookup with a key of their own.
/// </para>
/// <para>
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
/// sharing works, not that verification does.
/// </para>
/// <para>
/// It was <c>TeamSharingTests</c>, 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.
/// </para>
/// </remarks>
public sealed class VaultSharingTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
private readonly FakeSshConnectionFactory ssh = new();
private string directory = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
directory = Path.Combine(Path.GetTempPath(), $"dodossh-vaults-{Guid.CreateVersion7():N}");
var paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
knownHosts = new VaultKnownHostStore();
deviceKeys = new FakeDeviceKeyStore();
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
ssh,
TimeProvider.System);
shell = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
(_, _) => Task.FromResult<IVaultServer>(server),
TimeProvider.System,
NSubstitute.Substitute.For<ISftpSessionFactory>(),
CheapProfile);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
caches.Dispose();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// A cache file the process has not finished releasing. The directory is under the temp path
// and named per run, so leaving it costs a few kilobytes and never collides.
}
}
/// <remarks>
/// The whole point of a shared vault, in one test. Note what the status line says after the add and
/// before the share: adding somebody grants them nothing readable, and the interface has to say so
/// rather than let a user believe the credential is already with their colleague.
/// </remarks>
[Fact]
public async Task CreatingAVaultAndSharingIt_WrapsTheKeyToTheOtherMember()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.Count.ShouldBe(2, vaults.Status);
vaults.Status.ShouldContain("cannot read anything yet");
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ShareVaultCommand.ExecuteAsync(null);
var vaultId = vaults.SelectedVault!.VaultId;
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
vaults.Status.ShouldContain("Shared");
// The one thing verification cannot promise, said in the same breath as the success.
vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
/// worse than no check at all, because the interface would have said it was verified.
/// </para>
/// </remarks>
[Fact]
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "mallory@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
server.CorruptKeyLog = true;
await vaults.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldBeEmpty();
vaults.Status.ShouldContain("Did not share");
vaults.Status.ShouldContain("key log");
}
/// <remarks>
/// A vault created here is usable here, without a relock. The key was generated in this process, so
/// making the user lock and unlock to reach the vault they just made would be asking them to work
/// around bookkeeping.
/// </remarks>
[Fact]
public async Task AVaultCreatedHere_IsImmediatelyReadableAndWritable()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
var session = shell.Vault!.Session;
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
// And it is offered as somewhere to file a new item, which is what makes it worth having.
await shell.Vault.LoadAsync(Token);
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
shell.Vault.HasVaultChoice.ShouldBeTrue();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public async Task AHostFiledIntoASharedVault_StaysThere()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
// Moved back after the editor opened. The host must still land in the shared vault: the keychain
// screen's picker seeds the editor's and stops mattering from there.
vault.SelectedTargetVault =
vault.TargetVaults.First(choice => choice.VaultId != sharedVaultId);
await vault.SaveHostCommand.ExecuteAsync(null);
var row = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
row.VaultId.ShouldBe(sharedVaultId);
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
/// shared vault has to stay there, because hosts are read across every readable vault and so come back.
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
/// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
/// </remarks>
[Fact]
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var sharedVaultId = vaults.SelectedVault!.VaultId;
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
vault.SelectedTargetVault = vault.TargetVaults.Single(
choice => choice.VaultId == sharedVaultId);
vault.NewTagCommand.Execute(null);
vault.TagEditorLabel = "eu-west-1";
await vault.SaveTagCommand.ExecuteAsync(null);
vault.Tags.ShouldHaveSingleItem().Label
.ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
}
/// <remarks>
/// The screen's answer to "who can actually open this". Asserted after a share rather than before,
/// because an empty list proves nothing about whether the call was made.
/// </remarks>
[Fact]
public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ShareVaultCommand.ExecuteAsync(null);
var holder = vaults.Grants.ShouldHaveSingleItem();
holder.UserId.ShouldBe(colleague);
holder.IsLive.ShouldBeTrue(vaults.Status);
holder.State.ShouldBe("holds a key");
}
/// <remarks>
/// A role change is authorization only. The status line has to say so, because the obvious reading
/// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
/// key they were already wrapped. Withdrawing that is a separate act.
/// </remarks>
[Fact]
public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
vaults.Status.ShouldContain("does not withdraw a vault key");
}
/// <remarks>
/// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
/// itself rather than letting the server do it: a button that produced a server error would be
/// reporting a rule the screen already knew.
/// </remarks>
[Fact]
public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
vaults.Status.ShouldContain("HAND OVER");
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// It also goes through the armed confirmation rather than calling the command directly, since
/// arming and confirming are where the target ids are carried — and carrying them on the selection
/// instead is how a confirmation ends up applied to whatever was clicked last.
/// </para>
/// </remarks>
[Fact]
public async Task HandingOverAVault_MakesThemTheOwnerAndTheCallerAnAdmin()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
vaults.HandOverCommand.Execute(null);
vaults.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
vaults.ShowsVaultActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
await vaults.ConfirmActionCommand.ExecuteAsync(null);
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
vaults.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
vaults.IsConfirming.ShouldBeFalse();
}
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public async Task RenamingAVault_ReachesTheKeychainScreensPickerToo()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
vaults.RenameVaultCommand.Execute(null);
vaults.EditVaultName = "Platform";
await vaults.SaveVaultNameCommand.ExecuteAsync(null);
vaults.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
vaults.Status.ShouldContain("re-encrypted");
await shell.Vault!.LoadAsync(Token);
shell.Vault.TargetVaults
.Single(choice => choice.VaultId == vaultId)
.Name
.ShouldBe("Platform");
}
/// <remarks>
/// <para>
/// The address the directory does not know used to be a dead end — the screen said they had to sign
/// in first and stopped. It invites them instead, from the same button, because which of the two
/// applies is a fact about the server's account table rather than about what the user is doing.
/// </para>
/// <para>
/// The status assertion is the point of the test. Nothing is sent, and an interface that said
/// "invited" without saying that would leave somebody waiting for an email that is never coming.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "newcomer@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
var invitation = vaults.Invitations.ShouldHaveSingleItem();
invitation.Email.ShouldBe("newcomer@example.com");
invitation.IsPending.ShouldBeTrue();
invitation.State.ShouldContain("Nothing was sent");
vaults.Status.ShouldContain("cannot send mail");
}
/// <remarks>
/// <para>
/// The regression this whole path was rewritten for. An account exists from its owner's first
/// authenticated request and publishes no key until they choose a passphrase on their own machine,
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD quietly
/// issued an invitation instead: the members list did not change, the screen said they had no
/// account here, and they only actually joined on the next hourly sweep.
/// </para>
/// <para>
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
/// what is true of them — no key, so nothing can be shared with them yet.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "carol@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
vaults.Members.Count.ShouldBe(2, vaults.Status);
var member = vaults.Members.Single(row => row.UserId == colleague);
member.Email.ShouldBe("carol@example.com");
// The label the user asked to see, and the reason SHARE KEY is not the next step.
member.KeyState.ShouldContain("no key yet");
vaults.Status.ShouldContain("Added");
vaults.Status.ShouldContain("no key yet");
}
/// <remarks>
/// The other half of the pair above: an address with no account at all still falls through to an
/// invitation. It is the server that decides which, so this proves the fall-through survived being
/// moved behind it rather than being replaced by an error.
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "stranger@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
}
/// <remarks>
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
/// disappeared would read as never having been sent, which is the same thing the screen looks like
/// before anybody does anything.
/// </remarks>
[Fact]
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "newcomer@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
await vaults.RevokeInvitationCommand.ExecuteAsync(null);
vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
vaults.Status.ShouldContain("Withdrew the invitation");
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
/// survives the test. Holding the read open is what makes this behave like a server.
/// </para>
/// </remarks>
[Fact]
public async Task CreatingAVault_ReadsItsMembersOnce()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
var gate = new TaskCompletionSource();
server.MemberReadGate = gate;
var create = vaults.CreateVaultCommand.ExecuteAsync(null);
// Asserted while the read is still in flight: that is the only moment at which a second read
// started by the selection handler is distinguishable from the reload's own.
server.MemberReads.ShouldBe(1, "a reload reads the selected vault's members once");
gate.SetResult();
await create;
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
/// <remarks>
/// 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.
/// </remarks>
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = name;
await vaults.CreateVaultCommand.ExecuteAsync(null);
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
}
/// <remarks>
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
/// that entry is what the client verifies its own directory answer against.
/// </remarks>
private async Task UnlockedAsync()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.Passphrase = Passphrase;
shell.ConfirmPassphrase = Passphrase;
await shell.EnrollCommand.ExecuteAsync(null);
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
}
@@ -6,6 +6,7 @@ using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh; using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage; using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal; using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto; using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests; namespace DodoSSH.Client.App.Tests;
@@ -90,28 +91,31 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
/// <remarks> /// <remarks>
/// The whole feature in one test. A name is all that is asked for, and what comes back is a vault this /// 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 /// machine can already write to, with a membership list this account owns — which is what makes the
/// screen, members and roles and key holders, apply to it. /// rest of the screen, members and roles and key holders, apply to it.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task CreatingAVaultByNameAlone_MakesATeamForItAndOwnsIt() public async Task CreatingAVaultByNameAlone_MakesTheMembershipListForItAndOwnsIt()
{ {
await UnlockedAsync(); await UnlockedAsync();
var teams = shell.Teams; var vaults = shell.Vaults;
await teams.LoadAsync(Token); await vaults.LoadAsync(Token);
await CreateVaultAsync("Platform secrets"); 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.Name.ShouldBe("Platform secrets");
team.Slug.ShouldBe("platform-secrets", "the slug is derived rather than asked for"); 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 shell.Vault!.Session.ReadableVaults
.Select(row => row.VaultId) .Select(row => row.VaultId)
.ShouldContain(vault.VaultId, "a vault made here is usable here, without a relock"); .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. /// next thing anybody making a shared vault wants is the people, and the people are here.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheTeamsScreen() public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheVaultsScreen()
{ {
await UnlockedAsync(); await UnlockedAsync();
var teams = shell.Teams; var vaults = shell.Vaults;
await teams.LoadAsync(Token); await vaults.LoadAsync(Token);
await CreateVaultAsync("Platform secrets"); await CreateVaultAsync("Platform secrets");
teams.SelectedTeam.ShouldNotBeNull(teams.Status); vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
teams.SelectedTeam.Name.ShouldBe("Platform secrets"); vaults.SelectedVault.Name.ShouldBe("Platform secrets");
teams.SelectedVault.ShouldNotBeNull(teams.Status); vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
teams.SelectedVault.Name.ShouldBe("Platform secrets");
} }
/// <remarks> /// <remarks>
/// The failure between the two calls. The team is real and stays — a client that archived it because a /// The failure between the two calls. The membership list is real and is kept for the retry — the
/// later step failed is a client that will one day archive a team somebody has just been added to — so /// sentence has to carry the whole state rather than "creating the vault failed", because pressing
/// the sentence has to carry the whole state rather than "creating the vault failed". /// CREATE again is what finishes the job and cancelling is what undoes it.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task AVaultCreateThatFailsAfterTheTeam_KeepsTheTeamAndSaysSo() public async Task AVaultCreateThatFailsAfterTheMembershipList_KeepsItAndSaysSo()
{ {
await UnlockedAsync(); await UnlockedAsync();
var teams = shell.Teams; var vaults = shell.Vaults;
await teams.LoadAsync(Token); await vaults.LoadAsync(Token);
server.VaultCreateFailures = 1; server.VaultCreateFailures = 1;
teams.NewVaultInItsOwnTeamCommand.Execute(null); vaults.NewVaultCommand.Execute(null);
teams.NewVaultName = "Platform secrets"; vaults.NewVaultName = "Platform secrets";
await teams.CreateVaultCommand.ExecuteAsync(null); await vaults.CreateVaultCommand.ExecuteAsync(null);
teams.Teams.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets"); (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
teams.Vaults.ShouldBeEmpty(); 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"); vaults.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.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
teams.Status.ShouldContain("was created, but its vault was not"); vaults.Status.ShouldContain("was not created");
teams.Status.ShouldContain("Press CREATE again"); vaults.Status.ShouldContain("Press CREATE again");
} }
/// <remarks> /// <remarks>
/// The retry, and the reason the team id is generated once and held rather than per attempt. A second /// The retry, and the reason the 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. /// membership list would be one nothing on this screen could show and nobody could remove.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task RetryingAfterTheVaultCreateFailed_ReusesTheTeamRatherThanMakingASecond() public async Task RetryingAfterTheVaultCreateFailed_ReusesTheMembershipListRatherThanMakingASecond()
{ {
await UnlockedAsync(); await UnlockedAsync();
var teams = shell.Teams; var vaults = shell.Vaults;
await teams.LoadAsync(Token); await vaults.LoadAsync(Token);
server.VaultCreateFailures = 1; server.VaultCreateFailures = 1;
teams.NewVaultInItsOwnTeamCommand.Execute(null); vaults.NewVaultCommand.Execute(null);
teams.NewVaultName = "Platform secrets"; 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 // Pressed again on the form that is still open, which is exactly what the message tells the user
// to do. // to do.
await teams.CreateVaultCommand.ExecuteAsync(null); await vaults.CreateVaultCommand.ExecuteAsync(null);
teams.Teams.ShouldHaveSingleItem().TeamId.ShouldBe(teamId); (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
teams.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets"); vaults.Vaults.ShouldContain(
teams.IsCreatingVault.ShouldBeFalse(teams.Status); row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
}
/// <remarks>
/// 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.
/// </remarks>
[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");
} }
/// <remarks> /// <remarks>
@@ -209,15 +241,15 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
{ {
await UnlockedAsync(); await UnlockedAsync();
var teams = shell.Teams; var vaults = shell.Vaults;
await teams.LoadAsync(Token); await vaults.LoadAsync(Token);
server.TakenSlugs.Add("platform-secrets"); server.TakenSlugs.Add("platform-secrets");
await CreateVaultAsync("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.Name.ShouldBe("Platform secrets", "the name is what the user typed");
team.Slug.ShouldStartWith("platform-secrets-"); team.Slug.ShouldStartWith("platform-secrets-");
@@ -226,20 +258,20 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
/// <remarks> /// <remarks>
/// 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 /// 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 /// vault a person can make, so the fallback is an id rather than a refusal pointing at a field that
/// field that does not exist. /// does not exist.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug() public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug()
{ {
await UnlockedAsync(); await UnlockedAsync();
var teams = shell.Teams; var vaults = shell.Vaults;
await teams.LoadAsync(Token); await vaults.LoadAsync(Token);
await CreateVaultAsync("διαχείριση"); await CreateVaultAsync("διαχείριση");
var team = teams.Teams.ShouldHaveSingleItem(); var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
team.Name.ShouldBe("διαχείριση"); team.Name.ShouldBe("διαχείριση");
team.Slug.ShouldStartWith("vault-"); team.Slug.ShouldStartWith("vault-");
@@ -455,7 +487,9 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
shell.HasVaultSwitches.ShouldBeTrue(); shell.HasVaultSwitches.ShouldBeTrue();
shell.VaultToggles.Count.ShouldBe(2); shell.VaultToggles.Count.ShouldBe(2);
shell.VaultToggles[0].IsPersonal.ShouldBeTrue(); 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 ---- // ---- Helpers ----
@@ -475,22 +509,22 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
/// <summary>Names a vault, from the form the tab strip's menu opens.</summary> /// <summary>Names a vault, from the form the tab strip's menu opens.</summary>
private async Task<Guid> CreateVaultAsync(string name) private async Task<Guid> CreateVaultAsync(string name)
{ {
var teams = shell.Teams; var vaults = shell.Vaults;
teams.NewVaultInItsOwnTeamCommand.Execute(null); vaults.NewVaultCommand.Execute(null);
teams.NewVaultName = name; 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; .VaultId;
} }
private async Task<Guid> VaultWithAHostAsync(string vaultName, string hostLabel) private async Task<Guid> VaultWithAHostAsync(string vaultName, string hostLabel)
{ {
await shell.Teams.LoadAsync(Token); await shell.Vaults.LoadAsync(Token);
var vaultId = await CreateVaultAsync(vaultName); var vaultId = await CreateVaultAsync(vaultName);
@@ -501,7 +535,7 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
private async Task<Guid> VaultWithAKeyAsync(string vaultName, string keyLabel) private async Task<Guid> VaultWithAKeyAsync(string vaultName, string keyLabel)
{ {
await shell.Teams.LoadAsync(Token); await shell.Vaults.LoadAsync(Token);
var vaultId = await CreateVaultAsync(vaultName); var vaultId = await CreateVaultAsync(vaultName);
var vault = shell.Vault!; var vault = shell.Vault!;