Public Access
Merge branch 'claude/main-page-group-hierarchy-3a3210'
This commit is contained in:
@@ -231,18 +231,30 @@ What is not here: transferring a directory, dragging between the panes, and rout
|
|||||||
bastion — the last needs jump hosts the connection layer has not got. All three are in
|
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.
|
||||||
@@ -251,14 +263,14 @@ published in a log every other client also reads. **It does not prove the key is
|
|||||||
Compare the fingerprint with them over something this server does not carry; that is the only step that
|
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
|
||||||
@@ -270,9 +282,9 @@ you the public key you are about to verify and wrap a vault to, and an invitatio
|
|||||||
there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful
|
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.
|
||||||
|
|
||||||
@@ -280,50 +292,46 @@ rather than waiting for a first sign-in that has already happened.
|
|||||||
to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or
|
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 rotate every team vault, and somebody handing
|
key grants; removing you would revoke them and rotate the vault, and somebody handing a vault over is
|
||||||
over a team is usually staying in it.
|
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
|
Six 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, rotates every team vault your machine
|
archiving it would take the vault away from everybody holding a key, including you, quietly and all at
|
||||||
can open, and hands each new key to the members who are left — so nothing written from that point on is
|
once. The screen says so where you would otherwise go looking for the button.
|
||||||
readable to them. Everything they already pulled is still on their machine. Rotate the SSH credentials
|
- **Removing a member is not retroactive.** It revokes their grants, rotates every vault behind that
|
||||||
that matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
|
membership list your machine can open, and hands each new key to the members who are left — so nothing
|
||||||
|
written from that point on is readable to them. Everything they already pulled is still on their machine.
|
||||||
|
Rotate the SSH credentials that matter — that is the actual remediation, and it is why there is no button
|
||||||
|
labelled anything stronger.
|
||||||
- **A rotation moves the vault's contents too, and says so when it could not.** The key changes first, in
|
- **A rotation moves the vault's contents too, and says so when it could not.** The key changes first, in
|
||||||
one server transaction; then every item already stored is re-sealed under it, so the key somebody left
|
one server transaction; then every item already stored is re-sealed under it, so the key somebody left
|
||||||
with opens nothing that is still here. Existing items keep working throughout — everybody still in the
|
with opens nothing that is still here. Existing items keep working throughout — everybody still in the
|
||||||
team holds the older keys as well as the new one, which is what stops a half-finished rotation making a
|
vault holds the older keys as well as the new one, which is what stops a half-finished rotation making a
|
||||||
vault unreadable, and what makes the pass safe to interrupt and run again. An item somebody else was
|
vault unreadable, and what makes the pass safe to interrupt and run again. An item somebody else was
|
||||||
editing at that moment is left for the next pass, and the message tells you which of the two you got.
|
editing at that moment is left for the next pass, and the message tells you which of the two you got.
|
||||||
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
|
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
|
||||||
- **Adding a member shares the vaults you can open, including their history.** Membership is still one act
|
- **Adding somebody shares the vaults you can open, including their history.** Membership is still one act
|
||||||
and a key is still another — nothing changed about that — but the client now performs the second one for
|
and a key is still another — nothing changed about that — but the client now performs the second one for
|
||||||
you, wrapping every generation it holds so the new member can read the vault back to its first item. A
|
you, wrapping every generation it holds so the new member can read the vault back to its first item. A
|
||||||
vault your machine holds no key to is skipped and says so; somebody who holds it has to share that one.
|
vault your machine holds no key to is skipped and says so; somebody who holds it has to share that one.
|
||||||
- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used
|
- **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
|
||||||
@@ -331,9 +339,14 @@ Four limits, stated rather than discovered:
|
|||||||
as roughly-when rather than to the minute, because showing it to the minute would be reading a precision
|
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
|
||||||
|
|
||||||
@@ -345,7 +358,7 @@ fit 360dp.
|
|||||||
|
|
||||||
Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the
|
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
|
||||||
@@ -397,9 +410,9 @@ confirmation — without offering to change it. What this head does make, it mak
|
|||||||
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's
|
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
|
||||||
@@ -603,15 +616,16 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
|
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.* Teams with roles, a public-key directory, the
|
- **M3 — shared vaults**, sharing, ACLs. *Done.* Membership with roles, a public-key directory, the
|
||||||
append-only key log served for clients to verify against, team-owned vaults, and vault key grants
|
append-only key log served for clients to verify against, shared vaults, and vault key grants
|
||||||
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -121,6 +121,36 @@ Three decisions inside it belong here, because each had a more convenient altern
|
|||||||
an address already belonging to a member of *this* team is refused, and that is a fact the caller can
|
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
@@ -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
|
||||||
|
|||||||
+52
-44
@@ -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,18 +1147,22 @@ follows is about what happens the first time it does.
|
|||||||
|
|
||||||
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
|
### 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. This is where an invitation differs from an add and the difference is not a defect: adding an
|
||||||
|
account the directory already knows wraps the key on the spot, because the adding machine has it and
|
||||||
|
the recipient has a published key to wrap to. An invitation has neither at the moment it is issued —
|
||||||
|
there is no account yet — and the claim happens on Bob's machine, which holds nothing. So the key is
|
||||||
|
still owed, and somebody has to hand it over.
|
||||||
|
|
||||||
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
|
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
|
||||||
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
|
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
|
||||||
@@ -1171,15 +1175,15 @@ reached that machine by a route this architecture says does not exist.
|
|||||||
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
|
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 +1192,27 @@ under some other name.
|
|||||||
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
|
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 +1231,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// One row per holder, not one per grant. A rotated vault holds several grants per member — one per
|
/// One row per holder, not one per grant. A rotated vault holds several grants per member — one per
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -574,6 +587,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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
+2
-2
@@ -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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+139
-144
@@ -2,141 +2,140 @@
|
|||||||
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">
|
The name-a-vault form, in place rather than in a modal: this window has no idiom for one. It is
|
||||||
|
in this column rather than beside the pane on the right for one reason — that pane is bound to
|
||||||
|
HasSelection, so with no vaults at all it is not on screen, and "no vaults at all" is exactly
|
||||||
|
the state somebody arrives in from the tab strip's New vault entry.
|
||||||
|
|
||||||
<Border Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
One field. The membership list behind it is made with it and named after it, and its slug is
|
||||||
IsVisible="{Binding IsCreatingTeam}">
|
derived — see VaultsViewModel.CreateVaultAsync. Asking for a URL handle would be asking for one
|
||||||
<StackPanel Spacing="8">
|
from somebody who has not been told they are making anything but a vault.
|
||||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
-->
|
||||||
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||||
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
IsVisible="{Binding IsCreatingVault}">
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
<StackPanel Spacing="8">
|
||||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
<TextBlock Classes="label" Text="NEW VAULT" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" />
|
||||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
</StackPanel>
|
<Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewVaultCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
<!--
|
|
||||||
The name-a-vault form, and it is in this column rather than beside the VAULTS list it belongs to
|
|
||||||
for one reason: that list lives inside a ScrollViewer bound to HasSelection, so with no teams at
|
|
||||||
all it is not on screen — and "no teams at all" is exactly the state somebody arrives in from
|
|
||||||
the tab strip's New vault entry. Here it is reachable whatever else is true.
|
|
||||||
|
|
||||||
One field. A team is made behind it and named after the vault, and its slug is derived — see
|
|
||||||
TeamsViewModel.CreateVaultAsync. Asking for a slug as the form above does would be asking for a
|
|
||||||
URL handle from somebody who has not been told they are making a team.
|
|
||||||
-->
|
|
||||||
<Border Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
|
||||||
IsVisible="{Binding IsCreatingVault}">
|
|
||||||
<StackPanel Spacing="8">
|
|
||||||
<TextBlock Classes="label" Text="NEW VAULT" />
|
|
||||||
<TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" />
|
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
|
||||||
Text="{Binding NewVaultDestination}" />
|
|
||||||
<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">
|
|
||||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewVaultCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</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();
|
||||||
|
}
|
||||||
@@ -151,6 +151,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))
|
||||||
|
|||||||
+711
-677
File diff suppressed because it is too large
Load Diff
@@ -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(RekeyVaultRequest))]
|
[JsonSerializable(typeof(RekeyVaultRequest))]
|
||||||
[JsonSerializable(typeof(VaultGrantsResponse))]
|
[JsonSerializable(typeof(VaultGrantsResponse))]
|
||||||
|
|||||||
@@ -679,6 +679,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
|
||||||
@@ -867,6 +874,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!
|
||||||
@@ -960,6 +970,8 @@ static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator !=(DodoSSH.Contra
|
|||||||
static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator ==(DodoSSH.Contracts.TransferTeamOwnershipRequest? left, DodoSSH.Contracts.TransferTeamOwnershipRequest? right) -> bool
|
static DodoSSH.Contracts.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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1284,6 +1412,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 />
|
||||||
|
|||||||
@@ -541,6 +541,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);
|
||||||
|
|||||||
+361
-288
@@ -12,11 +12,11 @@ using DodoSSH.Crypto;
|
|||||||
namespace DodoSSH.Client.App.Tests;
|
namespace DodoSSH.Client.App.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
|
/// Vaults, from the side that holds the keys: make one, add somebody, and wrap its key to them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The reason this suite exists rather than leaving teams to the server's own tests is that the
|
/// The reason this suite exists rather than leaving sharing to the server's own tests is that the
|
||||||
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
|
/// 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
|
/// 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
|
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
|
||||||
@@ -27,8 +27,14 @@ namespace DodoSSH.Client.App.Tests;
|
|||||||
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
|
/// 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.
|
/// sharing works, not that verification does.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
public sealed class TeamSharingTests : IAsyncLifetime
|
public sealed class VaultSharingTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private const string Passphrase = "a sufficiently long passphrase";
|
private const string Passphrase = "a sufficiently long passphrase";
|
||||||
|
|
||||||
@@ -50,7 +56,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask InitializeAsync()
|
public ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
|
directory = Path.Combine(Path.GetTempPath(), $"dodossh-vaults-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
var paths = new ClientPaths(directory);
|
var paths = new ClientPaths(directory);
|
||||||
|
|
||||||
@@ -98,35 +104,64 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The whole point of a team, in one test. Adding somebody wraps every team vault this machine can
|
/// The whole point of a shared vault, in one test. Adding somebody wraps the vault to them, so the
|
||||||
/// open to them, so the status line names what they were given rather than what is still owed —
|
/// status line names what they were given rather than what is still owed — and the grant is on the
|
||||||
/// and the grant is on the server before the add has finished reporting.
|
/// server before the add has finished reporting.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddingAMember_WrapsEveryTeamVaultThisMachineHoldsToThem()
|
public async Task AddingSomebody_WrapsTheVaultToThemStraightAway()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
teams.Vaults.Count.ShouldBe(1, teams.Status);
|
|
||||||
|
|
||||||
var vaultId = teams.Vaults[0].VaultId;
|
vaults.InviteEmail = "bob@example.com";
|
||||||
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
teams.Members.Count.ShouldBe(2, teams.Status);
|
|
||||||
|
|
||||||
server.IssuedGrants.ShouldContainKey(
|
server.IssuedGrants.ShouldContainKey(
|
||||||
(vaultId, colleague),
|
(vaultId, colleague),
|
||||||
"adding somebody to a team is what shares its vaults with them");
|
"adding somebody to a vault is what shares it with them");
|
||||||
|
|
||||||
teams.Status.ShouldContain("Platform secrets");
|
vaults.Status.ShouldContain("Platform secrets");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The manual path still works and is still worth having: a vault whose key this machine did not
|
||||||
|
/// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
|
||||||
|
/// somebody who already holds the key is the same call, and the server replaces the row rather than
|
||||||
|
/// adding a second one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
|
vaults.InviteEmail = "bob@example.com";
|
||||||
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||||
|
|
||||||
|
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
||||||
|
vaults.Status.ShouldContain("Shared");
|
||||||
|
|
||||||
|
// The one thing verification cannot promise, said in the same breath as the success.
|
||||||
|
vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -141,38 +176,37 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemovingAMember_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
|
public async Task RemovingSomebody_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var leaving = server.AddAccount("bob@example.com", "Bob Example");
|
var leaving = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
var staying = server.AddAccount("carol@example.com", "Carol Example");
|
var staying = server.AddAccount("carol@example.com", "Carol Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
var vaultId = teams.Vaults[0].VaultId;
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
|
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
|
||||||
{
|
{
|
||||||
teams.InviteEmail = address;
|
vaults.InviteEmail = address;
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
teams.Members.Count.ShouldBe(3, teams.Status);
|
vaults.Members.Count.ShouldBe(3, vaults.Status);
|
||||||
|
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == leaving);
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == leaving);
|
||||||
|
|
||||||
await teams.RemoveMemberCommand.ExecuteAsync(null);
|
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.Status.ShouldContain("Rotated", customMessage: teams.Status);
|
vaults.Status.ShouldContain("Rotated", customMessage: vaults.Status);
|
||||||
teams.Status.ShouldContain("Platform secrets");
|
vaults.Status.ShouldContain("Platform secrets");
|
||||||
|
|
||||||
// The last act of a rotation is moving what is already stored onto the new key. Proven by the
|
// The last act of a rotation is moving what is already stored onto the new key. Proven by the
|
||||||
// bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
|
// bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
|
||||||
// and says which of the two guarantees the user has ended up with.
|
// and says which of the two guarantees the user has ended up with.
|
||||||
teams.Status.ShouldContain("re-sealed under the new key", customMessage: teams.Status);
|
vaults.Status.ShouldContain("re-sealed under the new key", customMessage: vaults.Status);
|
||||||
|
|
||||||
// Gone entirely, at every generation. A revocation that left the history behind would leave them
|
// Gone entirely, at every generation. A revocation that left the history behind would leave them
|
||||||
// able to read everything written before they went, from a copy of the ciphertext.
|
// able to read everything written before they went, from a copy of the ciphertext.
|
||||||
@@ -189,70 +223,35 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
/// reads as corruption rather than as the missing grant it is.
|
/// reads as corruption rather than as the missing grant it is.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddingAMemberToARotatedVault_HandsThemItsHistoryAsWell()
|
public async Task AddingSomebodyToARotatedVault_HandsThemItsHistoryAsWell()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var first = server.AddAccount("bob@example.com", "Bob Example");
|
var first = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
var second = server.AddAccount("carol@example.com", "Carol Example");
|
var second = server.AddAccount("carol@example.com", "Carol Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
var vaultId = teams.Vaults[0].VaultId;
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
vaults.InviteEmail = "bob@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
|
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
|
||||||
// with a history rather than one that has only ever had a single key.
|
// with a history rather than one that has only ever had a single key.
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == first);
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
|
||||||
await teams.RemoveMemberCommand.ExecuteAsync(null);
|
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.InviteEmail = "carol@example.com";
|
vaults.InviteEmail = "carol@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], teams.Status);
|
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The manual path still works and is still worth having: a vault whose key this machine did not
|
|
||||||
/// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
|
|
||||||
/// somebody who already holds the key is the same call, and the server replaces the row rather than
|
|
||||||
/// adding a second one.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
|
|
||||||
{
|
|
||||||
await UnlockedAsync();
|
|
||||||
|
|
||||||
var teams = shell.Teams;
|
|
||||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
|
||||||
teams.SelectedVault = teams.Vaults[0];
|
|
||||||
|
|
||||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
var vaultId = teams.Vaults[0].VaultId;
|
|
||||||
|
|
||||||
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
|
||||||
teams.Status.ShouldContain("Shared");
|
|
||||||
|
|
||||||
// The one thing verification cannot promise, said in the same breath as the success.
|
|
||||||
teams.Status.ShouldContain("fingerprint", Case.Insensitive);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The test this whole design exists for. A server that wants to read a team's vault only has to
|
/// The test this whole design exists for. A server that wants to read a shared vault only has to
|
||||||
/// answer one directory lookup with a key it holds the private half of — so the client reads the
|
/// 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
|
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
|
||||||
/// offered is in there unchanged.
|
/// offered is in there unchanged.
|
||||||
@@ -267,33 +266,31 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
// Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
|
// Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
|
||||||
// that corrupted the log afterwards would be asserting about the second one only.
|
// that corrupted the log afterwards would be asserting about the second one only.
|
||||||
server.CorruptKeyLog = true;
|
server.CorruptKeyLog = true;
|
||||||
|
|
||||||
teams.InviteEmail = "mallory@example.com";
|
vaults.InviteEmail = "mallory@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
var vaultId = teams.Vaults[0].VaultId;
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
||||||
teams.Status.ShouldContain("Could not share");
|
vaults.Status.ShouldContain("Could not share");
|
||||||
teams.Status.ShouldContain("key log");
|
vaults.Status.ShouldContain("key log");
|
||||||
|
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||||
teams.SelectedVault = teams.Vaults[0];
|
|
||||||
|
|
||||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
||||||
teams.Status.ShouldContain("Did not share");
|
vaults.Status.ShouldContain("Did not share");
|
||||||
teams.Status.ShouldContain("key log");
|
vaults.Status.ShouldContain("key log");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -302,16 +299,15 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
/// around bookkeeping.
|
/// around bookkeeping.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
|
public async Task AVaultCreatedHere_IsImmediatelyReadableAndWritable()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
var vaultId = teams.Vaults[0].VaultId;
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
var session = shell.Vault!.Session;
|
var session = shell.Vault!.Session;
|
||||||
|
|
||||||
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
|
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
|
||||||
@@ -324,47 +320,183 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
|
/// Making a vault makes exactly one membership list, and this is the assertion that the two-step create
|
||||||
|
/// has not started leaking them: the screen no longer offers to make one on its own, so a second one
|
||||||
|
/// per vault would be invisible in the interface and visible only to an operator.
|
||||||
|
/// </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
|
/// 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.
|
/// with a half-typed host on screen would move it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AHostFiledIntoATeamVault_StaysThere()
|
public async Task AHostFiledIntoASharedVault_StaysThere()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
var teamVaultId = teams.Vaults[0].VaultId;
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
await vault.LoadAsync(Token);
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
vault.SelectedTargetVault =
|
vault.SelectedTargetVault =
|
||||||
vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
|
vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
vault.EditorLabel = "prod-db";
|
vault.EditorLabel = "prod-db";
|
||||||
vault.EditorHostname = "db.internal";
|
vault.EditorHostname = "db.internal";
|
||||||
vault.EditorUsername = "deploy";
|
vault.EditorUsername = "deploy";
|
||||||
|
|
||||||
// Moved back after the editor opened. The host must still land in the team's vault.
|
// Moved back after the editor opened. The host must still land in the shared vault: the keychain
|
||||||
|
// screen's picker seeds the editor's and stops mattering from there.
|
||||||
vault.SelectedTargetVault =
|
vault.SelectedTargetVault =
|
||||||
vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
|
vault.TargetVaults.First(choice => choice.VaultId != sharedVaultId);
|
||||||
|
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
var row = vault.Hosts.Single(
|
var row = vault.Hosts.Single(
|
||||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||||
row.VaultId.ShouldBe(teamVaultId);
|
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>
|
/// <remarks>
|
||||||
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
||||||
/// team vault has to stay there, because hosts are read across every readable vault and so come back.
|
/// shared vault has to stay there, because hosts are read across every readable vault and so come back.
|
||||||
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
|
/// 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
|
/// 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.
|
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
|
||||||
@@ -374,12 +506,11 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
var teamVaultId = teams.Vaults[0].VaultId;
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await vault.LoadAsync(Token);
|
await vault.LoadAsync(Token);
|
||||||
@@ -387,7 +518,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
|
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
|
||||||
|
|
||||||
vault.SelectedTargetVault = vault.TargetVaults.Single(
|
vault.SelectedTargetVault = vault.TargetVaults.Single(
|
||||||
choice => choice.VaultId == teamVaultId);
|
choice => choice.VaultId == sharedVaultId);
|
||||||
|
|
||||||
vault.NewTagCommand.Execute(null);
|
vault.NewTagCommand.Execute(null);
|
||||||
vault.TagEditorLabel = "eu-west-1";
|
vault.TagEditorLabel = "eu-west-1";
|
||||||
@@ -398,42 +529,30 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The screen's answer to "who can actually open this", which until now it could not give at all —
|
/// The screen's answer to "who can actually open this". Asserted after somebody has been added
|
||||||
/// the endpoint existed and nothing called it. Asserted after a share rather than before, because
|
/// rather than before, because an empty list proves nothing about whether the call was made.
|
||||||
/// an empty list proves nothing about whether the call was made.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SelectingATeamVault_ListsWhoHoldsAKeyToIt()
|
public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
vaults.InviteEmail = "bob@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
// Two, and the creator is the other: their own self-grant is what makes a vault they just made
|
||||||
teams.SelectedVault = teams.Vaults[0];
|
// readable at all, so a list that left it out would show the one person who can certainly open
|
||||||
|
// this vault as somebody who cannot.
|
||||||
|
vaults.Grants.Count.ShouldBe(2, vaults.Status);
|
||||||
|
|
||||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
var holder = vaults.Grants.Single(row => row.UserId == colleague);
|
||||||
|
|
||||||
// Selecting the vault again is what drives the read; the share above happened after the
|
holder.IsLive.ShouldBeTrue(vaults.Status);
|
||||||
// previous selection had already loaded an empty list.
|
|
||||||
teams.SelectedVault = null;
|
|
||||||
teams.SelectedVault = teams.Vaults[0];
|
|
||||||
|
|
||||||
// Two, and the second one matters: the creator's own grant is recorded when the vault is made,
|
|
||||||
// so a list that showed only the people it was shared with would be describing a vault its
|
|
||||||
// owner cannot open.
|
|
||||||
teams.Grants.Count.ShouldBe(2, teams.Status);
|
|
||||||
|
|
||||||
var holder = teams.Grants.Single(row => row.UserId == colleague);
|
|
||||||
|
|
||||||
holder.IsLive.ShouldBeTrue(teams.Status);
|
|
||||||
holder.State.ShouldBe("holds a key");
|
holder.State.ShouldBe("holds a key");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,20 +566,20 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
vaults.InviteEmail = "bob@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||||
|
|
||||||
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
|
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
|
||||||
|
|
||||||
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
|
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
|
||||||
teams.Status.ShouldContain("does not withdraw a vault key");
|
vaults.Status.ShouldContain("does not withdraw a vault key");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -473,126 +592,91 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
vaults.InviteEmail = "bob@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||||
|
|
||||||
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
|
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
|
||||||
|
|
||||||
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
|
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
|
||||||
teams.Status.ShouldContain("HAND OVER");
|
vaults.Status.ShouldContain("HAND OVER");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Both halves, because a transfer that only promoted the recipient would leave the team owned
|
/// Both halves, because a transfer that only promoted the recipient would leave the vault owned
|
||||||
/// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
|
/// 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.
|
/// a single transaction to make impossible, so the client test asserts the same pair.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// It also goes through the armed confirmation rather than calling the command directly, since
|
/// It also goes through the armed confirmation rather than calling the command directly, since
|
||||||
/// arming and confirming are where the target id is carried — and carrying it on the selection
|
/// arming and confirming are where the target ids are carried — and carrying them on the selection
|
||||||
/// instead is how a confirmation ends up applied to whatever was clicked last.
|
/// instead is how a confirmation ends up applied to whatever was clicked last.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandingOverATeam_MakesThemTheOwnerAndTheCallerAnAdmin()
|
public async Task HandingOverAVault_MakesThemTheOwnerAndTheCallerAnAdmin()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
vaults.InviteEmail = "bob@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||||
|
|
||||||
teams.TransferOwnershipCommand.Execute(null);
|
vaults.HandOverCommand.Execute(null);
|
||||||
|
|
||||||
teams.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
|
vaults.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
|
||||||
teams.ShowsTeamActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
|
vaults.ShowsVaultActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
|
||||||
|
|
||||||
await teams.ConfirmActionCommand.ExecuteAsync(null);
|
await vaults.ConfirmActionCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
|
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
|
||||||
teams.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
|
vaults.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
|
||||||
teams.IsConfirming.ShouldBeFalse();
|
vaults.IsConfirming.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Archiving is refused while the team owns a vault, and the refusal has to reach the screen. The
|
/// Renaming reaches the rest of the shell, which is the half a client can get wrong quietly: the name
|
||||||
/// failure this guards is the quiet one: a client that swallowed the 409 and reloaded would show a
|
/// is drawn on the badge of every host card in a session holding more than one vault, in the
|
||||||
/// team that is still there with no explanation of why nothing happened.
|
/// file-this-into picker, and in the tab strip's menu.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndSaysWhy()
|
public async Task RenamingAVault_ReachesTheKeychainScreensPickerToo()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
await CreateVaultAsync(teams, "Platform secrets");
|
|
||||||
|
|
||||||
teams.ArchiveTeamCommand.Execute(null);
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
await teams.ConfirmActionCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
teams.Teams.ShouldContain(team => team.Slug == "platform");
|
vaults.RenameVaultCommand.Execute(null);
|
||||||
teams.Status.ShouldContain("holding a key");
|
vaults.EditVaultName = "Platform";
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
await vaults.SaveVaultNameCommand.ExecuteAsync(null);
|
||||||
/// 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;
|
vaults.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
|
||||||
|
vaults.Status.ShouldContain("re-encrypted");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await shell.Vault!.LoadAsync(Token);
|
||||||
|
|
||||||
teams.ArchiveTeamCommand.Execute(null);
|
shell.Vault.TargetVaults
|
||||||
await teams.ConfirmActionCommand.ExecuteAsync(null);
|
.Single(choice => choice.VaultId == vaultId)
|
||||||
|
.Name
|
||||||
teams.Teams.ShouldNotContain(team => team.Slug == "platform");
|
.ShouldBe("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>
|
/// <remarks>
|
||||||
@@ -611,22 +695,22 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "newcomer@example.com";
|
vaults.InviteEmail = "newcomer@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||||
|
|
||||||
var invitation = teams.Invitations.ShouldHaveSingleItem();
|
var invitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||||
|
|
||||||
invitation.Email.ShouldBe("newcomer@example.com");
|
invitation.Email.ShouldBe("newcomer@example.com");
|
||||||
invitation.IsPending.ShouldBeTrue();
|
invitation.IsPending.ShouldBeTrue();
|
||||||
invitation.State.ShouldContain("Nothing was sent");
|
invitation.State.ShouldContain("Nothing was sent");
|
||||||
|
|
||||||
teams.Status.ShouldContain("cannot send mail");
|
vaults.Status.ShouldContain("cannot send mail");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -634,9 +718,9 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
/// The regression this whole path was rewritten for. An account exists from its owner's first
|
/// 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,
|
/// 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
|
/// 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
|
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD quietly
|
||||||
/// quietly issued an invitation instead: the members list did not change, the screen said they had
|
/// issued an invitation instead: the members list did not change, the screen said they had no
|
||||||
/// no account here, and they only actually joined on the next hourly sweep.
|
/// account here, and they only actually joined on the next hourly sweep.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
|
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
|
||||||
@@ -648,27 +732,27 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
|
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "carol@example.com";
|
vaults.InviteEmail = "carol@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
|
vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
|
||||||
|
|
||||||
teams.Members.Count.ShouldBe(2, teams.Status);
|
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||||
|
|
||||||
var member = teams.Members.Single(row => row.UserId == colleague);
|
var member = vaults.Members.Single(row => row.UserId == colleague);
|
||||||
|
|
||||||
member.Email.ShouldBe("carol@example.com");
|
member.Email.ShouldBe("carol@example.com");
|
||||||
|
|
||||||
// The label the user asked to see, and the reason SHARE KEY is not the next step.
|
// The label the user asked to see, and the reason SHARE KEY is not the next step.
|
||||||
member.KeyState.ShouldContain("no key yet");
|
member.KeyState.ShouldContain("no key yet");
|
||||||
|
|
||||||
teams.Status.ShouldContain("Added");
|
vaults.Status.ShouldContain("Added");
|
||||||
teams.Status.ShouldContain("no key yet");
|
vaults.Status.ShouldContain("no key yet");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -681,15 +765,15 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "stranger@example.com";
|
vaults.InviteEmail = "stranger@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||||
teams.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
|
vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -702,28 +786,28 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "newcomer@example.com";
|
vaults.InviteEmail = "newcomer@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.SelectedInvitation = teams.Invitations.ShouldHaveSingleItem();
|
vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||||
|
|
||||||
await teams.RevokeInvitationCommand.ExecuteAsync(null);
|
await vaults.RevokeInvitationCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
teams.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
|
vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
|
||||||
teams.Status.ShouldContain("Withdrew the invitation");
|
vaults.Status.ShouldContain("Withdrew the invitation");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A reload rebuilds the team list and reselects, so a reload that changed the selection — creating
|
/// A reload rebuilds the vault 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
|
/// the first shared vault is exactly that — used to leave two reads of the same membership list in
|
||||||
/// reload awaits, and one the selection handler started on its own. Both clear the member list and
|
/// flight: the one the reload awaits, and one the selection handler started on its own. Both clear the
|
||||||
/// then both append to it, so every member was drawn twice. On a team nobody has been added to yet,
|
/// member list and then both append to it, so every member was drawn twice. On a vault nobody has been
|
||||||
/// whose only member is its owner, that read as the owner being in the team twice.
|
/// added to yet, whose only member is its owner, that read as the owner being in it twice.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
|
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
|
||||||
@@ -732,61 +816,50 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreatingATeam_ReadsItsMembersOnce()
|
public async Task CreatingAVault_ReadsItsMembersOnce()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var teams = shell.Teams;
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
await teams.LoadAsync(Token);
|
await vaults.LoadAsync(Token);
|
||||||
|
|
||||||
teams.NewTeamCommand.Execute(null);
|
vaults.NewVaultCommand.Execute(null);
|
||||||
teams.NewTeamName = "Platform";
|
vaults.NewVaultName = "Platform secrets";
|
||||||
teams.NewTeamSlug = "platform";
|
|
||||||
|
|
||||||
var gate = new TaskCompletionSource();
|
var gate = new TaskCompletionSource();
|
||||||
|
|
||||||
server.MemberReadGate = gate;
|
server.MemberReadGate = gate;
|
||||||
|
|
||||||
var create = teams.CreateTeamCommand.ExecuteAsync(null);
|
var create = vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
// Asserted while the read is still in flight: that is the only moment at which a second read
|
// 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.
|
// started by the selection handler is distinguishable from the reload's own.
|
||||||
server.MemberReads.ShouldBe(1, "a reload reads the selected team's members once");
|
server.MemberReads.ShouldBe(1, "a reload reads the selected vault's members once");
|
||||||
|
|
||||||
gate.SetResult();
|
gate.SetResult();
|
||||||
|
|
||||||
await create;
|
await create;
|
||||||
|
|
||||||
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Through the form rather than straight at the command, because the name is what the form is for: a
|
/// Through the form rather than straight at the command, because the name is what the form is for —
|
||||||
/// vault used to be named after its team, which gave a team with three of them three vaults called the
|
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
|
||||||
/// same thing.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task CreateVaultAsync(TeamsViewModel teams, string name)
|
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
|
||||||
{
|
{
|
||||||
teams.NewVaultCommand.Execute(null);
|
await vaults.LoadAsync(Token);
|
||||||
teams.NewVaultName = name;
|
|
||||||
|
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
vaults.NewVaultCommand.Execute(null);
|
||||||
|
vaults.NewVaultName = name;
|
||||||
|
|
||||||
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
|
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
|
||||||
{
|
vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
|
||||||
await teams.LoadAsync(Token);
|
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
|
||||||
|
|
||||||
teams.NewTeamCommand.Execute(null);
|
|
||||||
teams.NewTeamName = name;
|
|
||||||
teams.NewTeamSlug = slug;
|
|
||||||
|
|
||||||
await teams.CreateTeamCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -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!;
|
||||||
|
|||||||
Reference in New Issue
Block a user