Merge main into the phone connections branch
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 46s

Main had already taken this branch's first two commits, so what merged is the
Connections work against three things that landed beside it. Four of the six
conflicts were prose about arrangements both sides changed; two were real.

**The phone hub gained a Teams row while this branch was moving the keychain
onto it.** Both are additions to `IsMoreSurface` and both belong: teams because
the desktop reaches them from its rail and the phone through the hub, the
keychain because a bottom bar is for the places a session moves between. The
membership test, the back gesture's first case and the hub's own arithmetic all
take the union. The distinction is now written down rather than implied — teams
is the design's count plus one, and the keychain is the only rearrangement of
it: the bar lost a slot to gain that row.

**`ConnectAndAnnounceAsync` was the real one.** Main gave it
`RememberTypedPasswordAsync`, which binds the password that just worked to the
host it worked on; this branch had replaced the `HostRowViewModel` that method
needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a
manual connection does with a password that succeeded, and the answer was
already written on the screen it is typed into: nothing. There is no item to
bind a credential to and none to bind it on, and that path saves nothing by
design.

So `ConnectionTarget` carries the row again — as a nullable, in place of the
host id it had, with `HostId` derived from it. Two things read it and both are
things that can only be done to a keychain item rather than to an address:
naming the log entry, and keeping the password. Null is not missing data there;
it is the whole of what makes the manual path different, and having one field
rather than two keeps "was this a keychain host" a question with one answer.

The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's
"a rail with nine slots has room" was true when it was written this afternoon
and is not now. It says the room rather than the number.

Phase 11's four new device checks and main's Phase 12 on teams were the same
conflict twice — two appends to the end of one file — and both are kept.

Verified after resolving: the solution builds, the Android head builds clean,
and 837 tests pass across the seven client suites, including main's own additions
(233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39
storage).
This commit is contained in:
2026-08-03 15:35:49 +02:00
88 changed files with 9866 additions and 1707 deletions
+100 -17
View File
@@ -148,8 +148,9 @@ dotnet run --project src/DodoSSH.Client.App
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
it in the sidebar, or select it and press **CONNECT**, which is the same command with the password box beside
it. Keycloak's admin console is at `http://localhost:18080` (`admin` / `admin`).
its card, or select it and press **CONNECT** in the drawer that opens beside the grid, which is the same
command with the password box above it. Keycloak's admin console is at `http://localhost:18080`
(`admin` / `admin`).
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the
private key, then edit a host and pick that key from its **key** dropdown. From then on that host
@@ -226,8 +227,8 @@ bastion — the last needs jump hosts the connection layer has not got. All thre
### Working as a team
**TEAMS** in the nav rail creates a team, adds members and shares vaults. One distinction runs through the
whole screen and is worth having before you use it.
**TEAMS** in the nav rail creates a team, adds and invites members, shares vaults, hands a team over and
archives one. One distinction runs through the whole screen and is worth having before you use it.
**Adding somebody to a team and giving them a key are two different acts, and only the first is something
the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in
@@ -244,7 +245,58 @@ published in a log every other client also reads. **It does not prove the key is
Compare the fingerprint with them over something this server does not carry; that is the only step that
closes it, and the success message says so every time.
Three limits, stated rather than discovered:
**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD
MEMBER** — and it does whichever of the two applies, because which one applies is a fact about the server's
account table rather than about what you are trying to do. If the directory knows the address, that account
is added straight away. If it does not, the address is invited instead, and the status line says which
happened, because the difference decides what you do next.
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
outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a
channel this server does not carry, and a link nobody can deliver would be worse than no link. An
invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a
standing offer for ever; it can be withdrawn until it is taken up; and like adding a member it grants
nothing readable, so somebody still has to press SHARE KEY afterwards.
The one thing the merged button costs is worth knowing. Adding an account the directory knows also hands
you the public key you are about to verify and wrap a vault to, and an invitation cannot do that because
there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful
sequence is still the same one: add them, see them appear in the members list, then share.
Inviting an address that already belongs to a member of the team is refused and says so. Inviting one that
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
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.
**An invitation is only claimed if your identity provider says the address is verified, and there is no way
to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or
sent under another name — claims nothing at all, and no setting turns that off: an invitation decides what
the server will serve, and one that could be taken by anybody able to obtain a token asserting somebody
else's address is a way into a team. **If your invitations never activate, this is the first thing to
check.** They sit at *pending* rather than failing, the server logs a warning each time it declines to
claim one, and the two fixes are on your side: set `Oidc:EmailVerifiedClaim` to whatever your provider
calls the claim if it is not `email_verified`, and make sure the provider puts it in the **access** token
rather than only in the ID token or the userinfo response.
**Ownership is sole, and handing it over is one act.** Transferring names an existing active member: they
become owner and you become an admin, in a single transaction. Not two role changes — promoting first
leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the
authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep
your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody
handing over a team is usually staying in it.
**Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team
vault is readable *because* of membership, so archiving a team that still owned vaults would take them away
from everybody holding a key — including you — quietly and all at once. Nothing in this product deletes a
vault, so there is no order of operations that gets past the refusal today, and it says so with a count of
what is in the way rather than failing vaguely. Archiving an empty team takes its memberships and its
outstanding invitations with it, in one transaction. Its name can be changed whenever you like; its slug
cannot, because a slug is unique only among live teams and a rename could take one an archived team is
still holding.
Four limits, stated rather than discovered:
- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey,
and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials
@@ -255,6 +307,12 @@ Three limits, stated rather than discovered:
then trust silently for a host you defined. The cost is that each member approves a team host's key once
on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what
has been trusted.
- **LAST ACTIVE is coarse on purpose.** The server records it at most once per account per hour, so a value
an hour old means "recently" and not "at that moment". That is the granularity the question is really
asked at — whether somebody is still using this deployment — and writing it on every request would put an
UPDATE on the hot path of every authenticated call for a number nobody reads that closely. It is shown
as roughly-when rather than to the minute, because showing it to the minute would be reading a precision
into it that is not there.
Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
@@ -270,11 +328,11 @@ fit 360dp.
Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the
last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP,
S3 buckets, logs and preferences behind Settings. A bottom bar is for the places a session moves between,
and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is a
190-pixel labelled sidebar in place of the icon rail, a centred search box in the titlebar, and session tabs
as pills, and it keeps its Keychain entry — a rail with nine slots has room. Its light theme is not built —
see [`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both.
S3 buckets, logs, teams and preferences behind Settings. A bottom bar is for the places a session moves
between, and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is
a 190-pixel labelled nav rail in place of the icon rail, a centred search box in the titlebar, and session
tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see
[`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both.
**Connections is where a connection is made, not only where one is shown.** With nothing open it offers a
box taking `user@host` or `user@host:port` and a password, and lists the machines most recently connected
@@ -289,15 +347,31 @@ sessions as pills, and a `+` on the right offering the three connections this ap
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers
that menu first if it is open.
Widening the sidebar moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
screen exactly the width it was designed against.
A third desktop pass has since moved the furniture. The tab strip belongs to the window rather than to the
terminal: **Vaults**, **SFTP** and **S3** are fixed tabs at its head and open terminals follow them, which
took SFTP and S3 out of the nav rail — they are the two destinations you stay in while something runs. The
hosts screen became a grid of cards, groups above and hosts below, with a right-hand drawer for whichever
host is selected and for both editors; the 268-pixel host sidebar is gone. Text is white rather than the
design's blue-tinted `#E3E7F4`, and the type scale is a point larger.
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
put in one — moving a file *in* from the phone needs the system document picker and is the next piece of
work rather than a thing the screen pretends to do. What is still absent is a host editor and a keychain
item editor, so hosts and keys are created on the desktop and sync down; pins, teams and import have no
phone screen either. Importing an `~/.ssh/config` has no meaning on a phone at all.
work rather than a thing the screen pretends to do. Hosts and groups are made and corrected here now, from
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
created on the desktop and sync down, and the phone will delete an item — behind the same counted
confirmation — without offering to change it. What this head does make, it makes where the need arises
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's
remember tick, which stores the password just typed and moves the host onto it. Renaming either is still a
desktop job. Pins and import have no phone screen either, and importing an `~/.ssh/config` has no meaning
on a phone at all. **TEAMS does have one**, behind MORE, and it is there for a reason the design could not
have anticipated: an invitation is claimed by signing in, so somebody being told they have been put in a
team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on
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
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
@@ -499,7 +573,10 @@ keychain plus a terminal — and the spike that gates all of it.
append-only key log served for clients to verify against, team-owned vaults, and vault key grants
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
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. See
every vault it holds a key for, and a real TEAMS screen replaces the placeholder. A team can be renamed,
handed to another member, and archived once it owns no vaults; a member row carries when that account was
last here; and an address with no account on this deployment can be invited, joining the moment somebody
signs in with it. See
[Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth
knowing before you rely on it; the reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md).
@@ -508,8 +585,14 @@ keychain plus a terminal — and the spike that gates all of it.
every team vault `RekeyRequired`, and nothing acts on that flag. A rekey re-wraps every item's data key
under a fresh vault key and can only be performed by a client that holds the current one; that is M5's
key rotation. Until it lands the flag is what the interface reads to say a rotation is owed, which is
more honest than a button that only appears to do it. Ownership transfer is absent for the same kind of
reason — the owner cannot be removed or demoted, because nothing can appoint a replacement.
more honest than a button that only appears to do it.
**Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner
and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves
the team owned twice and demoting first leaves it owned by nobody, and there is nobody left with the
authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault
key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey,
which is a much larger act than the one being asked for.
- **M4 — hardening and ops**, packaging, self-hosting guide.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
+49 -6
View File
@@ -33,7 +33,7 @@ existed since the first migration — but nothing had had to name the split.
else. Whether the caller can read what it serves is decided by whether they hold a grant, which the
server records, cannot produce and cannot verify.
Four consequences, each of which is a place where a more reassuring design was rejected:
Five consequences, each of which is a place where a more reassuring design was rejected:
- **A member with no grant is a normal state, not an error.** `VaultSummary.WrappedVaultKey` is null
and the vault appears in their list saying it is waiting for a key. Hiding it until a grant existed
@@ -51,14 +51,21 @@ Four consequences, each of which is a place where a more reassuring design was r
- **Removal is named for what it does.** It revokes grants and flags the vault for rekey. It does not
claim to reach anything already downloaded, and the interface says the remediation is rotating the
credential — the same non-retroactive limit ADR 0001 records.
- **Ownership is sole, so handing it over is one write and not a role change.** If membership
authorises, the owner's membership is the last authority in the team, and a transfer that stopped
halfway would leave nobody with the standing to finish it — owned twice if the promotion went first,
owned by nobody if the demotion did, and in either case recoverable only by an operator editing the
database. So `POST /teams/{id}/owner` promotes the recipient and demotes the outgoing owner to
**admin** in one transaction, `ChangeRoleAsync` refuses `Owner` outright, and the recipient must
already be an active member — handing a team to an id supplied once is the same mistake as adding
somebody straight to the owner role. Demoting rather than removing is the deliberate half: removing
them would revoke their vault key grants and flag every team vault for rekey, which is a far larger
act than the one being asked for, and somebody handing over a team is usually staying in it.
Two things were deliberately **not** built, and both are refusals rather than omissions:
One thing is deliberately **not** built, and it is a refusal rather than an omission:
- **The rekey itself.** Only a client holding the current vault key can re-wrap every item's data key
under a new one. The server records that a rotation is owed and the interface reports it. M5.
- **Ownership transfer.** The owner cannot be demoted or removed, with its own problem code. Allowing
it without a transfer would leave a team nobody can administer, recoverable only by an operator
editing the database.
Two smaller choices, recorded because the alternative was written down first and rejected:
@@ -74,11 +81,47 @@ Two smaller choices, recorded because the alternative was written down first and
until that exists, the safe direction is the narrow one, and the cost — approving a team host's key
once per member per machine — is stated in the README rather than hidden.
### An invitation is membership decided before there is an account to hold it
A membership names an account: `team_membership.user_id` is not nullable and carries a foreign key, so
somebody who has never signed in here has nothing for that row to point at. `MembershipStatus.Invited`
has existed since the first migration and is still never written — not as an oversight, but because a
membership waiting for a person is the one shape this model cannot store. An invitation is therefore its
own record, `team_invitation`, held against an **address**, and it becomes an ordinary active membership
the moment an account with that address signs in.
That extends the model rather than bending it. An invitation grants nothing readable and cannot be a
step towards sharing, because there is no account and so no public key to wrap a vault to. It moves the
first half of the split earlier and leaves the second half exactly where it was.
Three decisions inside it belong here, because each had a more convenient alternative:
- **The claim requires `email_verified` on the access token, and nothing relaxes it.** This is the whole
of the security boundary. Membership is authorisation, so an invitation that could be taken by anybody
able to obtain a token asserting somebody else's address is a way into a team — the same attack
`OidcOptions.AllowEmailLinking` exists to refuse, arriving by another door and deserving the same bar.
An unverified or absent claim claims nothing and logs a warning, which is the only signal an operator
gets that their provider is not sending it. There is deliberately no setting to trust an unverified
address: a flag that exists is a flag somebody turns on for the afternoon their provider is
misconfigured, and this is the one it must not be possible to turn on.
- **Nothing is sent, and the product says so rather than implying a mail path it has not got.** There is
no token and no link — the row is a standing instruction, and telling the invitee to go and sign in
happens over a channel this server does not carry. A link nobody can deliver would be worse than none.
The compensation, such as it is, is real: an invitation that is not a bearer credential is one that
cannot be forwarded, intercepted or replayed.
- **An address that already has an account here is accepted rather than refused.** Refusing and pointing
at the directory would have been tidier, and would have turned the endpoint into an oracle for which
addresses have accounts on this deployment, answerable by anybody willing to create a team first. Only
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.
## Consequences
The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds
a grant are all plaintext rows. That was already true of metadata generally (`docs/crypto.md` §10)
and is not made worse here, but it is now a graph rather than a list.
and is not made worse here, but it is now a graph rather than a list. Invitations widen it by one
edge — an address that has been invited is on the graph before its owner has ever been here — which is
the same class of fact and worth naming rather than leaving to be noticed.
A malicious granter can seal garbage. The recipient detects it as a tag failure and the grant's
Ed25519 signature names who issued it — detectable and attributable, which is the most that is
+4 -2
View File
@@ -489,7 +489,8 @@ go at 360dp:
The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here:
import has no meaning under scoped storage, and the other four are list screens whose view models already
exist — they are additive rather than structural.
exist — they are additive rather than structural. *(Snippets, logs and teams have since been built, behind
MORE. Pins and import have not, and import still cannot be.)*
**Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took
the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the
@@ -552,7 +553,8 @@ What is left, in the order it matters:
source, which is the decision recorded above and the obvious next piece of work.
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
- **Pins, teams and import**, which v2 does not draw either.
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
the three whose view model needed nothing new on the phone, because none of that screen is vault content.
- **The App Link upgrade**, unchanged from step 5.
---
+77 -14
View File
@@ -29,6 +29,13 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`;
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
>
> **A sixth is behind MORE that v2 never drew: TEAMS.** It is the reverse case — a shipped screen the
> design had no slot for — and it is on the phone for a reason the design could not have anticipated,
> because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the
> person being invited is at least as likely to be holding a phone as sitting at a desktop; a team the
> server has just put somebody in, visible only on a head they may not have installed, is a membership
> they cannot see. It runs over the same view model the desktop screen drives, like the other four.
>
> | v2 element | What ships instead |
> | --- | --- |
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
@@ -77,7 +84,38 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | **Split ⌘D** | Still omitted — the renderer stacks panes and shows one; tiling needs a pane geometry it has not got. |
> | macOS traffic lights, and `⌘K` | The window's own minimise/maximise/close, and `CTRL K`. Development is Windows-first and the chrome is `BorderOnly` for a documented reason. |
> | No status bar | Kept, and cut down to the one thing the titlebar does not now carry: `Vault.Status`, which is the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of a working afternoon and has nowhere to put a sentence like that. |
> | The sidebar's five destinations, and a **Team vault** card at its foot | Nine destinations, because Pins, Teams, Import and Preferences are built screens and dropping their entry would strand them. 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, 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 desktop's v3
>
> A third pass, and the smallest of the three: it moves furniture rather than adding screens. Nothing in it
> needed a layer below `client-app`, which is why it has no table of its own — there was nothing to omit.
>
> **The tab strip became the window's, not the terminal's.** Three fixed tabs sit at its head — Vaults,
> SFTP, S3 — and terminal tabs follow them. SFTP and S3 left the nav rail to get there, which is the one
> semantic change: they are the two destinations you *stay in* while something runs, and a rail entry is
> for somewhere you go and come back from. The rail is drawn under Vaults alone, so SFTP, S3 and a terminal
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
> a page test rather than a fourth `ShellSurface`.
>
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
> drawer collapses when nothing is selected, which is most of the time. Pressing a group card narrows the
> grid to that group; `SHOW ALL` is the way back.
>
> **The type scale went up a point and the text ramp went white.** `#E3E7F4` was a blue-tinted white on
> blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing
> a hue with what it is drawn on. Every step of the ramp moved with the top, so the intervals the design
> chose are kept. Both are in the shared palette, so the phone has them too.
>
> | v3 element | What ships instead |
> | --- | --- |
> | The hosts toolbar's view-mode switch (grid / list / table) | One view. A switch between three layouts where only one is built is two disabled buttons. |
> | The hosts toolbar's tag filter, calendar and share control | Omitted. There are no saved filters, nothing in a vault carries a date, and sharing outside a team vault does not exist. Tags are real and are searched by being read off the cards. |
> | **Serial** beside **Terminal** in the toolbar | Omitted. Every session here is an SSH channel; a serial transport is not a button, it is a second session kind. |
> | The strip's tabs inside the titlebar row, with a hamburger | A row of its own under the titlebar. The titlebar already carries the drag region, the search box, the sync light and three window controls, and the strip scrolls — putting both on one 44-pixel row would make the thing that scrolls fight the thing that drags. |
> | A count on the S3 tab | Omitted. The rail entry it replaces carried one; a number on two of five tabs reads as a fact about those two rather than as the tab's own state, and a terminal tab has nothing to count. The count is on the S3 screen. |
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
project each piece would have to land in, and **what the shipped interface does instead**. That last
@@ -109,6 +147,13 @@ unused tables bought. See [Teams](#teams). What has *not* changed is the split u
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.
One table did have to be added, and what it shows is the limit of that reservation. An invitation names an
**address**, and `team_membership.user_id` is not nullable and carries a foreign key, so somebody who has
never signed in has nothing for that row to point at — which is why `MembershipStatus.Invited` has been
reserved since the first migration and is still never written. `team_invitation` is its own table for that
one reason. The schema was right about the shape of a team and had said nothing about the shape of joining
one.
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
@@ -228,7 +273,7 @@ caption buttons and window title drawn on top of the application's own — two s
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the sidebar already is. | Omitted. Connect opens a tab; Ctrl+K opens one by name. |
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
| Terminal font size (`--termfs`, 1116px) | client-storage | See preferences. | Fixed at the renderer's 13px. |
---
@@ -298,9 +343,11 @@ and both editors. What follows is what the design drew around them.
## Teams
**Built in M3.** The screen ships: a team list, a members table, the team's vaults, and the two buttons the
whole design was really about — add a member, and share a vault key. What follows is what it still does not
do, and one thing this document got wrong before it was built.
**Built in M3.** The screen ships: a team list, a members table with a real last-active column, the
invitations standing against addresses that have no account here yet, the team's vaults, and the two buttons
the whole design was really about — add a member, and share a vault key. A team can also be renamed, handed
to another member, and archived, the last only while it owns no vaults. What follows is what it still does
not do, and one thing this document got wrong before it was built.
**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
@@ -309,6 +356,16 @@ place that changed, exactly as its remark predicted, and no migration was needed
What the row did not anticipate is that the interesting half is not the endpoints at all. It is that
**membership and readability are different things**, and the screen is arranged around saying so.
**There are two ways into a team and they are not interchangeable.** Adding a member takes a *user id* the
caller has already got from the directory, so that account must have signed in here at least once — and the
ordering is deliberate rather than incidental, because whoever adds a member is usually about to wrap a
vault key to the public key that lookup returned. Inviting takes an *address*, grants nothing readable, and
cannot be a step towards sharing: there is no account, so there is no key to wrap to. Inviting an address
that already belongs to a member of this team is refused and says so — that is a fact about a team the
caller can already see. Inviting one that merely has an account somewhere on this deployment is **not**
refused, because answering that would turn the endpoint into an oracle for which addresses have accounts,
answerable by anybody willing to create a team first. It simply gets claimed sooner.
| Design element | Layer | What ships |
| --- | --- | --- |
| The team itself | server | `POST/GET /api/v1/teams`, plus members, roles and team vaults. Ids are client-chosen, so a create whose response was lost is safe to repeat. |
@@ -316,17 +373,20 @@ What the row did not anticipate is that the interesting half is not the endpoint
| Roles | contracts + server | `TeamMemberRole` on the wire, numerically pinned to `DodoSSH.Domain.TeamRole` by a test. Viewer reads, Member writes, Admin and Owner also share and administer. |
| Members table | server | `TeamMemberSummary`, and a directory that resolves an exact email to a public key. |
| Sharing an item | client | `VaultSession.ShareVaultAsync`: verify the recipient's key against the key log, wrap, sign, record. The server stores the wrap and the signature and can check neither. |
| Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. |
| Ownership transfer | server | `POST /api/v1/teams/{id}/owner`, owner only. One transaction: the named member becomes owner and the outgoing owner becomes an admin. Not two role changes — ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody. The outgoing owner is demoted rather than removed, because removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one being asked for. |
| `LAST ACTIVE` | server | Real, and coarse on purpose. `UserAccount.LastSeenAtUtc` is now refreshed on ordinary authenticated requests, at most once per account per hour: writing it per request would put an UPDATE on the hot path of every authenticated call and start losing races on `user_account`'s own concurrency token. So the column answers "this week or not", which is the granularity the question is actually asked at, and is shown coarsely rather than to the minute. |
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. |
| Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- |
| `CONNECT-ONLY` role | — | Nothing that would be true. Connect is a user-interface hint, not a boundary: SSH terminates on the client, so a session needs the credential's plaintext on that machine. See ADR 0001. | Four roles, all of which are enforceable. `Connect` rides along with `Read` and is documented as a hint. |
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member column carries what *is* known and matters: whether they have published a key a vault can be wrapped to. |
| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` is written at just-in-time provisioning and at enrollment and never on an ordinary authenticated request, so the column cannot answer "last active". | Omitted. |
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member columns carry what *is* known and matters: whether they have published a key a vault can be wrapped to, and when they were last here. |
| Avatars | server | No picture is stored anywhere. | Omitted; the row shows a name and an address. |
| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` remains unwritten. | Adding a member resolves an address the caller types against the directory, so the account has to have signed in here once. The screen says that when the lookup finds nothing. |
| The invitation mail, and **resend** | server | An outbound mail path: an SMTP configuration, a template, a bounce story and a deliverability problem, none of which this server has. | **Nothing is sent, and the interface says so.** An invitation is a standing instruction rather than a message — the next account to sign in with that address joins the team — so there is no token, no link, and nothing to resend. Telling somebody to sign in is done over a channel this server does not carry. A link nobody can deliver would be worse than no link. |
| Archiving a team that owns vaults | — | Nothing that would be safe. A team vault resolves through membership, so archiving would take those vaults away from everybody holding a key, silently, including the caller — and nothing in this product deletes a vault, so there is no sequence of calls that turns the refusal into a success. | Refused, with `team-not-empty` and a count of the vaults in the way. A stated limit rather than a coming feature, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. |
| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
| A rekey after a membership change | client | Re-wrapping every item's data key under a fresh vault key, which only a client holding the current one can do. M5. | The vault is flagged `RekeyRequired` and the row says a rotation is owed. |
| Ownership transfer | server | A confirmation flow and a rule for what happens to the outgoing owner. | The owner cannot be removed or demoted, with its own problem code rather than a bare 400. |
> **The trap this document warned about is still a trap.** `GET /api/v1/meta` advertises
> `features: ["teams"]` *unconditionally* (`MetaEndpoints.cs`). It was meaningless when nothing implemented
@@ -355,7 +415,7 @@ lists the rest as absent rather than omitting it silently.
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
| `KEYS & AGENT` section | client-ssh | **There is no agent, at all** — no own agent, no forwarding, no Pageant or OpenSSH-agent interop. |
| `SYNC & VAULT` section | client-domain | Nothing here is adjustable. The auto-sync interval is a `private static readonly` with a remark arguing for its value. |
| `SECURITY & SSO` section | server | The SSO half needs team endpoints; there is no policy for the screen to show. |
| `SECURITY & SSO` section | server | Per-team SSO, which is a refusal rather than a pending endpoint: authentication is one global JWT scheme bound to one authority, so there is no per-team policy for this screen to show. |
| `SHORTCUTS` section | ui | There is no keybinding infrastructure and no rebinding surface. |
| Auto-lock after idle | client-session | An activity source, a decision about what counts as idle, and — the hard part — a policy for a shell mid-job. `LockAsync`'s own remark already argues that an unattended timeout which killed a running job would be worse than the exposure it removed. |
| Require biometric to sign | crypto | Three separate falsehoods in one row. There is no signing service — the private key is decrypted and handed to SSH.NET whole, so there is no per-signature moment to interrupt; there are no connect-only keys; and per-use consent would need the key to live in the TPM, which is a different key hierarchy from the one in `docs/crypto.md`. |
@@ -365,10 +425,13 @@ lists the rest as absent rather than omitting it silently.
## Two things the import changed on purpose
**Hosts left the vault column.** They have their own sidebar beside the terminal, and the vault screen
holds keys, passwords and pinned host keys. This follows the design, and it is also the better split: the
host list is what you look at while you work, and the rest is what you go and manage. `VaultSection` lost
its `Hosts` member and gained `All`.
**Hosts left the vault column.** They have their own screen and the vault screen holds keys, passwords and
pinned host keys. This follows the design, and it is also the better split: the host list is what you look
at while you work, and the rest is what you go and manage. `VaultSection` lost its `Hosts` member and
gained `All`.
That screen was a 268-pixel sidebar beside the terminal when this paragraph was written, and v3 made it a
grid of cards with a drawer — see above. The split it describes did not change; only which half is wide.
**Tabs moved to the shell, not the vault.** Locking disposes the vault and deliberately leaves shells
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
+172 -20
View File
@@ -22,10 +22,11 @@ Each item says what to do, what a pass looks like, and what a failure would mean
### 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, FILES, KEYS, TEAM, PREFS.
Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Teams,
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
five.
nine. The nav rail is there for the seven and gone for the two, because it belongs to the Vaults tab.
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
@@ -268,8 +269,8 @@ single-process test can reach.
Open the hosts screen without creating any group.
**Pass:** the sidebar list is the flat list of hosts it always was — no headings, no UNGROUPED, nothing
saying the hosts are unfiled.
**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no headings
between the cards, no UNGROUPED, nothing saying the hosts are unfiled.
**Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they
did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature.
@@ -281,9 +282,13 @@ Make two groups, file some hosts into each through the host editor, then click a
**Pass:** the heading's chevron flips and its hosts disappear; the count on the heading does not change,
because it counts what is in the group rather than what is on screen. Clicking again brings them back.
**Also check:** clicking a heading does not change which host is selected — the buttons at the foot of the
sidebar go on acting on the same machine. This is asserted in a test, but the test drives the view model
directly; what it cannot see is whether the `ListBox` writes something else back through the binding first.
**Also check:** clicking a heading does not change which host is selected — the drawer stays open on the
same machine and its EDIT and DELETE go on acting on it. This is asserted in a test, but the test drives the
view model directly; what it cannot see is whether the `ListBox` writes something else back through the
binding first.
**Then press a group card.** The grid narrows to that group's hosts, the card is marked as chosen, and
SHOW ALL appears beside GROUPS. Pressing it brings the rest back and unmarks the card.
### 3.3 Deleting a group with hosts in it
@@ -639,10 +644,10 @@ back.
### 7.6 Dragging a host into a group · **least covered, like all drag and drop**
Make two groups and file a host into one. Drag a host row onto another group's heading; onto a host row
Make two groups and file a host into one. Drag a host card onto another group's heading; onto a host card
inside another group; and onto UNGROUPED.
**Pass:** the row under the pointer washes accent while the pointer is over it, the cursor shows a move
**Pass:** whatever is under the pointer washes accent while the pointer is over it, the cursor shows a move
rather than a refusal, and the drop files the host — it moves under that heading and the counts on both
headings change. Dropping onto its own group's heading is refused while still in the air.
@@ -651,16 +656,19 @@ automated. The write it performs is: `MovingAHostToAGroup_FilesItAndLeavesItSele
### 7.7 A click still selects, and a double click still connects
Click host rows; drag one a few pixels without releasing; double-click one.
Click host cards; drag one a few pixels without releasing; double-click one. Then double-click a group
heading.
**Pass:** a click selects, a small movement starts nothing, and a double click connects.
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking a
heading folds it and unfolds it again and connects to nothing.
**Failure means:** the 5-pixel threshold in `HostSidebar.axaml.cs` is not doing its job — the same failure
as 2.16 on the other screen, and here it would make the list unusable.
**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure
as 2.16 on the other screen, and here it would make the grid unusable. A heading that connects means the
double-tap handler has lost its check that the pointer was over a card.
### 7.8 The highlight clears after a drag that goes nowhere
Drag a host over a heading and release outside the list, or press Escape mid-drag.
Drag a host over a heading and release outside the grid, or press Escape mid-drag.
**Pass:** the wash goes away.
@@ -672,8 +680,8 @@ With host A selected, right-click host B and choose Delete.
**Pass:** no menu opens at all, and the host selection has not moved.
**Failure means:** a menu acting on the selection rather than on the row under the pointer deletes the wrong
machine. `HostSidebarTests` covers both halves headlessly, so this is a confirmation that a real popup
**Failure means:** a menu acting on the selection rather than on the card under the pointer deletes the
wrong machine. `HostGridTests` covers both halves headlessly, so this is a confirmation that a real popup
behaves as the headless one did.
### 7.10 Clicking a host in the palette connects
@@ -800,11 +808,12 @@ side of it remains unmeasurable for the reasons in phase 8.
Open a host's editor with a keychain holding a dozen tags.
**Pass:** the editor pane scrolls, and FORGET HOST KEY is reachable at the bottom of it.
**Pass:** the drawer scrolls, and FORGET HOST KEY is reachable at the bottom of it.
**Failure means:** the pane's MaxHeight is gone or the ScrollViewer is. The layout harness skips anything
inside a ScrollViewer, so from that commit on it certifies the pane fits rather than the fields — it will
tell you the pane is fine while the last button sits below the window.
**Failure means:** the drawer's ScrollViewer is gone. The layout harness skips anything inside one, so from
that commit on it certifies the drawer fits rather than the fields — it will tell you the drawer is fine
while the last button sits below the window. The editor used to carry a MaxHeight of its own because the
host list shared its column; the drawer is alone in its column now, so the height is the window's.
### 9.2 A chip toggles and reads as toggled
@@ -1003,3 +1012,146 @@ by gesture or by the arrow, returns to Settings and not to HOSTS; a second back
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
case, and those two have to move together — the switch mirrors that property by construction.
---
## Phase 12 — Teams: the operations that span two accounts
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
tokens the test mints itself. That last freedom is exactly what puts this phase here. **A test can mint a
token asserting anything it likes, so it can prove the server's rule and can say nothing whatever about
whether *your* identity provider sends the claim that rule depends on** — and an invitation that never
activates fails by sitting still, which is the failure mode nobody notices. What is left needs two real
accounts, a real sign-in, and in two cases a clock.
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`, both with verified addresses; a
second DodoSSH profile means a second machine, a second OS user, or the same machine after signing out.
Whichever account plays the invitee **must not have signed in to this deployment before** — most of what
follows is about what happens the first time it does.
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
1. Sign in as `alice`, make a team, and open its invitations.
2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was**
no "invitation emailed", no link to copy, no token anywhere on the screen.
3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out.
Bob is **not** in the members table, because he has no account here for a membership row to point at.
4. Sign in as `bob` on the second profile and enroll.
5. **Pass:** the team is in Bob's list the first time he looks, at Member, with nothing further pressed on
either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
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
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
opens.
**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.
Step 6 opening the vault *without* Alice sharing a key would be the far more serious failure: nothing on
the server can wrap a vault key, so an item that decrypts after a membership change alone means a key
reached that machine by a route this architecture says does not exist.
### 12.2 An unverified address claims nothing, and the log is the only place that says so
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
Invite that address, then sign in as them.
**Pass:** they get an account and a personal vault and no team at all. The invitation stays *pending* on
the inviter's screen rather than turning into anything, and the API log carries a warning naming how many
invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request
that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not
hurry it along — the account already exists, so there is no second first-sign-in to trigger it.
**Failure means:** if the team appears while the address is unverified, the one security boundary
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
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
### 12.3 An invitation can be withdrawn until it is taken up
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
**Pass:** the row reads *revoked* and stays on the list rather than disappearing, and the sign-in produces
an ordinary account in no team. Revoking one that has *already* been accepted answers that there was
nothing to withdraw.
**Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An
accepted one that could be unpicked here would be worse: it is a membership now, and removing a member
revokes their vault key grants and flags every team vault for rekey, which is not what "revoke invitation"
should quietly do.
### 12.4 An address already in the team is refused; an address that merely has an account is not
With Bob in the team, invite `bob@example.com` to it again.
**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.
**Pass:** accepted. Bob having an account is deliberately not a reason to refuse — it is claimed within the
hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked.
**Failure means:** if the second invitation is refused because the address already has an account, this
endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody
willing to create a team first. See ADR 0009.
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
Use one account and leave the other idle for two or three hours, then read the members table.
**Pass:** the account being used carries a recent time, the idle one does not move, and neither moves more
than once an hour however much is done in it. It is shown as roughly-when, never to the minute.
**Failure means:** a value that tracks every click means the hourly gate is gone and every authenticated
request is writing to `user_account` — which carries the xmin concurrency token, so the next symptom is a
user's own overlapping requests failing on a version that moved under them. A value frozen at enrollment
means the refresh is not running on ordinary requests at all, which is the state that made this column
impossible to offer honestly before.
### 12.6 Ownership changes hands in one act
As the owner, transfer ownership to another active member, then read both rows.
**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grants are intact
and the team's vaults have not come back flagged for rekey. Then try to transfer to somebody who is not a
member, and to yourself.
**Pass:** both refused, and the message says which.
**Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and
either one leaves a team that no client can administer back into shape. If your grants were revoked or the
vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
### 12.7 Archiving is refused while the team owns a vault
With a team that owns at least one vault, try to archive it.
**Pass:** refused, and the message counts the vaults in the way and says there is no way to delete a vault
in this product. The team is still in everybody's list afterwards and its vaults still open.
**Failure means:** an archive that succeeded here would have taken those vaults out of the list of
everybody holding a key — including the person who pressed it, quietly, and with nothing in the product
able to put them back.
### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
Make a team that owns no vaults, add the second account to it, invite a third address, and archive it.
**Pass:** the team is gone from both accounts' lists. Sign in with the invited address afterwards and it
joins nothing. A new team can be created under the archived one's slug.
**Failure means:** the invited address turning up in a team nobody can see is exactly what revoking pending
invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in
nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason
an archived team is only restorable by an operator who checks that first.
### 12.9 Renaming a team, and the slug that does not move
Rename a team and change its description.
**Pass:** the new name is on every screen that names the team, on both accounts after a refresh. The slug is
unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed.
**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that
archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented
data — `team` has no updated-at column, so there is nothing behind it.
@@ -17,6 +17,38 @@ public interface ICurrentUserContext
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Turns pending team invitations addressed to a verified email into memberships.
/// </summary>
/// <remarks>
/// <para>
/// Declared here, beside its only caller, and implemented in <c>Features/Teams</c>. The direction is
/// deliberate: sign-in is what an invitation waits for, so the sign-in path names the shape it needs
/// and the teams feature supplies it — rather than <see cref="ICurrentUserContext"/>, which every
/// endpoint in the server depends on, growing a reference into one feature's folder.
/// </para>
/// </remarks>
public interface ITeamInvitationClaim
{
/// <summary>
/// Claims every live invitation addressed to <paramref name="email"/> for this account.
/// </summary>
/// <param name="user">The account signing in.</param>
/// <param name="email">The address the token asserted, or null if it asserted none.</param>
/// <param name="emailVerified">
/// Whether the provider marked that address verified. False refuses the claim outright and is the
/// whole of what stops an invitation being taken by anybody able to assert somebody else's
/// address.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>How many invitations became memberships.</returns>
Task<int> ClaimAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken);
}
/// <summary>
/// Request-scoped caller identity with just-in-time provisioning.
/// </summary>
@@ -29,9 +61,23 @@ internal sealed class CurrentUserContext(
IHttpContextAccessor accessor,
DodoDbContext database,
IOptions<Setup.OidcOptions> oidcOptions,
ITeamInvitationClaim invitations,
TimeProvider clock)
: ICurrentUserContext
{
/// <summary>
/// How stale <see cref="UserAccount.LastSeenAtUtc"/> may get before a request refreshes it.
/// </summary>
/// <remarks>
/// An hour, and coarse on purpose in both directions. Writing it on every request would put an
/// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries
/// the xmin concurrency token — would start losing races between a user's own overlapping
/// requests. Writing it never is what made the old "last active" column impossible to offer
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not",
/// and it is also the window on which a pending invitation is swept for.
/// </remarks>
private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);
private UserAccount? cached;
/// <inheritdoc />
@@ -55,14 +101,90 @@ internal sealed class CurrentUserContext(
var options = oidcOptions.Value;
var email = principal.FindFirstValue(options.EmailClaim);
var displayName = principal.FindFirstValue(options.NameClaim);
var emailVerified = IsVerified(principal, options.EmailVerifiedClaim);
cached = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false)
?? await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
.ConfigureAwait(false);
// A first sign-in is exactly what an invitation is waiting for, so it is claimed at once
// rather than on the next hourly sweep — which would leave somebody staring at a team
// list that does not yet contain the team they were told they had been added to.
await invitations
.ClaimAsync(cached, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
return cached;
}
cached = existing;
await RefreshLastSeenAsync(existing, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
return cached;
}
/// <summary>
/// Records that this account is active, and sweeps for invitations it can now claim.
/// </summary>
/// <remarks>
/// <para>
/// The two are one operation because they want the same rate. Both are housekeeping nobody is
/// waiting on, and doing them together costs one extra round trip per account per hour rather
/// than two.
/// </para>
/// <para>
/// The sweep is what makes claiming recoverable rather than one-shot. A claim that failed at
/// provisioning — or an invitation issued in the window between an account being created and this
/// person next signing in — is picked up here instead of being stranded for ever.
/// </para>
/// <para>
/// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
/// </para>
/// </remarks>
private async Task RefreshLastSeenAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
if (user.LastSeenAtUtc is { } seen && now - seen < LastSeenWindow)
{
return;
}
await database.Users
.Where(u => u.Id == user.Id
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken)
.ConfigureAwait(false);
await invitations
.ClaimAsync(user, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
}
/// <remarks>
/// A JWT boolean arrives as the string "true", so this parses rather than compares against a
/// constant. Anything else — absent, "false", or a value this does not understand — is false,
/// because the failure that matters is treating an unverified address as verified.
/// </remarks>
private static bool IsVerified(ClaimsPrincipal principal, string claimType) =>
bool.TryParse(principal.FindFirstValue(claimType), out var verified) && verified;
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
database.Users.SingleOrDefaultAsync(
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
@@ -84,6 +84,188 @@ internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamSer
}
}
/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// Admin rather than owner-only. A rename is visible to everybody and reversible by anybody who can
/// perform it, which is the test that separates it from archiving and from handing the team over.
/// </remarks>
internal sealed class UpdateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
: Endpoint<UpdateTeamRequest, Results<Ok<TeamSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
// PUT, not PATCH: the body carries both fields every time, so clearing a description is
// sending null rather than a distinct verb, and a repeat is the same team.
Put("/api/v1/teams/{teamId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("UpdateTeam")
.WithSummary("Renames a team, or changes its description.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
UpdateTeamRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can rename it.");
}
try
{
return TypedResults.Ok(
await teams.UpdateAsync(user, access, req, ct).ConfigureAwait(false));
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>Archives a team.</summary>
/// <remarks>
/// Owner-only, and refused while the team owns vaults. See <c>TeamService.ArchiveAsync</c> for why
/// the refusal is the end of that road rather than a step on it.
/// </remarks>
internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/teams/{teamId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ArchiveTeam")
.WithSummary("Archives a team. Refused while it still owns vaults.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
if (!access.IsOwner)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only the owner of this team can archive it.");
}
try
{
await teams.ArchiveAsync(user, access.Team!, ct).ConfigureAwait(false);
return TypedResults.NoContent();
}
catch (TeamNotEmptyException exception)
{
return Problems.Coded(
StatusCodes.Status409Conflict, ProblemCodes.TeamNotEmpty, exception.Message);
}
}
}
/// <summary>Hands a team's ownership to another member.</summary>
internal sealed class TransferTeamOwnershipEndpoint(ICurrentUserContext currentUser, TeamService teams)
: Endpoint<TransferTeamOwnershipRequest, Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
// POST to a singular sub-resource rather than PUT on the member's role, because it is not a
// change to one membership: two rows move together and neither is meaningful alone.
Post("/api/v1/teams/{teamId:guid}/owner");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("TransferTeamOwnership")
.WithSummary("Hands ownership to another member, demoting the outgoing owner to admin.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
TransferTeamOwnershipRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.IsOwner)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only the owner of this team can hand it over.");
}
try
{
await teams.TransferOwnershipAsync(user, teamId, req, ct).ConfigureAwait(false);
// 204. The caller knows both ids — it supplied one and is the other — and a client that
// wants the new roles reads the members list, which is where roles live.
return TypedResults.NoContent();
}
catch (LastTeamOwnerException exception)
{
return Problems.Coded(
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>Lists a team's members.</summary>
internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>>
@@ -306,6 +488,173 @@ internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser,
}
}
/// <summary>Lists a team's invitations.</summary>
/// <remarks>
/// Readable by every member, as the members list is: whoever is about to be handed a vault key needs
/// to see who else is on their way in. Accepted and withdrawn invitations are included so the screen
/// can say an invitation was taken up rather than letting it vanish and read as never sent.
/// </remarks>
internal sealed class ListTeamInvitationsEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ListTeamInvitations")
.WithSummary("Lists a team's invitations.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await invitations.ListAsync(teamId, ct).ConfigureAwait(false));
}
}
/// <summary>Invites an address to a team.</summary>
/// <remarks>
/// Authenticated rather than Enrolled, and pointedly so. Every other write that ends in somebody
/// reading a vault needs a key of the caller's own; this one does not, because an invitation grants
/// membership and membership is not readability. Requiring enrollment here would also be requiring it
/// of the wrong person — the invitee is the one with no key, and they have no account yet either.
/// </remarks>
internal sealed class CreateTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: Endpoint<CreateTeamInvitationRequest, Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("CreateTeamInvitation")
.WithSummary("Invites an email address to a team.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
CreateTeamInvitationRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can invite people to it.");
}
try
{
var invitation = await invitations
.CreateAsync(user, teamId, req, ct)
.ConfigureAwait(false);
return TypedResults.Ok(invitation);
}
catch (TeamInvitationInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.InvalidTeamInvitation,
exception.Message);
}
}
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
/// <remarks>
/// 404 for an invitation that is not there, is not this team's, or has already been claimed — the
/// same answer for all three, and for the reason revoking a device grant gives: a caller driving
/// towards "that invitation will not let anybody in" can treat 404 as having arrived. A claimed one
/// is a membership now, and removing a member is a different operation with different consequences.
/// </remarks>
internal sealed class RevokeTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/teams/{teamId:guid}/invitations/{invitationId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("RevokeTeamInvitation")
.WithSummary("Withdraws an invitation that has not been taken up.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var invitationId = Route<Guid>("invitationId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can withdraw an invitation.");
}
var revoked = await invitations
.RevokeAsync(user, teamId, invitationId, ct)
.ConfigureAwait(false);
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
}
}
/// <summary>Creates a vault owned by a team.</summary>
internal sealed class CreateTeamVaultEndpoint(
ICurrentUserContext currentUser,
@@ -20,10 +20,28 @@ internal sealed class TeamSlugTakenException(string message) : Exception(message
/// <summary>The change would leave a team with no owner.</summary>
/// <remarks>
/// Refused rather than allowed: a team with no owner has nobody who can appoint one, so the only
/// route back would be an operator editing the database by hand.
/// route back would be an operator editing the database by hand. The deliberate way through it is a
/// transfer, which moves ownership and the outgoing owner's demotion together.
/// </remarks>
internal sealed class LastTeamOwnerException(string message) : Exception(message);
/// <summary>The team still owns vaults, so it cannot be archived.</summary>
/// <remarks>
/// Its own type because the remedy is neither fixing the request nor picking another value: archiving
/// would hide vaults from every member including the ones holding keys to them, and nothing in this
/// product deletes a vault, so there is no sequence of calls that turns this refusal into a success
/// today. Saying that plainly is better than a flag that hides somebody's data.
/// </remarks>
internal sealed class TeamNotEmptyException(string message) : Exception(message);
/// <summary>An invitation was rejected.</summary>
/// <remarks>
/// Separate from <see cref="TeamInvalidException"/> because its commonest cause has a different
/// remedy: an address that already has an account here should be added through the directory, which
/// is the path that shows the caller the public key they are about to trust.
/// </remarks>
internal sealed class TeamInvitationInvalidException(string message) : Exception(message);
/// <summary>
/// A vault key grant was rejected.
/// </summary>
@@ -0,0 +1,488 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Invitations to addresses that have no account here yet, and the sign-in path that claims them.
/// </summary>
/// <remarks>
/// <para>
/// <b>An invitation is a standing instruction, not a message and not a token.</b> This server has no
/// outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row
/// says "the next account to sign in with this address joins this team as this role", and telling
/// them to sign in is the caller's job over a channel this server does not carry. That is a smaller
/// feature than the design drew, and it is the whole of what can be built honestly without a mail
/// path — a link nobody can deliver would be worse than none.
/// </para>
/// <para>
/// <b>Verification is the security boundary, and it is the only one.</b> Membership is authorization
/// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the
/// identity provider has not marked verified would let anybody who can get a token asserting somebody
/// else's address walk into their team — which is precisely the attack
/// <c>OidcOptions.AllowEmailLinking</c> exists to refuse. So an unverified address claims nothing,
/// there is no setting that relaxes it, and the refusal is logged rather than silent.
/// </para>
/// <para>
/// What an invitation still cannot do is make anything readable. It creates a membership, and a
/// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine
/// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves
/// the first half of it earlier.
/// </para>
/// </remarks>
internal sealed class TeamInvitationService(
DodoDbContext database,
TimeProvider clock,
ILogger<TeamInvitationService> logger)
: ITeamInvitationClaim
{
/// <summary>Longest acceptable address. Matches the column, and RFC 5321's own limit.</summary>
private const int MaxEmailLength = 320;
/// <summary>
/// How long an invitation stays claimable.
/// </summary>
/// <remarks>
/// Fourteen days, and finite for a reason rather than as a default. An invitation that never
/// expired would be a standing offer against an address, and addresses are reassigned — a
/// company address handed to the next person to hold the job would let them into a team the
/// person who left was invited to. Fourteen days is long enough to survive a holiday and short
/// enough that a forgotten invitation lapses rather than waiting.
/// </remarks>
private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
/// <remarks>
/// Every member may read this, as with the members list and for the same reason: whoever is about
/// to be handed a vault key needs to see who else is on their way into the team. Accepted and
/// revoked rows are returned too, so the screen can show that an invitation was taken up rather
/// than having it silently vanish and read as never sent.
/// </remarks>
internal async Task<IReadOnlyList<TeamInvitationSummary>> ListAsync(
Guid teamId,
CancellationToken cancellationToken)
{
var invitations = await database.TeamInvitations
.Where(i => i.TeamId == teamId)
.OrderByDescending(i => i.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var now = clock.GetUtcNow();
return [.. invitations.Select(invitation => Describe(invitation, now))];
}
/// <summary>Invites an address to a team.</summary>
/// <remarks>
/// <para>
/// <b>An address that already has an account here is accepted rather than refused.</b> The obvious
/// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle
/// for which addresses have accounts, answerable by anybody willing to create a team first. It
/// would also be answering a question the caller did not ask: they want that person in the team,
/// and whether the account exists yet only changes how soon it happens. An existing account picks
/// the invitation up on its next request, within the hour.
/// </para>
/// <para>
/// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and
/// address returns the existing invitation rather than a second one. A different address under an
/// id already in use is refused rather than reinterpreted.
/// </para>
/// </remarks>
internal async Task<TeamInvitationSummary> CreateAsync(
UserAccount actor,
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var email = RequireEmail(request.Email);
var role = RequireInvitableRole(request.Role);
if (request.InvitationId == Guid.Empty)
{
throw new TeamInvitationInvalidException(
"An invitation id is required. Generate a UUIDv7 on the client.");
}
var now = clock.GetUtcNow();
var existing = await database.TeamInvitations
.SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return ResolveExisting(existing, teamId, email, now);
}
await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false);
var invitation = new TeamInvitation
{
Id = request.InvitationId,
TeamId = teamId,
Email = email,
Role = role,
InvitedByUserId = actor.Id,
CreatedAtUtc = now,
ExpiresAtUtc = now + Lifetime,
};
database.TeamInvitations.Add(invitation);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The partial unique index on (team, email) among live invitations. Reported as its own
// sentence because it is the one failure the caller could not see coming from their own
// input — somebody else may have invited the same person a minute earlier.
throw new TeamInvitationInvalidException(
"There is already an invitation to that address for this team. Withdraw it first if "
+ "you want to invite them at a different role.");
}
TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id);
return Describe(invitation, now);
}
/// <remarks>
/// A retry is the same id against the same team and address. Anything else under an id already in
/// use is refused rather than reinterpreted: returning a differently-addressed invitation would
/// tell a client its invite went to somebody it did not.
/// </remarks>
private static TeamInvitationSummary ResolveExisting(
TeamInvitation existing,
Guid teamId,
string email,
DateTimeOffset now)
{
var isRetry = existing.TeamId == teamId
&& string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase);
return isRetry
? Describe(existing, now)
: throw new TeamInvitationInvalidException(
"That invitation id is already in use. Generate a new UUIDv7 and retry.");
}
private static TeamRole RequireInvitableRole(TeamMemberRole role)
{
var domain = ToDomain(role);
return domain is TeamRole.Unspecified or TeamRole.Owner
? throw new TeamInvitationInvalidException(
"Invite somebody as a viewer, member or admin. Ownership is sole and is handed over "
+ "deliberately, never conferred by an address signing in.")
: domain;
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
/// <returns>Whether there was a live invitation to withdraw.</returns>
/// <remarks>
/// An invitation that has already been claimed is <em>not</em> withdrawable, and answering false
/// rather than unpicking it is the honest outcome: it is a membership now, and removing a member
/// is a different operation with different consequences — it revokes their vault key grants and
/// flags every team vault for rekey.
/// </remarks>
internal async Task<bool> RevokeAsync(
UserAccount actor,
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken)
{
var invitation = await database.TeamInvitations
.SingleOrDefaultAsync(
i => i.Id == invitationId
&& i.TeamId == teamId
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (invitation is null)
{
return false;
}
invitation.RevokedAtUtc = clock.GetUtcNow();
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id);
return true;
}
/// <inheritdoc />
public async Task<int> ClaimAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
if (string.IsNullOrWhiteSpace(email))
{
return 0;
}
var now = clock.GetUtcNow();
var pending = await database.TeamInvitations
.Where(i => i.Email == email
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null
&& i.ExpiresAtUtc > now)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (pending.Count == 0)
{
return 0;
}
if (!emailVerified)
{
// Logged rather than silent, and this is the only signal an operator gets that their
// provider is not sending the claim. Without it, invitations would simply never work and
// there would be nothing anywhere saying why.
TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id);
return 0;
}
return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false);
}
/// <summary>Turns each claimable invitation into an active membership.</summary>
private async Task<int> ApplyAsync(
UserAccount user,
List<TeamInvitation> pending,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var teamIds = pending.Select(i => i.TeamId).ToArray();
// Archived teams are excluded here as well as at archive time. An invitation issued moments
// before an archive can still be in flight, and joining a team nobody can see is worse than
// an invitation that quietly lapses.
var liveTeamIds = await database.Teams
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
.Select(t => t.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var live = liveTeamIds.ToHashSet();
var memberships = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId) && m.UserId == user.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var claimed = 0;
foreach (var invitation in pending.Where(i => live.Contains(i.TeamId)))
{
invitation.AcceptedAtUtc = now;
invitation.AcceptedByUserId = user.Id;
if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now))
{
claimed++;
TeamLog.InvitationClaimed(
logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role);
}
}
return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false);
}
/// <summary>Adds or reactivates the membership an invitation asks for.</summary>
/// <returns>Whether the membership changed. False means they were already an active member.</returns>
private bool Join(
UserAccount user,
TeamInvitation invitation,
TeamMembership? membership,
DateTimeOffset now)
{
if (membership is null)
{
database.TeamMemberships.Add(new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = invitation.TeamId,
UserId = user.Id,
Role = invitation.Role,
Status = MembershipStatus.Active,
InvitedByUserId = invitation.InvitedByUserId,
JoinedAtUtc = now,
CreatedAtUtc = now,
});
return true;
}
if (membership.Status == MembershipStatus.Active)
{
// Already in the team — the invitation is satisfied rather than applied. It must not
// change a role somebody set deliberately in the meantime, which is what re-applying an
// invitation issued weeks ago would silently do.
return false;
}
// Removed earlier and invited again. The row is reactivated rather than duplicated, exactly
// as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one
// membership. Their revoked key grants are not restored — those were wrapped to a generation
// the vault has since been flagged to leave behind.
membership.Role = invitation.Role;
membership.Status = MembershipStatus.Active;
membership.JoinedAtUtc = now;
return true;
}
/// <remarks>
/// Its own SaveChanges, never folded into the caller's. <c>CurrentUserContext.ProvisionAsync</c>
/// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that
/// call would put violations from this table inside a filter written for exactly one race, and
/// its rethrow would stop being correct.
/// </remarks>
private async Task<int> SaveClaimAsync(int claimed, CancellationToken cancellationToken)
{
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// Two of this account's requests claiming at once. One wins; the other finds nothing
// left to do on the next sweep. Swallowed rather than surfaced because this runs inside
// the authorization middleware, where a throw is a 500 on a request that was otherwise
// fine — and because the outcome the caller wanted has happened either way.
foreach (var entry in database.ChangeTracker.Entries<TeamMembership>().ToList())
{
entry.State = EntityState.Detached;
}
return 0;
}
return claimed;
}
/// <remarks>
/// Refused only for an account that is <em>already in this team</em> — a fact about a team the
/// caller can see, so naming it leaks nothing. Whether an address has an account at all is
/// deliberately not answered here; see <see cref="CreateAsync"/>.
/// </remarks>
private async Task RefuseIfAlreadyAMemberAsync(
Guid teamId,
string email,
CancellationToken cancellationToken)
{
var isMember = await database.TeamMemberships
.Where(m => m.TeamId == teamId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.Join(
database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null),
m => m.UserId,
u => u.Id,
(m, u) => m.Id)
.AnyAsync(cancellationToken)
.ConfigureAwait(false);
if (isMember)
{
throw new TeamInvitationInvalidException(
"That address already belongs to a member of this team. Change their role instead.");
}
}
/// <summary>Derives what has become of an invitation from its timestamps.</summary>
/// <remarks>
/// Computed rather than stored, which is why <see cref="TeamInvitationState"/> has no domain twin.
/// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an
/// invitation that read Pending because nothing had run yet would be a lie the interface repeats.
/// </remarks>
private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now)
{
var state = invitation switch
{
{ AcceptedAtUtc: not null } => TeamInvitationState.Accepted,
{ RevokedAtUtc: not null } => TeamInvitationState.Revoked,
_ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired,
_ => TeamInvitationState.Pending,
};
return new TeamInvitationSummary(
invitation.Id,
invitation.Email,
ToContract(invitation.Role),
state,
invitation.InvitedByUserId,
invitation.CreatedAtUtc,
invitation.ExpiresAtUtc,
invitation.AcceptedAtUtc);
}
/// <remarks>
/// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign
/// with something either side, no spaces, and inside the length the column holds — and nothing
/// more. A stricter address grammar here would reject addresses that a real identity provider
/// will happily assert, and the only thing that ultimately decides whether an address is that
/// person's is the provider marking it verified.
/// </remarks>
private static string RequireEmail(string? value)
{
var email = (value ?? string.Empty).Trim();
var at = email.IndexOf('@', StringComparison.Ordinal);
var acceptable = email.Length is > 2 and <= MaxEmailLength
&& at > 0
&& at == email.LastIndexOf('@')
&& at < email.Length - 1
&& !email.Any(char.IsWhiteSpace);
return acceptable
? email
: throw new TeamInvitationInvalidException(
"That does not look like an email address. Invite the address they sign in with.");
}
private static TeamRole ToDomain(TeamMemberRole role) => role switch
{
TeamMemberRole.Viewer => TeamRole.Viewer,
TeamMemberRole.Member => TeamRole.Member,
TeamMemberRole.Admin => TeamRole.Admin,
TeamMemberRole.Owner => TeamRole.Owner,
_ => TeamRole.Unspecified,
};
private static TeamMemberRole ToContract(TeamRole role) => role switch
{
TeamRole.Viewer => TeamMemberRole.Viewer,
TeamRole.Member => TeamMemberRole.Member,
TeamRole.Admin => TeamMemberRole.Admin,
TeamRole.Owner => TeamMemberRole.Owner,
_ => TeamMemberRole.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}
+76
View File
@@ -65,4 +65,80 @@ internal static partial class TeamLog
+ "Blocks future reads only; see ADR 0001.")]
internal static partial void GrantRevoked(
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
[LoggerMessage(
EventId = 2108,
Level = LogLevel.Information,
Message = "Renamed team {TeamId}, by {ActorId}.")]
internal static partial void TeamUpdated(ILogger logger, Guid teamId, Guid actorId);
/// <remarks>
/// Warning, and it names the member count, for the reason removal does: an archive takes a team
/// out of every member's list at once and only an operator can put it back.
/// </remarks>
[LoggerMessage(
EventId = 2109,
Level = LogLevel.Warning,
Message = "Archived team {TeamId} and its {MemberCount} membership(s), by {ActorId}. "
+ "Recoverable only by an operator clearing deleted_at_utc.")]
internal static partial void TeamArchived(
ILogger logger, Guid teamId, Guid actorId, int memberCount);
/// <remarks>
/// Warning rather than information: it is the only operation that takes administrative control of
/// a team away from the account that had it, and the account it is taken from is not the one
/// asking afterwards.
/// </remarks>
[LoggerMessage(
EventId = 2110,
Level = LogLevel.Warning,
Message = "Transferred ownership of team {TeamId} from {FormerOwnerId} to {NewOwnerId}. "
+ "The former owner is now an admin.")]
internal static partial void OwnershipTransferred(
ILogger logger, Guid teamId, Guid formerOwnerId, Guid newOwnerId);
/// <remarks>
/// The invitation id, never the address. TeamLog's rule is ids and outcomes only, and an email is
/// exactly the kind of personal detail a log aggregator would then keep for its whole retention.
/// </remarks>
[LoggerMessage(
EventId = 2111,
Level = LogLevel.Information,
Message = "Issued invitation {InvitationId} to team {TeamId} as {Role}, by {ActorId}.")]
internal static partial void InvitationIssued(
ILogger logger, Guid invitationId, Guid teamId, Domain.TeamRole role, Guid actorId);
[LoggerMessage(
EventId = 2112,
Level = LogLevel.Information,
Message = "Revoked invitation {InvitationId} to team {TeamId}, by {ActorId}.")]
internal static partial void InvitationRevoked(
ILogger logger, Guid invitationId, Guid teamId, Guid actorId);
[LoggerMessage(
EventId = 2113,
Level = LogLevel.Information,
Message = "User {UserId} claimed invitation {InvitationId} and joined team {TeamId} as {Role}.")]
internal static partial void InvitationClaimed(
ILogger logger, Guid userId, Guid invitationId, Guid teamId, Domain.TeamRole role);
/// <remarks>
/// <para>
/// Warning, and the one log line an operator will need when invitations appear not to work at all.
/// A provider that does not assert <c>email_verified</c> leaves every invitation pending for ever
/// with nothing else to show for it, and this is the only place that difference is visible.
/// </para>
/// <para>
/// It names the count and the account, never the address — the address is the thing being refused
/// as untrustworthy, and writing it to a log would be keeping a claim the server just rejected.
/// </para>
/// </remarks>
[LoggerMessage(
EventId = 2114,
Level = LogLevel.Warning,
Message = "Left {InvitationCount} invitation(s) unclaimed for user {UserId}: the access token "
+ "does not assert that their email address is verified. Check the identity provider "
+ "sends the email_verified claim.")]
internal static partial void InvitationNotClaimedUnverified(
ILogger logger, int invitationCount, Guid userId);
}
+242 -9
View File
@@ -24,6 +24,17 @@ internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
/// </remarks>
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
/// <summary>
/// Whether the caller owns this team.
/// </summary>
/// <remarks>
/// Distinct from <see cref="CanAdminister"/>, and the distinction is load-bearing: an admin may
/// manage members and vaults, but archiving a team and handing it to somebody else are the two
/// things that decide whether the team continues to exist and who controls it. Gating those on
/// <see cref="CanAdminister"/> would let anybody the owner promoted take the team from them.
/// </remarks>
public bool IsOwner => Role is TeamRole.Owner;
/// <summary>Denied access.</summary>
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
}
@@ -156,6 +167,200 @@ internal sealed class TeamService(
return team;
}
/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// The slug is not touched and cannot be. It is unique only among live teams, so a rename could
/// take a slug an archived team still holds, and that archived team could then never be restored
/// — a rename that quietly forecloses somebody else's recovery is worse than one the product
/// simply does not offer. There is also nowhere to record that this happened: <c>team</c> has no
/// updated-at column, so nothing can show "edited" and the log line is the only trace.
/// </remarks>
/// <param name="actor">Who is renaming it.</param>
/// <param name="access">
/// The caller's resolved access. The <em>role</em> is taken from here rather than assumed, because
/// an admin may rename a team and telling them the response says <see cref="TeamRole.Owner"/> would
/// hand a client a summary claiming rights it does not have — and this is the one write on a team
/// that both an admin and an owner can perform.
/// </param>
/// <param name="request">The new name and description.</param>
/// <param name="cancellationToken">Cancellation.</param>
internal async Task<TeamSummary> UpdateAsync(
UserAccount actor,
TeamAccess access,
UpdateTeamRequest request,
CancellationToken cancellationToken)
{
var team = access.Team
?? throw new TeamInvalidException("That team is not there.");
ArgumentNullException.ThrowIfNull(request);
team.Name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
team.Description = OptionalText(request.Description, MaxDescriptionLength);
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.TeamUpdated(logger, team.Id, actor.Id);
var memberCount = await CountMembersAsync(team.Id, cancellationToken).ConfigureAwait(false);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
return new TeamSummary(
team.Id, team.Name, team.Slug, team.Description,
ToContract(access.Role), memberCount, vaultCount, team.CreatedAtUtc);
}
/// <summary>
/// Archives a team, provided it owns no vaults.
/// </summary>
/// <remarks>
/// <para>
/// <b>The vault check is the whole of this operation's safety and it refuses rather than
/// cascades.</b> Archiving a team hides it from every member's list at once, and a team vault
/// resolves through membership — so archiving one that still owned vaults would take those vaults
/// away from people who hold keys to them, silently, including the caller. Nothing in this product
/// deletes a vault, so there is no sequence of calls that turns this refusal into a success today.
/// That is stated plainly rather than worked around, for the reason the SFTP layer refuses a
/// recursive delete: a refusal is visible and a quiet removal is not.
/// </para>
/// <para>
/// Memberships are archived with the team, in one transaction, because a live membership pointing
/// at an archived team is a row every membership query has to remember to exclude twice. The slug
/// is freed by the same write — the unique index is filtered on <c>deleted_at_utc IS NULL</c> — so
/// a team can be recreated under the archived one's slug, and restoring the archived one would
/// then collide. Only an operator can restore it, and this is the thing they have to look at
/// first.
/// </para>
/// </remarks>
internal async Task ArchiveAsync(
UserAccount actor,
Team team,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(team);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
{
throw new TeamNotEmptyException(
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. There is no way to delete a vault in this product yet, so a team with vaults cannot be archived."));
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var archived = await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
var memberships = await database.TeamMemberships
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var membership in memberships)
{
membership.Status = MembershipStatus.Revoked;
membership.DeletedAtUtc = now;
}
// Pending invitations go too. An invitation that outlived its team would become a
// membership of something nobody can see, on a sign-in weeks later.
var invitations = await database.TeamInvitations
.Where(i => i.TeamId == team.Id
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var invitation in invitations)
{
invitation.RevokedAtUtc = now;
}
team.DeletedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return memberships.Count;
}).ConfigureAwait(false);
TeamLog.TeamArchived(logger, team.Id, actor.Id, archived);
}
/// <summary>
/// Hands ownership to another active member, demoting the outgoing owner to admin.
/// </summary>
/// <remarks>
/// <para>
/// One transaction, because ownership is sole and the two writes are not separable: promoting
/// first leaves the team owned twice, demoting first leaves it owned by nobody, and a failure
/// between them leaves whichever of those the ordering chose. That is why this is not two calls
/// to <see cref="ChangeRoleAsync"/>, which refuses <see cref="TeamRole.Owner"/> outright.
/// </para>
/// <para>
/// The recipient must already be an active member. Adding somebody and handing them the team in
/// one step would let an id supplied once take it, and the reason
/// <see cref="AddMemberAsync"/> refuses the owner role is the same one.
/// </para>
/// <para>
/// The outgoing owner is demoted rather than removed. Removing them would revoke their vault key
/// grants and flag every team vault for rekey — a far larger act than the one asked for, and
/// somebody handing over a team is usually staying in it.
/// </para>
/// </remarks>
internal async Task TransferOwnershipAsync(
UserAccount actor,
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.UserId == actor.Id)
{
throw new TeamInvalidException("You already own this team.");
}
var outgoing = await RequireMembershipAsync(teamId, actor.Id, cancellationToken)
.ConfigureAwait(false);
// Belt and braces: the endpoint already refused anybody who is not the owner. Checking again
// here keeps the invariant with the code that enforces it rather than one layer away.
if (outgoing.Role != TeamRole.Owner)
{
throw new LastTeamOwnerException("Only this team's owner can hand it over.");
}
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
incoming.Role = TeamRole.Owner;
outgoing.Role = TeamRole.Admin;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
TeamLog.OwnershipTransferred(logger, teamId, actor.Id, request.UserId);
}
/// <summary>Lists the teams the caller is an active member of.</summary>
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
UserAccount user,
@@ -260,7 +465,8 @@ internal sealed class TeamService(
ToContract(m.Role),
ToContract(m.Status),
enrolledIds.Contains(m.UserId),
m.JoinedAtUtc)),
m.JoinedAtUtc,
m.User?.LastSeenAtUtc)),
];
}
@@ -268,8 +474,9 @@ internal sealed class TeamService(
/// <remarks>
/// <para>
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
/// somebody else is a transfer rather than an addition — a different operation with a different
/// confirmation, and not one M3 offers.
/// somebody else is a transfer rather than an addition — a different operation, with its own
/// endpoint, which demotes the outgoing owner in the same transaction. Adding somebody straight
/// to owner would hand a team to an id typed once.
/// </para>
/// <para>
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
@@ -365,7 +572,8 @@ internal sealed class TeamService(
ToContract(membership.Role),
ToContract(membership.Status),
isEnrolled,
membership.JoinedAtUtc);
membership.JoinedAtUtc,
user.LastSeenAtUtc);
}
/// <summary>Changes a member's role.</summary>
@@ -388,12 +596,15 @@ internal sealed class TeamService(
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
// Demoting the owner is what would leave the team ownerless, and there is no transfer to
// do it through yet. Refused with the code a client can act on rather than a bare 400.
// Demoting the owner here would leave the team ownerless, because this operation cannot
// appoint a replacement in the same breath. Transferring can, and does both at once — so
// the refusal names it rather than saying the thing is impossible.
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be demoted, because nothing can appoint a replacement yet.");
"This team's owner cannot be demoted on its own. Transfer ownership to another "
+ "member instead: that hands the team over and makes the outgoing owner an admin, "
+ "in one step, so the team is never left with nobody who can manage it.");
}
membership.Role = role;
@@ -445,8 +656,9 @@ internal sealed class TeamService(
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be removed. Ownership transfer is not implemented, so "
+ "removing them would leave the team with nobody who can manage it.");
"This team's owner cannot be removed while they own it, because that would leave the "
+ "team with nobody who can manage it. Transfer ownership to another member first — "
+ "the outgoing owner becomes an admin and can then be removed like anybody else.");
}
var now = clock.GetUtcNow();
@@ -580,6 +792,27 @@ internal sealed class TeamService(
?? throw new TeamInvalidException("That account is not an active member of this team.");
}
/// <summary>Counts a team's active members.</summary>
private Task<int> CountMembersAsync(Guid teamId, CancellationToken cancellationToken) =>
database.TeamMemberships.CountAsync(
m => m.TeamId == teamId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken);
/// <summary>Counts the vaults a team owns.</summary>
/// <remarks>
/// Filtered on <c>OwnerKind</c> as well as on the id, matching <see cref="ListAsync"/>. A vault
/// carrying a team id it does not belong to would otherwise be counted here and not there, and
/// this count is what decides whether a team may be archived.
/// </remarks>
private Task<int> CountVaultsAsync(Guid teamId, CancellationToken cancellationToken) =>
database.Vaults.CountAsync(
v => v.TeamId == teamId
&& v.OwnerKind == VaultOwnerKind.Team
&& v.DeletedAtUtc == null,
cancellationToken);
/// <remarks>
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
/// else under an id that is already taken is refused: silently returning somebody else's team
+7
View File
@@ -36,6 +36,13 @@ builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<DirectoryService>();
builder.Services.AddScoped<KeyLogService>();
builder.Services.AddScoped<TeamService>();
builder.Services.AddScoped<TeamInvitationService>();
// Registered twice on purpose, resolving to the same scoped instance: the endpoints take the
// concrete service, and CurrentUserContext takes only the claim it needs, so the sign-in path does
// not gain a reference to the whole of a feature it calls one method on.
builder.Services.AddScoped<ITeamInvitationClaim>(
provider => provider.GetRequiredService<TeamInvitationService>());
builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
+21
View File
@@ -63,6 +63,27 @@ public sealed class OidcOptions
/// <summary>Claim type holding the user's display name.</summary>
public string NameClaim { get; set; } = "name";
/// <summary>
/// Claim type asserting that the provider has verified the user's email.
/// </summary>
/// <remarks>
/// <para>
/// Read for exactly one purpose: deciding whether a pending team invitation addressed to that
/// email may be claimed. Nothing else in this server trusts the email claim for anything, and
/// <see cref="AllowEmailLinking"/> records why — a token from any configured provider carrying a
/// victim's address must not confer access to anything of theirs. An invitation is access, so it
/// needs the same bar.
/// </para>
/// <para>
/// <b>Absence is a refusal, not a default.</b> A provider that does not send this claim leaves
/// every invitation pending for ever, which is visible on the teams screen and diagnosable in the
/// log. There is deliberately no option to trust an unverified address instead: a flag that exists
/// is a flag somebody turns on for the afternoon their provider is misconfigured, and this is the
/// one it must not be possible to turn on.
/// </para>
/// </remarks>
public string EmailVerifiedClaim { get; set; } = "email_verified";
}
/// <summary>Schema management.</summary>
@@ -45,10 +45,16 @@ internal static class EndpointRegistration
typeof(SyncPushEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
typeof(ArchiveTeamEndpoint),
typeof(TransferTeamOwnershipEndpoint),
typeof(ListTeamMembersEndpoint),
typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint),
typeof(ListTeamInvitationsEndpoint),
typeof(CreateTeamInvitationEndpoint),
typeof(RevokeTeamInvitationEndpoint),
typeof(CreateTeamVaultEndpoint),
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
@@ -1,25 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The launcher mark, and it is the same mark ServerScreen and LockedScreen draw: a square
outline in the accent with >_ inside it. Redrawn as a vector rather than exported as a
bitmap so there is one geometry to change and no set of five PNG densities to forget one
of.
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
#5B8CFF is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out
because an Android resource cannot reference a XAML dictionary. The same duplication
colors.xml already carries for the window background, and the same rule applies: if the
palette moves, this moves with it.
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
its mask, so letting the mask make the tile gets a squircle on one device and a circle on another
without either being drawn here. A rounded rectangle painted into this layer would be a second
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
one it was drawn for.
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer
18 on each edge is what the launcher eats for masking and parallax. That 72 is a width,
though, and the mark is a square: its corners are what a circular mask reaches first. At
48 across the corners land 33.9 out against a radius of 36 and read as clipped even
though they technically clear it. 42 puts them at 29.7, which is margin one can see.
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
the window behind it, and it follows AccentInk if the two ever part.
The stroke widths are the one place this deliberately departs from the screen. In the app
the box is a 1px border on 44px; scaled honestly that would be 1.0 here, and a launcher
drawing this at 48dp would render it at half a pixel and show nothing. 2.2 and 2.8 are
what keep it reading as the same hairline mark at the size it is actually looked at.
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
half the width of that 72 and centred in it. That half is taken from the headers rather than
invented: the phone draws >_ at font size 10 on a 26px tile and the titlebar at 9 on 20px, both a
little under half the tile across, and a launcher icon is looked at from further away than either.
The stroke width is the one place this deliberately departs from the screen. In the app the glyph
is a bold mono face whose stems come out near a fifth of its height; 5.4 here is nearer a quarter,
which is what keeps it reading as the same mark at the size it is actually looked at.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
@@ -27,28 +31,21 @@
android:viewportWidth="108"
android:viewportHeight="108">
<!-- The box: 42 across, centred, square-cornered as the Border in the app is. -->
<path
android:pathData="M33,33 L75,33 L75,75 L33,75 Z"
android:fillColor="#00000000"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.2" />
<!-- The chevron of >_ -->
<path
android:pathData="M44.8,48.75 L51.9,54 L44.8,59.25"
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
android:fillColor="#00000000"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.8"
android:strokeColor="#0E1220"
android:strokeWidth="5.4"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- The underscore, on the baseline the chevron bottoms out at. -->
<path
android:pathData="M54,59.7 L63.2,59.7"
android:pathData="M54,64.68 L71.94,64.68"
android:fillColor="#00000000"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.8"
android:strokeColor="#0E1220"
android:strokeWidth="5.4"
android:strokeLineCap="round" />
</vector>
@@ -4,9 +4,14 @@
the wallpaper's colours. The system tints this by its alpha and discards the colour, so
the geometry is the foreground's and white is only a way of saying "opaque here".
Note what that means for the filled design: the accent tile is the background layer, and a
themed icon drops the background entirely. So the shape that survives here is the glyph, not
the tile — which is the right way round anyway. Filling this layer to the edges to stand in
for the tile would tint to a featureless square with nothing of the mark left in it.
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
layer to use falls back to the full-colour icon, so the one app on the home screen still
drawn in green is this one.
drawn in blue is this one.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
@@ -15,24 +20,18 @@
android:viewportHeight="108">
<path
android:pathData="M33,33 L75,33 L75,75 L33,75 Z"
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.2" />
<path
android:pathData="M44.8,48.75 L51.9,54 L44.8,59.25"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.8"
android:strokeWidth="5.4"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M54,59.7 L63.2,59.7"
android:pathData="M54,64.68 L71.94,64.68"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.8"
android:strokeWidth="5.4"
android:strokeLineCap="round" />
</vector>
@@ -5,12 +5,17 @@
adaptive icons landed in 26, so there is no device this ships to that would need the
bitmaps. Density buckets exist to pick a PNG; a vector has nothing to pick between.
The background is the same @color/dodo_window the window, status bar and navigation bar
use, so the mark sits on the app's own near-black rather than on a second dark that is
almost but not quite it.
The background is the accent, and that is the tile itself rather than a backdrop for one:
the v2 mark is >_ knocked out of a solid accent square, so the square is this layer and the
launcher's mask is what rounds it. See ic_launcher_foreground.xml for why the rounding is
left to the mask instead of drawn.
It was @color/dodo_window until the mark went from outlined to filled, which is worth
knowing if a home screen still shows the dark version: a launcher caches icons, and the
cache outlives the install that changed them.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/dodo_window" />
<background android:drawable="@color/dodo_accent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -10,4 +10,11 @@
hidden.
-->
<color name="dodo_window">#0E1220</color>
<!--
AccentColor from the same palette, here because the launcher icon's background layer is a colour
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
this moves with it.
-->
<color name="dodo_accent">#5B8CFF</color>
</resources>
@@ -418,6 +418,10 @@
<!--
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
The tick below it is the phone's whole answer to storing one, and on this head it is the only one:
the keychain lists credentials here but has no editor to create one in, so before this a password
typed on a phone could only ever be typed again. The host editor's picker could then bind it.
-->
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PlaceholderText="password">
@@ -426,6 +430,12 @@
</TextBox.KeyBindings>
</TextBox>
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44"
IsVisible="{Binding SelectedHostAsksForAPassword}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Remember this password for this host" />
</CheckBox>
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !SelectedHostAsksForAPassword}"
Text="{Binding SelectedHostAuthenticationNote}" />
@@ -80,9 +80,10 @@
</RadioButton>
<!--
Tags. The one category holding nothing secret — a tag is a name — and here because renaming one is
one write instead of twenty, and a rename needs somewhere to happen. Making one usually happens in
a host's editor instead; this is where they are renamed and deleted.
Tags. The one category holding nothing secret — a tag is a name — and here because a host's editor
is the only place on this head that makes one, which leaves nowhere else to see the whole set or
what wears it. Deleting one happens here, with every other item kind; renaming is desktop work,
because nothing on this screen opens an editor.
-->
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsTags, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
@@ -112,11 +113,46 @@
-->
<TextBlock Grid.Row="2" Classes="detail" Margin="18,6,18,2" Text="{Binding SectionSummary}" />
<!-- ============ what to do with the chosen item ============ -->
<!--
DELETE and nothing beside it. The desktop's detail pane offers EDIT as well, and there is nothing here
for it to open — so this is the one action this head has for a keychain item, and until it existed the
confirmation below could not be reached at all.
It shares row 2 with that confirmation rather than being a bottom bar like the one on HOSTS or FILES,
because the question is what this button turns into: ShowsItemActions is false while a deletion is
armed, so the two swap in place and DELETE cannot be pressed again underneath its own question. A
button at the foot of the screen and its question at the top would be one exchange drawn twice.
And it names the item, because a keychain row is a card with a background of its own — see the selected
style below, which had nothing to colour until now. DELETE is the last button that should be pressed on
a guess about which row it means.
-->
<Border Grid.Row="2" IsVisible="{Binding ShowsItemActions}" Margin="12,4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="12" Padding="14,10">
<Grid ColumnDefinitions="*,10,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="label" Text="SELECTED" />
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
Text="{Binding SelectedVaultItem.Name}" TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline">
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
</Border>
</StackPanel>
</StackPanel>
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE"
Command="{Binding DeleteSelectedItemCommand}" />
</Grid>
</Border>
<!-- ============ ◆ the counted confirmation ============ -->
<!--
In place, above the list it refers to. The text comes from the view model, which counts what breaks —
"three hosts authenticate with this key and will refuse to connect" — and the count is the whole
difference between a sentence somebody reads and one they click past.
In place, above the list it refers to and over the button that asked it. The text comes from the view
model, which counts what breaks — "three hosts authenticate with this key and will refuse to connect" —
and the count is the whole difference between a sentence somebody reads and one they click past.
-->
<Border Grid.Row="3" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
@@ -152,6 +188,16 @@
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
<!--
The fill goes on the card and not on the ContentPresenter behind it, which is where HOSTS and
FILES put theirs. Those rows are bare Borders; a keychain row is Border.card, which paints an
opaque Sidebar over anything drawn underneath — so the selector has to reach the card itself or
the chosen row looks exactly like the others. It has to look different now that a button above
the list acts on it.
-->
<Style Selector="ListBoxItem:selected Border.card">
<Setter Property="Background" Value="{StaticResource Active}" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
@@ -24,10 +24,11 @@
<!-- Identity. The chip names the account so a phone with two profiles is not a guess. -->
<StackPanel Grid.Row="0" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="10" Margin="0,48,0,36">
<Border Width="44" Height="44" BorderBrush="{StaticResource Accent}" BorderThickness="1"
<!-- Filled since v2; see ServerScreen for where the 14 comes from. -->
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
HorizontalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource Accent}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="SemiBold"
<TextBlock Text="&gt;_" Foreground="{StaticResource AccentInk}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
@@ -49,37 +50,31 @@
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
phone client called slow.
-->
<TextBox Classes="secret" Text="{Binding Passphrase}" PlaceholderText="vault passphrase"
Height="48" Padding="14,0" VerticalContentAlignment="Center"
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"
FontFamily="{StaticResource MonoFont}" FontSize="12"
<TextBox Classes="field secret" Text="{Binding Passphrase}" PlaceholderText="vault passphrase"
IsEnabled="{Binding !IsBusy}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Content="UNLOCK" Command="{Binding UnlockCommand}" IsEnabled="{Binding !IsBusy}"
Height="48" Margin="0,10,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Background="{StaticResource Accent}" Foreground="{StaticResource AccentInk}"
CornerRadius="6" FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="SemiBold" />
<Button Classes="primary" Content="UNLOCK" Margin="0,10,0,0"
Command="{Binding UnlockCommand}" IsEnabled="{Binding !IsBusy}" />
<!--
Present only when this phone actually holds a device key. The design draws it unconditionally,
but offering a fingerprint that cannot open anything is worse than not offering one — see
AndroidDeviceKeyStore for the three ordinary ways it stops being available.
The label sets no font of its own: inside a Button.secondary it inherits the class's, which is
what stops this one reading half a point smaller than every other outlined button on the phone.
-->
<Button Command="{Binding UnlockWithDeviceCommand}" IsVisible="{Binding CanUnlockWithDevice}"
IsEnabled="{Binding !IsBusy}"
Height="48" Margin="0,8,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Background="Transparent" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
CornerRadius="6" Foreground="{StaticResource Text}">
<Button Classes="secondary" Margin="0,8,0,0"
Command="{Binding UnlockWithDeviceCommand}" IsVisible="{Binding CanUnlockWithDevice}"
IsEnabled="{Binding !IsBusy}">
<StackPanel Orientation="Horizontal" Spacing="9">
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
VerticalAlignment="Center" />
<TextBlock Text="UNLOCK WITH FINGERPRINT" FontFamily="{StaticResource MonoFont}"
FontSize="11" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock Text="UNLOCK WITH FINGERPRINT" VerticalAlignment="Center" />
</StackPanel>
</Button>
@@ -93,10 +88,15 @@
<!--
◆ The disclosure. Absent when there is nothing to disclose; never a card reading zero.
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
and this is a warning, so the background and the border are the warn pair rather than the chrome
one. What it does take is the radius the v2 ladder gives anything card-sized, which is what the
other two already draw.
-->
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<!-- Live, not Accent: this marks shells that are still running, which is a fact and not a
@@ -116,12 +116,17 @@
<!--
The only answer to a forgotten passphrase, and it is deliberately the last thing on the screen and
the only red one. Nothing can recover a passphrase; this empties the phone and starts again.
Two of Button.danger's properties are overridden rather than taken, and they are the two that would
change what this control says. Elsewhere that class is a DELETE sitting beside a KEEP, where a red
outline marks which of two offered actions costs something; here there is no pair, and a bordered
red button under UNLOCK would read as the second thing to try rather than as the last. Borderless
and Medium is what keeps it a way out rather than an offer. Everything else — the height, the
centring, the colour, the type — is the class's.
-->
<Button Grid.Row="2" Command="{Binding SignOutCommand}"
Margin="0,28,0,20" Padding="0,14" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center" Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Danger}" FontFamily="{StaticResource MonoFont}"
FontSize="10.5" FontWeight="Medium"
<Button Grid.Row="2" Classes="danger" Margin="0,28,0,20"
BorderThickness="0" FontWeight="Medium"
Command="{Binding SignOutCommand}"
Content="RESET THIS PHONE — forgot passphrase" />
</Grid>
@@ -8,18 +8,22 @@
<!--
Design v2 — SETTINGS: the hub for everything the bottom bar has no room for.
Three slots and nine destinations is the arithmetic the design solves by putting the rest one tap
Three slots and ten destinations is the arithmetic the design solves by putting seven of them one tap
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
every row on it is a navigation command and nothing here reads an item.
Teams is the tenth and the design never drew it — see the row itself. That one is the design's count
plus one rather than a rearrangement of it. The keychain below is a rearrangement, and the only one:
the bottom bar lost a slot to gain this row.
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
entries are one word each is a menu you have to open to read.
── from MORE to SETTINGS ──────────────────────────────────────────────────────────────────────────────
The hub was called More because that was the honest name for five things that did not fit. With the
keychain on it as well it is the place everything that is not connecting to a machine lives, which has a
name of its own and a glyph everyone already knows.
The hub was called More because that was the honest name for a handful of things that did not fit. With
the keychain on it as well it is the place everything that is not connecting to a machine lives, which
has a name of its own and a glyph everyone already knows.
The keychain is first, and above the rule, because it is the only row here that holds the user's own
material rather than a screen about the application. Everything below it is somewhere to look at what
@@ -123,6 +127,29 @@
</Grid>
</Button>
<!--
Teams, 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
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
giving one destination two marks is how a user learns the wrong one.
-->
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Team}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Teams" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Who shares a keychain with you, and who holds its key." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -20,10 +20,17 @@
<ScrollViewer>
<StackPanel VerticalAlignment="Center" Margin="24,48" Spacing="12">
<Border Width="44" Height="44" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
<!--
The same filled tile the rest of the head draws since v2, at the same 44 and the same 14 — and
drained rather than recoloured. Accent on this screen would be the shell's one "this is what the
screen wants you to do" colour spent on the one screen that wants nothing, so the tile is Raised
and the glyph stays TextFaint. The shape says which application this is; the colour says the
screen is inert, which is the whole message.
-->
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Raised}"
HorizontalAlignment="Left">
<TextBlock Text="&gt;_" Foreground="{StaticResource TextFaint}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="SemiBold"
FontSize="16" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
@@ -14,10 +14,11 @@
state machine. What differs is only what each one draws.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper
behind MORE: snippets, SFTP, S3, logs and preferences. That is the v2 design's own arrangement, and it
replaces the first design's four, which had nothing behind them at all. Five characters was a desktop
constraint and the phone uses words.
The desktop's eight rail destinations become four in a bottom bar, and the rest live one tap deeper
behind MORE: snippets, SFTP, S3, logs, preferences — and teams, which v2 did not draw and which is
argued for on the screen itself. That is the v2 design's own arrangement, and it replaces the first
design's four, which had nothing behind them at all. Five characters was a desktop constraint and the
phone uses words.
The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
@@ -58,7 +59,7 @@
Hidden behind SETTINGS, and that is the design's arrangement rather than a saving. v2 gives every
screen one header carrying that screen's own name and its own actions — a back arrow, an add, a
refresh — so the hub screens draw their own and this one stands down rather than stacking a second
refresh — so the hub's screens draw their own and this one stands down rather than stacking a second
row of chrome above theirs. What is left is HOSTS, which is the screen the application opens on and
the one where the vault's name and the sync light are the most useful thing a header could say.
@@ -135,7 +136,7 @@
============ under MORE ============
The hub itself takes the shell as its data context, because every row on it is a navigation
command; the five destinations behind it each take the view model they are about, so each one is
command; the destinations behind it each take the view model they are about, so each one is
wrapped. SnippetsScreen and LogsScreen are nullable on the shell — they are rebuilt on every
unlock and nulled on lock — and it is the collapsed wrapper that keeps a template from binding
against nothing.
@@ -150,6 +151,15 @@
<views:LogsScreen DataContext="{Binding LogsScreen}" />
</Panel>
<!--
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
is the data context, not the null. IsTeamShowing is the shell's and Teams is not the shell.
-->
<Panel IsVisible="{Binding IsTeamShowing}">
<views:TeamsScreen DataContext="{Binding Teams}" />
</Panel>
<!--
One screen for both file destinations. SFTP and S3 differ in which picker they offer and in
nothing else below it — the panes, the queue and the transfers are the same IRemoteFileStore
@@ -282,13 +292,14 @@
Command="{Binding ShowTerminalCommand}" />
<!--
IsMoreSurface rather than IsMoreShowing: this tab stands for seven screens, and a bar that went
dark the moment you opened one of them would only ever light two of its three entries.
IsMoreSurface rather than IsMoreShowing: this tab stands for the hub and everything behind it,
and a bar that went dark the moment you opened one of them would only ever light two of its
three entries.
"Settings" and a gear, where it was "More" and a hamburger. The hub held five destinations and a
paragraph and could not be called anything better than More; with the keychain and preferences
both on it, it is the place everything that is not connecting to something lives — which is what
a gear has meant on a phone since before this application existed.
"Settings" and a gear, where it was "More" and a hamburger. The hub held a handful of things
that did not fit and could not be called anything better than More; with the keychain, teams and
preferences all on it, it is the place everything that is not connecting to something lives —
which is what a gear has meant on a phone since before this application existed.
-->
<views:NavButton Grid.Column="2" Label="Settings" Glyph="⚙" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}"
@@ -272,7 +272,7 @@ internal sealed partial class PhoneShell : UserControl
/// </summary>
/// <remarks>
/// <para>
/// v2 is the first arrangement here with a second level: five destinations sit behind MORE, each with
/// v2 is the first arrangement here with a second level: six destinations sit behind MORE, each with
/// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first,
/// and left unhandled it does not go up — it finishes the activity. Ending the application from a log
/// screen is not a plausible reading of "back".
@@ -347,7 +347,8 @@ internal sealed partial class PhoneShell : UserControl
switch (current.Screen)
{
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vault:
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Team
or ShellScreen.Vault:
current.ShowScreenCommand.Execute(ShellScreen.More);
e.Handled = true;
break;
@@ -18,10 +18,16 @@
<StackPanel Margin="24,0" VerticalAlignment="Center" Spacing="0">
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
<Border Width="44" Height="44" BorderBrush="{StaticResource Accent}" BorderThickness="1"
<!--
Filled rather than outlined since v2, the same mark the header, the desktop titlebar and the
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, which is a
third of a unit per unit of tile and lands on 14 at 44 — and 14 is a rung of Phone.axaml's
ladder, the one for a block of monospaced output, which is what this is.
-->
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
HorizontalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource Accent}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="SemiBold"
<TextBlock Text="&gt;_" Foreground="{StaticResource AccentInk}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Classes="title" Text="DodoSSH" HorizontalAlignment="Center" />
@@ -0,0 +1,367 @@
<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>
@@ -0,0 +1,10 @@
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);
}
+118 -11
View File
@@ -75,6 +75,27 @@ public interface ITeamApi
/// <summary>Creates a team, with the caller as its owner.</summary>
Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
/// <summary>Renames a team, or changes its description.</summary>
Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Archives a team. Refused while it still owns vaults.
/// </summary>
/// <returns>
/// Whether there was a team to archive. False means there was not, which a caller driving towards
/// "that team is gone" should treat as having arrived.
/// </returns>
Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken);
/// <summary>Hands ownership to another member, demoting the outgoing owner to admin.</summary>
Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken);
/// <summary>Lists a team's members.</summary>
Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
@@ -102,6 +123,30 @@ public interface ITeamApi
/// </returns>
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken);
/// <summary>Invites an email address to a team.</summary>
Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws an invitation that has not been taken up.
/// </summary>
/// <returns>
/// Whether there was a live invitation to withdraw. False covers one that was never there and one
/// already claimed — a claimed invitation is a membership now, and removing a member is a different
/// operation with different consequences.
/// </returns>
Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken);
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
@@ -341,6 +386,35 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
JsonContent.Create(request, DodoSshJsonContext.Default.UpdateTeamRequest),
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
cancellationToken);
/// <inheritdoc />
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken) =>
SendNoContentAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/owner"),
JsonContent.Create(request, DodoSshJsonContext.Default.TransferTeamOwnershipRequest),
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
@@ -386,6 +460,39 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest),
DodoSshJsonContext.Default.TeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(
CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"),
cancellationToken);
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
@@ -507,22 +614,13 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a delete whose success carries no body.
/// </summary>
/// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
/// <remarks>
/// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer.
/// </remarks>
/// <summary>
/// Sends a request whose success carries no body.
/// </summary>
/// <remarks>
/// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
/// not be recorded is a failure with a problem document behind it, so there is nothing here to
/// translate into a return value.
/// not be recorded, or an ownership transfer that will not happen, is a failure with a problem
/// document behind it, so there is nothing here to translate into a return value.
/// </remarks>
private async Task SendNoContentAsync(
HttpMethod method,
@@ -547,6 +645,15 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
}
}
/// <summary>
/// Sends a delete whose success carries no body.
/// </summary>
/// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
/// <remarks>
/// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer.
/// </remarks>
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
+129 -19
View File
@@ -35,13 +35,40 @@
wraps in the application and does not wrap in the harness makes every measured height too small, which
is the one way this kind of test lies quietly.
-->
<!--
◆ THE BASE SIZE, and it is set here rather than on the window.
Every explicit FontSize below is a step off a base nothing was stating: a bare TextBlock took
TextElement's own default of 12, which meant the one number the whole scale is measured from lived in
Avalonia rather than in this file. Raising the scale meant naming it.
Declared on Window *and* on UserControl, which is not redundancy. FontSize inherits, so the window
alone would reach everything the application draws — but the layout harness hosts a UserControl in a
plain `new Window()` it constructs itself, and that window has no style of this application's on it.
Without the second selector the harness would measure every screen a point smaller than it ships,
which is the failure mode App.axaml's own note at the top of this block was written about: a test that
lays out text smaller than the application does reports heights that are all slightly too small, and
it does it silently.
A selector on TextBlock would have been the obvious way and is wrong. Style setters beat inherited
values in Avalonia, so `Selector="TextBlock"` would win over the size a Button sets on itself and
reach the TextBlock inside that button's template — collapsing every deliberate step below back to one
number.
-->
<Style Selector="Window">
<Setter Property="FontSize" Value="13" />
</Style>
<Style Selector="UserControl">
<Setter Property="FontSize" Value="13" />
</Style>
<Style Selector="TextBlock.hint">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.heading">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontSize" Value="18" />
<Setter Property="FontSize" Value="19" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Border.card">
@@ -74,7 +101,7 @@
-->
<Style Selector="TextBlock.label">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
@@ -93,7 +120,7 @@
</Style>
<Style Selector="Border.chip > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="LetterSpacing" Value="0.5" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
@@ -215,7 +242,7 @@
<!-- Every button in this window is small, mono and tracked out; only the colours differ. -->
<Style Selector="Button.ghost, Button.accent, Button.danger">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontSize" Value="11.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LetterSpacing" Value="0.8" />
<Setter Property="Padding" Value="10,5" />
@@ -259,7 +286,7 @@
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="LetterSpacing" Value="0.5" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
@@ -289,7 +316,7 @@
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
@@ -315,7 +342,7 @@
because a number that lit with its row would compete with the word beside it for the same emphasis.
-->
<Style Selector="TextBlock.navicon">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Width" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
@@ -325,7 +352,7 @@
</Style>
<Style Selector="TextBlock.navcount">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
@@ -343,7 +370,7 @@
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
@@ -375,6 +402,19 @@
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
One of the strip's three fixed tabs — Vaults, SFTP, S3. A pill in every respect except that it has no
close box, so it takes its padding back on the right: the base rule is short there to leave room for
the cross a terminal tab carries inside itself, and a fixed tab with the same asymmetry sits visibly
off-centre beside one that has a reason for it.
Nothing else differs, deliberately. These are tabs and have to read as tabs — the whole point of the
strip is that "where the window is" is one row of one kind of control.
-->
<Style Selector="Button.tab.fixed">
<Setter Property="Padding" Value="12,0" />
</Style>
<!--
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
state, because it is never the thing showing, and no outline, because it is not one of the things
@@ -411,7 +451,7 @@
<Style Selector="Button.choice">
<Setter Property="Padding" Value="10,5" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="LetterSpacing" Value="0.5" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
@@ -461,9 +501,9 @@
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="SelectionBrush" Value="{StaticResource AccentSoft}" />
<Setter Property="CaretBrush" Value="{StaticResource Accent}" />
<Setter Property="MinHeight" Value="28" />
<Setter Property="MinHeight" Value="30" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style Selector="TextBox /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{StaticResource Field}" />
@@ -512,6 +552,76 @@
<Setter Property="Background" Value="{StaticResource AccentWash}" />
</Style>
<!--
◆ A LIST OF TILES, which is the hosts screen's grid of group and host cards.
The three rules above paint the *item* — a full-bleed rectangle behind whatever the template draws —
and that is right for a row and wrong for a card: a square wash behind a 10-pixel-rounded tile shows
as four grey corners, and it does it on hover as well as on selection, which is most of the time the
pointer is anywhere near the grid. So a tiles list clears all three and the tile paints its own
states.
Declared after them and not before. Avalonia has no specificity: two rules matching one element are
settled by declaration order, so an exception stated above the rule it excepts does nothing at all.
That trap is recorded twice more in this file, for Button.tab and for Border.rowmark.
-->
<Style Selector="ListBox.tiles > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="ListBox.tiles > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="ListBox.tiles > ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<!--
The card itself. A fixed width and a free height, which is the pair that makes a WrapPanel of these
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
rather than narrower.
◆ 232 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
column at the window's minimum is 1016 less the rail's 190 and the drawer's 304, which is 522. The
scrolling stack inside it takes 16 of margin on each side, and the vertical scrollbar takes its own —
call the usable width 490. A WrapPanel fits floor(490 / (Width + 10)) per row, so two columns needs
Width no more than 235.
The first number here was 248, from the same reasoning with the two margins left out. It laid out
cleanly and the layout harness passed it, because the harness asks whether a control is inside the
window and not how many of them fit on a line — so the grid quietly became one column wide at exactly
the size this application guarantees, which is the shape the cards exist to avoid.
-->
<Style Selector="Border.tile">
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="Width" Value="232" />
<Setter Property="Margin" Value="0,0,10,10" />
</Style>
<Style Selector="ListBoxItem:pointerover Border.tile">
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
</Style>
<!--
Selected is the accent outline *and* the filled surface, which is the same pair the nav rail and the
tab strip use for "you are here". A tile marked by its border alone is legible on a card you are
looking at and invisible in peripheral vision, which is where a selected card usually is once the
drawer beside it has opened.
-->
<Style Selector="ListBoxItem:selected Border.tile">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
<!-- The square a tile carries on its left: a group's mark, or a host's prompt. -->
<Style Selector="Border.tileicon">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
The status dot, in one place rather than as a converter in code.
@@ -558,7 +668,7 @@
Set from the code-behind rather than by a binding, for the reason the whole gesture is code: the rows
are rebuilt from scratch on every filter keystroke and every sync pass, so a flag on the view model
would be state the list throws away halfway through the drag. See HostSidebar.axaml.cs.
would be state the list throws away halfway through the drag. See HostsScreen.axaml.cs.
-->
<Style Selector="ListBoxItem.droptarget /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
@@ -577,7 +687,7 @@
<Setter Property="CornerRadius" Value="4" />
</Style>
<Style Selector="MenuItem">
<Setter Property="FontSize" Value="12" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
@@ -602,16 +712,16 @@
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="MinHeight" Value="28" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="30" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style Selector="NumericUpDown">
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="MinHeight" Value="28" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="30" />
<Setter Property="FontSize" Value="13" />
</Style>
<!--
The foreground goes on the content presenter as well as on the control, for the same reason the
@@ -621,7 +731,7 @@
-->
<Style Selector="CheckBox">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontSize" Value="13" />
<Setter Property="MinHeight" Value="0" />
</Style>
<Style Selector="CheckBox /template/ ContentPresenter#PART_ContentPresenter">
@@ -0,0 +1,119 @@
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
#
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
#
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
#
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
# here would draw the glyph at half the size it is meant to be — correct arithmetic, and a stamp
# lost in a field of accent. Matching what the phone displays means matching the 72.
#
# The one thing this draws that the phone's vector does not is the tile. On Android the tile is
# the background layer and the launcher's mask rounds it; Windows has no mask, so the rounding
# happens here.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
# the gap, and its downsampler is not kind to a hairline.
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
function New-MarkPng([int]$size)
{
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bitmap)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
$radius = [double]$size * 0.22
$d = $radius * 2.0
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
$path.CloseFigure()
$brush = New-Object System.Drawing.SolidBrush($accent)
$g.FillPath($brush, $path)
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
$scale = [double]$size / 72.0
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
# difference between a mark and a smudge. The phone's file already bumps this width for the
# same reason at 48dp; the floor is that argument carried down to the sizes Windows asks for.
$pen = New-Object System.Drawing.Pen($ink, [float][Math]::Max(5.4 * $scale, 1.0))
$pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
$pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
$pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
# The chevron of >_ and the underscore on the baseline it bottoms out at.
$g.DrawLines($pen, @((P 36.06 43.33), (P 49.91 53.57), (P 36.06 63.8)))
$g.DrawLine($pen, (P 54 64.68), (P 71.94 64.68))
$stream = New-Object System.IO.MemoryStream
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
$pen.Dispose(); $brush.Dispose(); $path.Dispose(); $g.Dispose(); $bitmap.Dispose()
return $stream.ToArray()
}
# ICO is a six-byte header, a sixteen-byte directory entry per image, then the images. The entries
# carry PNG payloads rather than the older BMP-with-AND-mask form, which every Windows since Vista
# reads and which is what keeps a 256px entry from costing 256KB.
#
# The cast on each frame is load-bearing. A byte[] returned through PowerShell's output collector
# comes back as Object[] of boxed bytes, which BinaryWriter has no overload for — it binds to one
# of the scalar Write()s instead and puts a single byte on the stream. The first run of this
# script produced a 159-byte .ico that way, header and directory intact and nine one-byte images.
$frames = New-Object 'System.Collections.Generic.List[byte[]]'
foreach ($size in $sizes)
{
[byte[]]$png = New-MarkPng $size
$frames.Add($png)
}
$out = New-Object System.IO.MemoryStream
$w = New-Object System.IO.BinaryWriter($out)
$w.Write([uint16]0) # reserved
$w.Write([uint16]1) # type: icon
$w.Write([uint16]$sizes.Count)
$offset = 6 + (16 * $sizes.Count)
for ($i = 0; $i -lt $sizes.Count; $i++)
{
$size = $sizes[$i]
$w.Write([byte]($(if ($size -ge 256) { 0 } else { $size }))) # 0 means 256
$w.Write([byte]($(if ($size -ge 256) { 0 } else { $size })))
$w.Write([byte]0) # palette entries: none, this is truecolour
$w.Write([byte]0) # reserved
$w.Write([uint16]1) # colour planes
$w.Write([uint16]32) # bits per pixel
$w.Write([uint32]$frames[$i].Length)
$w.Write([uint32]$offset)
$offset += $frames[$i].Length
}
foreach ($frame in $frames) { $w.Write($frame) }
$w.Flush()
$target = Join-Path $PSScriptRoot 'dodossh.ico'
[System.IO.File]::WriteAllBytes($target, $out.ToArray())
$w.Dispose(); $out.Dispose()
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -5,6 +5,14 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!--
The icon on the executable itself — what Explorer, the Start menu and a pinned taskbar button
draw, all of which read it from the PE resource and never start the process. Window.Icon in
MainWindow.axaml is a separate thing that only exists once the application is running; both are
needed, and both point at this file.
-->
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
<!--
False here, unlike every server project. The root Directory.Build.props sets it true because
the API is container-hosted, UTC-only and has no business formatting anything for a human.
@@ -15,6 +23,14 @@
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!--
Named rather than globbed as Assets/**, because the folder also holds the script that draws the
icon and a build has no reason to carry a copy of it around inside the binary.
-->
<AvaloniaResource Include="Assets/dodossh.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
@@ -7,14 +7,14 @@
<!--
The question in front of deleting something in the vault.
One control used in two places — the host sidebar, where it takes the place of the row of buttons that
opened it, and the vault screen's detail pane, where it takes the place of EDIT and DELETE. The two
moments are different and what has to be said is not, which is why this is a shared control rather than
two blocks that would drift apart. The sign-out confirmation is the same arrangement, for the same
reason; see SignOutCard.
One control used in three places — the hosts drawer, where it takes the place of the row of buttons that
opened it; the hosts screen's GROUPS section, where it takes the place of that group's EDIT and DELETE;
and the vault screen's detail pane. The three moments are different and what has to be said is not,
which is why this is a shared control rather than three blocks that would drift apart. The sign-out
confirmation is the same arrangement, for the same reason; see SignOutCard.
A bare StackPanel and not a card, because the two hosts frame it themselves: the sidebar puts it in the
strip along its bottom edge, and the vault screen in a column that scrolls.
A bare StackPanel and not a card, because all three hosts frame it themselves — each wraps it in its own
DangerWash border in the place its buttons were.
Everything it says is something the view model can answer. The question names the item, the consequence
knows whether this machine can push a tombstone yet, and the line in the box is a count of the hosts
@@ -24,10 +24,10 @@
<StackPanel Spacing="8">
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Question}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Consequence}" />
<!--
@@ -37,7 +37,7 @@
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8,6"
IsVisible="{Binding PendingDeletion.HasUsage, FallbackValue=False}">
<TextBlock Foreground="{StaticResource Info}" FontSize="11" TextWrapping="Wrap"
<TextBlock Foreground="{StaticResource Info}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Usage}" />
</Border>
@@ -6,8 +6,9 @@ namespace DodoSSH.Client.App.Views;
/// The question in front of deleting a host, a key or a password.
/// </summary>
/// <remarks>
/// Its data context is the <c>VaultViewModel</c>, in both of the places it is shown, so every binding in the
/// markup is a property of the vault. See <see cref="HostSidebar"/> and <see cref="VaultScreen"/>.
/// 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
/// <see cref="VaultScreen"/>.
/// </remarks>
internal sealed partial class ConfirmDeleteCard : UserControl
{
@@ -29,10 +29,10 @@
Margin="24">
<StackPanel Spacing="6" HorizontalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="15" FontWeight="Medium"
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="10.5"
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</StackPanel>
@@ -42,17 +42,17 @@
the reason, in the palette's red, because it is the only place the reason will be after the user
navigates away from the screen that started the connection.
-->
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="11"
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"
IsVisible="{Binding SelectedTab.IsConnecting}" />
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="12"
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"
IsVisible="{Binding SelectedTab.IsFailed}" />
<TextBlock Classes="hint" FontSize="10.5" TextAlignment="Center" HorizontalAlignment="Center"
<TextBlock Classes="hint" FontSize="11.5" TextAlignment="Center" HorizontalAlignment="Center"
Text="Nothing else is waiting for this. Every other screen still works, and the strip above says how this one is getting on."
IsVisible="{Binding SelectedTab.IsConnecting}" />
@@ -0,0 +1,346 @@
<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.App.Views"
x:Class="DodoSSH.Client.App.Views.HostDrawer"
x:DataType="vm:VaultViewModel">
<!--
Everything about one thing: what the selected host is, the editor for it, or the editor for a group.
── THIS WAS HostSidebar, AND THE HOST LIST IS NOT IN IT ANY MORE. ───────────────────────────────────
It used to be a 268-pixel column on the left holding a filter box, the list of every host, and the
editor underneath. That column was doing two jobs at two-thirds size — choosing among forty machines,
and editing one of them — and it did the first one badly enough that the second was the only reason to
keep it narrow. The list is now a grid of cards filling the screen, so this control kept the half that
is about one host and moved to the right, which is where the thing you selected belongs.
Renamed with the job rather than kept as HostSidebar, unlike NavRail, which kept its name when it only
changed width. This one changed what it holds, which side it is on, and whether it is there at all.
── EXACTLY ONE OF THE THREE PANELS IS SHOWING ───────────────────────────────────────────────────────
Detail, host editor, group editor. They are exclusive by construction rather than by three flags that
could disagree: IsShowingHostDetail is defined as "neither editor is open and something is selected",
so no two of these can be true at once. The whole control collapses when none of them is — see
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
300-pixel column beside a grid is 300 pixels the grid could have had.
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
screen hands it over.
Nothing in here may be laid over the terminal's rectangle: it is a column of the hosts screen, and the
hosts screen is a sibling of the WebView. See MainWindow.axaml's occlusion rule.
-->
<Border Width="304" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<!--
◆ IT SCROLLS AS A WHOLE, and the host editor no longer carries a MaxHeight of its own.
The old column gave the editor 300 pixels and let the list above have the rest, so the editor had to
be bounded separately. Here the drawer is the only thing in its column, so one ScrollViewer over all
three panels is both simpler and more honest: whichever panel is up gets the whole height, and the
one that overflows is the one that scrolls.
The cost is the one the old note recorded and it has not changed: the layout harness skips anything
with a ScrollViewer in its ancestry — see LayoutHarness.IsScrollable — so from here on it certifies
that this pane fits the column rather than that every field inside it does. That is the true claim
about a pane that scrolls, and the tag picker is why it has to scroll: its height is a chip per tag
in the keychain, wrapped, so no fixed height holds it for somebody with fifteen.
-->
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<Panel>
<!-- ============ WHAT THIS HOST IS ============ -->
<StackPanel Margin="16" Spacing="12" IsVisible="{Binding IsShowingHostDetail}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Classes="dot" Classes.live="{Binding SelectedHost.IsConnected}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding SelectedHost.Label}" FontSize="16" FontWeight="SemiBold"
Foreground="{StaticResource Text}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<SelectableTextBlock Classes="mono" Text="{Binding SelectedHost.Address}" FontSize="12"
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" />
<!--
Which of the three ways this host authenticates, and where it came from. The note rather than
the one-word Authentication the card shows: a host that inherits its group's key is the case
where the word alone is misleading, and there is room for the sentence here.
-->
<TextBlock Classes="hint" FontSize="12" Text="{Binding SelectedHostAuthenticationNote}" />
<!--
The tags it wears, as the same chips the card draws. Repeated rather than shared with the card's
template because the two are different shapes — the card wraps them under a two-line summary and
this is a column 304 wide — and a shared template would have to be told which.
-->
<ItemsControl ItemsSource="{Binding SelectedHost.TagLabels}"
IsVisible="{Binding SelectedHost.HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip" Padding="6,1" Margin="0,0,4,4">
<TextBlock Text="{Binding}" FontSize="9.5" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<SelectableTextBlock Text="{Binding SelectedHost.Host.Notes}" FontSize="12"
Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
IsVisible="{Binding SelectedHost.Host.Notes,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,2" />
<!--
Connecting, and the box a host that wants a typed password needs. A sentence in the box's place
when it does not, because "nothing needs typing" and "something needs typing and the box has not
appeared" look identical and only one of them is fine.
It is here rather than in a bar across the top of the screen, which is where it used to be: the
password belongs to the host, and a box at the top of a grid of forty machines is one whose
subject you have to work out. That move is also what this arrangement is for — the bar had one
row and had to fit the box, the tick, the note and CONNECT along it; a column has room to put
the tick under the box it qualifies, which is where it reads as a property of the password
rather than as a fourth control in a row.
REMEMBER travels with the box and hides with it. It is the two-step chore the box's tooltip used
to describe — add a password under Keychain, then bind the host to it — done from the one place
that already has the password, and it takes effect only once the remote has accepted it.
-->
<TextBox Text="{Binding ConnectPassword}" PlaceholderText="password"
PasswordChar="•" HorizontalAlignment="Stretch"
IsVisible="{Binding SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time unless REMEMBER is ticked, in which case it is saved to your keychain and bound to this host once the connection succeeds." />
<CheckBox IsChecked="{Binding RemembersConnectPassword}"
IsVisible="{Binding SelectedHostAsksForAPassword}"
ToolTip.Tip="Saves this password to your keychain, bound to this host, so it is not asked for again. It syncs to your other machines, and only happens if the connection works.">
<TextBlock Text="Remember this password" Classes="hint" FontSize="12" />
</CheckBox>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding ShowsHostActions}">
<Button Classes="accent" Content="CONNECT" Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteHostCommand}" />
</StackPanel>
<!--
Swapped for the buttons rather than stacked under them, as it always was, so DELETE cannot be
pressed again while its own question is on screen. See VaultViewModel.ShowsHostActions.
-->
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingHostDeletion}">
<views:ConfirmDeleteCard />
</Border>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
</StackPanel>
<!-- ============ THE HOST EDITOR ============ -->
<!--
The editor doubles as the "add" form; there is no separate dialog.
-->
<StackPanel Margin="16" Spacing="6" IsVisible="{Binding IsEditing}">
<TextBlock Classes="label" Text="HOST" Foreground="{StaticResource TextDim}" Margin="0,0,0,4" />
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
<!--
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset". The
watermark is what the host will actually use if it is left that way, which is why it is bound
rather than literal: it changes when the group picker below moves.
-->
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False"
PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
</Grid>
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="56" TextWrapping="Wrap" />
<!--
How this host authenticates: a typed password, one of the vault's keys, or one of its credentials.
Part of the host rather than of the connection, so it follows the host to every machine; a host
bound to something since deleted keeps a placeholder entry here, so that editing the port cannot
quietly turn it back into a typed-password host.
One control for all three, which is what makes "a key or a credential, never both" impossible to
express rather than merely invalid. The qualifier beside each label is not decoration: a key called
"deploy" and the deploy account's password are the ordinary case, and bare labels would offer two
identical-looking entries that authenticate completely differently.
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
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 vault no longer has
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
The tags this host wears. Chips that toggle rather than a multi-select list, because a chip is
what a tag looks like on the card in the grid — a list of names to tick would make the user
match an entry to a chip they can already see.
The box under them creates one and puts it on straight away. That is where a tag is usually
wanted: while tagging a host and finding it does not exist yet. Unlike every other field here it
writes to the keychain immediately, because a host can only name a tag that has an id — so
cancelling this editor leaves the tag behind, which is honest rather than hidden. Renaming and
deleting are on the keychain screen, where every other item kind is managed.
-->
<ItemsControl ItemsSource="{Binding EditorTagChoices}" IsVisible="{Binding HasTagChoices}"
Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagChoice">
<!--
Worn is filled, unworn is outlined. One control per tag with two states rather than a
checkbox beside a label: the state and the name occupy the same object, so a row of them
reads as the host's tags rather than as a form about them.
-->
<Button Classes="chiptoggle" Classes.worn="{Binding IsWorn}" Margin="0,0,4,4"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).ToggleEditorTagCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Label}" FontSize="10.5" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="ghost" Content="ADD" Command="{Binding AddEditorTagCommand}" />
</Grid>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to resolve
the target, or it becomes an authenticated open proxy into the operator's network.
-->
<TextBlock Classes="hint" FontSize="11"
Text="The relay stores this host's address on the server in plain text. Everything else stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
</StackPanel>
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
not a field of the host.
-->
<Button Classes="danger" Content="FORGET HOST KEY" HorizontalAlignment="Left"
Command="{Binding ForgetHostKeyCommand}"
IsVisible="{Binding CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
<!-- ============ THE GROUP EDITOR ============ -->
<!--
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
grid is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is
done in the host's own editor above, for the same reason its key and its password are.
One form for both adding and renaming; GroupSaveLabel is what says which of the two is about to
happen. The four fields under the name are what the hosts inside inherit when they say nothing
themselves; every one of them may be left empty, and empty means "lend nothing" rather than
"unset". The parent picker leaves out this group and everything beneath it, so a cycle cannot be
made here — which is a courtesy rather than the guarantee, because one assembled offline on two
machines was never offered this list. See HostInheritance.
-->
<StackPanel Margin="16" Spacing="6" IsVisible="{Binding IsEditingGroup}">
<TextBlock Classes="label" Text="GROUP" Foreground="{StaticResource TextDim}" Margin="0,0,0,4" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A heading for the grid, and the defaults every host under it inherits. Which group a host is in is part of the host, and stays encrypted." />
<TextBox Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
SelectedItem="{Binding GroupEditorSelectedParent}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding GroupEditorDefaultPort}" Minimum="1"
Maximum="65535" FormatString="0" ShowButtonSpinner="False"
PlaceholderText="default port" />
<TextBox Grid.Column="2" Text="{Binding GroupEditorDefaultUsername}"
PlaceholderText="default username" />
</Grid>
<ComboBox ItemsSource="{Binding GroupEditorAuthenticationChoices}"
SelectedItem="{Binding GroupEditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
Command="{Binding SaveGroupCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGroupEditCommand}" />
</StackPanel>
</StackPanel>
</Panel>
</ScrollViewer>
</Border>
</UserControl>
@@ -0,0 +1,23 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The column beside the hosts grid: what the selected host is, or one of the two editors.
/// </summary>
/// <remarks>
/// <para>
/// This was <c>HostSidebar</c>, which held the host list as well and sat on the left. The list is a grid of
/// cards on <see cref="HostsScreen"/> now, so everything that made that control need code — the double-tap
/// that connects, the right click that moves the selection before the menu opens, and the drag that files a
/// host into a group — went with the list. What is left is markup, which is why this class is empty.
/// </para>
/// <para>
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault
/// rather than of the shell. <see cref="HostsScreen"/> hands it over.
/// </para>
/// </remarks>
internal sealed partial class HostDrawer : UserControl
{
public HostDrawer() => InitializeComponent();
}
@@ -1,366 +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.App.Views"
x:Class="DodoSSH.Client.App.Views.HostSidebar"
x:DataType="vm:VaultViewModel">
<!--
The host list, and the editor for whichever host is open.
This was the top half of VaultColumn. The design gives hosts their own column beside the terminal and
puts everything else in the vault screen, which is the split this control and VaultScreen are — and it
is a better split than the one it replaces: the hosts list is the thing you look at while you work, and
the keys and passwords behind it are the thing you go and manage.
Its data context is the VaultViewModel, so every binding here is a property of the vault. The shell
hands it over; see MainWindow.
The editor stays in this column rather than moving into the terminal's half of the window, and that is
an occlusion constraint rather than a preference: the terminal's WebView is a native child window that
composites above anything Avalonia draws in the same rectangle, so a form laid over there would render
underneath it with its buttons unclickable. It is also why this control is measurable at all — no part
of it is the WebView, so the layout harness can lay it out headlessly.
-->
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Background="{StaticResource Sidebar}">
<!--
The filter. It narrows this list and nothing else — the connect path, the selection and the pinned
host key list all read the unfiltered collection — so a filter left in the box can hide a host but
can never break one.
-->
<Border Grid.Row="0" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" PlaceholderText="filter hosts" FontSize="11"
FontFamily="{StaticResource MonoFont}" MinHeight="26" Padding="8,3" />
</Border>
<!--
One heading, which names the vault while there is one and says ALL VAULTS once a team's is readable
too — a heading that went on naming the personal vault over a list containing a team's hosts would be
a quiet lie, so the rows carry the vault name instead. The chevron folds the list away; the count is
the collection's own, so it follows the filter without a second number to keep in step.
-->
<Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<TextBlock Grid.Column="0" Text="▾" Foreground="{StaticResource TextFaint}" FontSize="8"
VerticalAlignment="Center" Margin="0,0,6,0"
IsVisible="{Binding AreHostsExpanded}" />
<TextBlock Grid.Column="0" Text="▸" Foreground="{StaticResource TextFaint}" FontSize="8"
VerticalAlignment="Center" Margin="0,0,6,0"
IsVisible="{Binding !AreHostsExpanded}" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding HostsHeading}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding VisibleHosts.Count}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</Button>
<!--
Named because it is where keyboard focus lands when the user leaves the terminal.
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty list has
none. Without it the release-the-keyboard path is a measured no-op: it takes Win32 focus off the
terminal's child window and then calls Focus() on something that refuses it, leaving the window with
nothing focused and the keystrokes going nowhere.
-->
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
DragDrop.AllowDrop="True"
IsVisible="{Binding AreHostsExpanded}"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}">
<!--
The three things you can do to a host, on the host itself.
On the list rather than in the item template, and that is what makes it one menu rather than one per
row: a ContextMenu inside a DataTemplate would have the row for its data context, and the commands
here are the vault's — the same three the buttons at the foot of this column run. The code-behind
selects whatever was right-clicked before the menu opens, so the selection-based commands act on the
row under the pointer, and cancels the menu outright over a group heading, which has no host to act
on. See HostSidebar.axaml.cs.
The same commands as the buttons, deliberately: a second path to deleting a host would be a second
place for the confirmation to be forgotten.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<!--
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
group. A vault with no groups produces no heading rows at all, so this is the list it always was.
The heading is a row rather than a container, which means the control will happily select it. That is
turned back into the previous host selection in the view model — see SelectedSidebarRow — because
CONNECT, EDIT and DELETE all act on a host and a highlighted heading is not one.
-->
<ListBox.DataTemplates>
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Chevron}" Foreground="{StaticResource TextFaint}"
FontSize="8" VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5">
<!-- The accent strip a selected row carries; see the style in App.axaml. -->
<Border Grid.Column="0" Classes="rowmark" />
<!--
Connected, and nothing more. Green means a terminal is open on this host right now; grey means
there is not one. It is deliberately not reachability — nothing here pings anything, and a dot
that meant "up" would be a claim this application never checks.
-->
<Ellipse Grid.Column="1" Classes="dot" Classes.live="{Binding IsConnected}"
Margin="8,5,8,0" VerticalAlignment="Top" />
<StackPanel Grid.Column="2" Spacing="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
FontSize="11.5" FontWeight="Medium"
TextTrimming="CharacterEllipsis" />
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
<!--
Which of the three ways this host authenticates. In the list because only one of them wants
the password box filled in, and an empty box on a key-authenticated host is otherwise
indistinguishable from one somebody forgot to fill in.
-->
<TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" />
<!--
Which vault this host is in, and only when there is more than one to be in. It decides
who else can see the host and where an edit goes back to, so on a list that spans
several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="9.5"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
<!--
The tags this host wears. A third line rather than more on the second, because a host can
wear several and the second line's three facts are fixed-width where these are not — one
long tag would push the vault badge off the end of a 268-pixel column.
Names, not ids: a tag the vault cannot resolve is left out rather than drawn. See
HostRowViewModel.TagLabels.
-->
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}"
Margin="0,1,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip" Padding="5,0" Margin="0,0,4,0">
<TextBlock Text="{Binding}" FontSize="8.5" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
<!--
The editor doubles as the "add" form; there is no separate dialog.
◆ It scrolls, and it did not until the tag picker arrived. The reason is that a picker's height is not
a property of the design: it is a chip per tag in the keychain, wrapped, so somebody with fifteen tags
has an editor half again as tall as somebody with three. No fixed height holds that, and trimming
other fields to buy room only moves the failure to whoever has sixteen.
The cost is real and worth naming. The layout harness skips any control with a ScrollViewer in its
ancestry — see LayoutHarness.IsScrollable — so from here on it certifies that this *pane* fits the
column rather than that every field inside it does. That is the true claim about a pane that scrolls.
The MaxHeight is stated rather than left to the row for the same reason: it is what keeps the pane
itself inside the window, which is the part the harness still watches. 300 is what the minimum window
affords once the filter, the heading and the button strip have taken theirs, with enough left that the
host list above does not vanish — the point of a column this narrow is that the list stays visible
while a host in it is edited.
-->
<Border Grid.Row="3" Padding="10" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsEditing}">
<ScrollViewer MaxHeight="300" HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="6">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
<!--
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset". The
watermark is what the host will actually use if it is left that way, which is why it is bound
rather than literal: it changes when the group picker below moves.
-->
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False"
PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
</Grid>
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<!--
How this host authenticates: a typed password, one of the vault's keys, or one of its credentials.
Part of the host rather than of the connection, so it follows the host to every machine; a host
bound to something since deleted keeps a placeholder entry here, so that editing the port cannot
quietly turn it back into a typed-password host.
One control for all three, which is what makes "a key or a credential, never both" impossible to
express rather than merely invalid. The qualifier beside each label is not decoration: a key called
"deploy" and the deploy account's password are the ordinary case, and bare labels would offer two
identical-looking entries that authenticate completely differently.
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="10"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
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 vault no longer has
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
◆ The tags this host wears. Chips that toggle rather than a multi-select list, because a chip is
what a tag looks like on the row two inches above — a list of names to tick would make the user
match an entry to a chip they can already see.
The box under them creates one and puts it on straight away. That is where a tag is usually
wanted: while tagging a host and finding it does not exist yet. Unlike every other field here it
writes to the keychain immediately, because a host can only name a tag that has an id — so
cancelling this editor leaves the tag behind, which is honest rather than hidden. Renaming and
deleting are on the keychain screen, where every other item kind is managed.
-->
<ItemsControl ItemsSource="{Binding EditorTagChoices}" IsVisible="{Binding HasTagChoices}"
Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagChoice">
<!--
Worn is filled, unworn is outlined. One control per tag with two states rather than a
checkbox beside a label: the state and the name occupy the same object, so a row of them
reads as the host's tags rather than as a form about them.
-->
<Button Classes="chiptoggle" Classes.worn="{Binding IsWorn}" Margin="0,0,4,4"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).ToggleEditorTagCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Label}" FontSize="9.5" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag"
FontSize="11" MinHeight="26" Padding="8,3">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="ghost" Content="ADD" Command="{Binding AddEditorTagCommand}" />
</Grid>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to resolve
the target, or it becomes an authenticated open proxy into the operator's network.
-->
<TextBlock Classes="hint" FontSize="10"
Text="The relay stores this host's address on the server in plain text. Everything else stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
</StackPanel>
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
not a field of the host.
-->
<Button Classes="danger" Content="FORGET HOST KEY" HorizontalAlignment="Left"
Command="{Binding ForgetHostKeyCommand}"
IsVisible="{Binding CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
</ScrollViewer>
</Border>
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding ShowsHostActions}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteHostCommand}" />
</StackPanel>
</Border>
<!--
The question DELETE asks, in the place the buttons were rather than under them. This strip is at the
bottom edge of a column whose middle is a list that has already taken every spare pixel, so a second
block below the first would push its own buttons off the window — the same reasoning that swaps the
unlock card for the sign-out card rather than stacking them. Swapping also means DELETE cannot be
pressed again while its own question is up; see VaultViewModel.ShowsHostActions.
-->
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsConfirmingHostDeletion}">
<views:ConfirmDeleteCard />
</Border>
</Grid>
</UserControl>
@@ -1,340 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The host list beside the terminal, and the editor for whichever host is open.
/// </summary>
/// <remarks>
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault
/// rather than of the shell. The shell hands it over; see <see cref="MainWindow"/>.
/// </remarks>
internal sealed partial class HostSidebar : UserControl
{
/// <summary>
/// How a host travels from the row it was picked up on to the heading it is dropped on.
/// </summary>
/// <remarks>
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
/// this window — there is nothing outside it that could accept a host — and the row is what the drop
/// needs: it knows which vault the edit has to return to, which an id on its own does not.
/// </remarks>
private static readonly DataFormat<HostRowViewModel> HostFormat =
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
/// <remarks>
/// A threshold, because a press on this list is nearly always a click: selecting a host, or the first
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
/// those into a drag gesture the user never asked for.
/// </remarks>
private const double DragThreshold = 5;
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
/// <remarks>
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
/// over to the operating system.
/// </remarks>
private PointerPressedEventArgs? press;
private HostRowViewModel? pickedUp;
private Point origin;
/// <summary>The row the pointer is currently over, while a drag is in flight.</summary>
private ListBoxItem? marked;
public HostSidebar()
{
InitializeComponent();
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
// every other client of this kind does, and the CONNECT button stays: it is the one that has the
// password box beside it, and a host that asks for a password still needs it typed first.
HostList.DoubleTapped += OnHostActivated;
// Tunnelled, so the row under the pointer is read before the ListBox has answered the press itself.
// Bubbling would work for the drag but not for the menu: by then the control has already decided
// what is selected, and the menu is about to open against it.
HostList.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
HostList.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
HostList.PointerMoved += OnPointerMoved;
HostList.PointerReleased += OnPointerReleased;
HostList.PointerCaptureLost += OnPointerCaptureLost;
DragDrop.AddDragOverHandler(HostList, OnDragOver);
DragDrop.AddDragLeaveHandler(HostList, OnDragLeave);
DragDrop.AddDropHandler(HostList, OnDrop);
}
private VaultViewModel? Vault => DataContext as VaultViewModel;
/// <remarks>
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
/// returning a task nothing observes.
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
if (Vault is { } vault)
{
_ = vault.ConnectCommand.ExecuteAsync(null);
}
}
/// <summary>
/// Points the menu at whatever was right-clicked.
/// </summary>
/// <remarks>
/// <para>
/// The menu's three commands all read the vault's host selection, and a right click does not move it —
/// which would mean a menu that quietly acted on whichever host happened to be selected instead of the
/// one under the pointer. Deleting the wrong machine is the version of that mistake worth designing
/// against.
/// </para>
/// <para>
/// Cancelled outright over a group heading and over the empty space below the rows. Neither is a host,
/// and a menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or
/// act on something else entirely.
/// </para>
/// </remarks>
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
{
e.Handled = true;
return;
}
vault.SelectedSidebarRow = row;
}
/// <remarks>
/// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
/// until the pointer moves, so this is the point at which both are still possible.
/// </remarks>
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
press = null;
pickedUp = null;
if (!e.GetCurrentPoint(HostList).Properties.IsLeftButtonPressed
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
}
press = e;
pickedUp = row;
origin = e.GetPosition(HostList);
}
/// <remarks>
/// The drag is started from the remembered press once the pointer has travelled far enough — see
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
/// </remarks>
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (press is not { } pressed || pickedUp is not { } row)
{
return;
}
if (!e.GetCurrentPoint(HostList).Properties.IsLeftButtonPressed)
{
Forget();
return;
}
var moved = e.GetPosition(HostList) - origin;
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
{
return;
}
Forget();
_ = DragAsync(pressed, row);
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
/// <summary>Carries one host row for as long as the user holds it.</summary>
private async Task DragAsync(PointerPressedEventArgs pressed, HostRowViewModel row)
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, row));
try
{
// ConfigureAwait(true): what follows touches the list's own containers, and those are the UI
// thread's.
await DragDrop
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
.ConfigureAwait(true);
}
finally
{
// Whatever the drop did or did not do. A mark left behind would be a row that looks like a
// target for a drag that ended somewhere else entirely.
Unmark();
}
}
/// <summary>
/// Says whether the row under the pointer would take this host, and marks it if it would.
/// </summary>
/// <remarks>
/// A host over its own group's heading is refused, which is not pedantry: <c>DragDropEffects.None</c> is
/// what turns the cursor into the "no" one, and a drag that looks like it would do something and then
/// does nothing is worse than one that says so while it is still in the air.
/// </remarks>
private void OnDragOver(object? sender, DragEventArgs e)
{
e.Handled = true;
if (Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
Unmark();
return;
}
e.DragEffects = DragDropEffects.Move;
Mark(target.Container);
}
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
/// <remarks>
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
/// task nothing observes.
/// </remarks>
private void OnDrop(object? sender, DragEventArgs e)
{
e.Handled = true;
Unmark();
if (Vault is not { } vault || Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
return;
}
e.DragEffects = DragDropEffects.Move;
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
}
/// <summary>
/// Where a drag currently is, or null if it is over nothing that would take it.
/// </summary>
/// <remarks>
/// <para>
/// A heading is the obvious target and a host is the useful one: dropping onto a machine files the
/// dragged host beside it, which means the whole band of rows under a heading is a target rather than
/// one seven-pixel line of text. The ungrouped heading is a target like any other, and it is how a host
/// is taken out of a group without opening the editor.
/// </para>
/// <para>
/// A group the vault no longer has is read as no group at all, which is what the list already does with
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
/// rather than here, so the rule has one home.
/// </para>
/// </remarks>
private static DropTarget? Target(DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|| Container(e.Source) is not { } container)
{
return null;
}
Guid? group = container.DataContext switch
{
SidebarGroupHeader header => header.GroupId,
HostRowViewModel row => row.Host.GroupId,
_ => null,
};
if (container.DataContext is not (SidebarGroupHeader or HostRowViewModel)
|| dragged.Host.GroupId == group)
{
return null;
}
return new DropTarget(dragged, group, container);
}
private void Mark(ListBoxItem container)
{
if (ReferenceEquals(marked, container))
{
return;
}
Unmark();
marked = container;
marked.Classes.Add("droptarget");
}
private void Unmark()
{
marked?.Classes.Remove("droptarget");
marked = null;
}
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
private void Forget()
{
press = null;
pickedUp = null;
}
/// <summary>The view model of the list row an event happened on, if it happened on one.</summary>
private static object? RowUnder(object? source) => Container(source)?.DataContext;
/// <remarks>
/// Walks up from whatever was actually hit — a text block, a border, the row's own grid — because that is
/// what an event's source is. Anything not inside a row, which is the empty space below the last one,
/// yields null.
/// </remarks>
private static ListBoxItem? Container(object? source) => source is Visual visual
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
: null;
/// <summary>A drag in flight, and where it would land.</summary>
private sealed record DropTarget(HostRowViewModel Host, Guid? GroupId, ListBoxItem Container);
/// <summary>
/// Where the keyboard should land when the terminal hands it back.
/// </summary>
/// <remarks>
/// <para>
/// Exposed as a property rather than left for the window to find by name, because the name is inside
/// this control's template and the window cannot see it.
/// </para>
/// <para>
/// It has to be a control that is on screen. <c>Focus()</c> on a collapsed control is measurably a no-op
/// and is not replayed when the control is revealed, so handing the keyboard to a folded-away list would
/// swallow it: the terminal would let go and nothing would take it. Folding the host list is the one way
/// a user can put this control into that state, so that is the case this answer has to cover — the
/// filter box is always there, and it is a perfectly good place for a keyboard to arrive.
/// </para>
/// </remarks>
internal IInputElement KeyboardTarget =>
HostList.IsEffectivelyVisible ? HostList : HostFilter;
}
+348 -197
View File
@@ -3,86 +3,109 @@
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostsScreen"
x:DataType="vm:MainWindowViewModel">
x:DataType="vm:VaultViewModel">
<!--
The hosts screen: the list of machines, and what this application has to say about the one that is
selected.
The hosts screen: every group, then every host, as cards — and a drawer for whichever one is selected.
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
promised: an overview.
── IT WAS A 268-PIXEL LIST BESIDE A MOSTLY EMPTY COLUMN. ────────────────────────────────────────────
That list was doing two jobs at two-thirds size. It had to be narrow so the editor beneath it could be
a column, and being narrow is what made forty machines a scroll rather than a glance: a host row was a
name, an address and an auth word stacked in 268 pixels, and the column beside it repeated all three
for the one that happened to be selected.
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
has to reproduce by hand.
Cards split the two jobs. The grid is about all of them — how many groups there are, which machines are
in one, which have a shell open — and it gets the full width to say so. The drawer is about one, and it
is only there when there is one. See HostDrawer and VaultViewModel.IsDrawerOpen.
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
two cannot be put on one element.
── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────────────
The design this was drawn from puts a view-mode switch, a tag filter, a calendar and a share control in
the toolbar, and offers Serial beside Terminal. None of the five exists: there is one view, no saved
filters, nothing dated, no sharing outside a team vault, and no serial transport in the SSH layer. Five
disabled controls would teach nobody anything. See docs/design-import-gaps.md.
── ITS DATA CONTEXT IS THE VAULT, and it used to be the shell. ──────────────────────────────────────
The shell was needed only so that the old sidebar could be handed the vault on its own element while
everything around it bound Vault.*. The drawer needs no such thing — it is a child of this control and
inherits what this control has — so the indirection went with the sidebar, and every binding in this
file is now a property of the vault.
That is worth more than the tidiness: a control whose data context is the shell cannot be laid out by
the layout harness without building an entire MainWindowViewModel, and this is the screen with the most
worth measuring. The window wraps it in a Panel that carries IsHostsScreen, which is the same pattern
every other screen there already uses — see MainWindow.axaml's note on why the two cannot go on one
element.
-->
<Grid ColumnDefinitions="268,*">
<Grid ColumnDefinitions="*,Auto">
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<!--
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
one of them is fine.
============ FIND ============
One wide box, which is what the width bought. It narrows the grid below and nothing else — the
connect path, the selection and the pinned host key list all read the unfiltered collection — so a
filter left in the box can hide a host but can never break one.
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
who has found this box should know about the one that also connects on Enter.
The connect bar that used to be this row — password box, REMEMBER, the note, CONNECT — is in the
drawer now, beside the host it is about. See HostDrawer.
-->
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
<Border Grid.Row="0" Padding="16,12" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="200" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
FontSize="11" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" />
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border>
<!--
============ THE TOOLBAR ============
Only what makes something new. EDIT and DELETE are not here: they act on one host, and one host is
what the drawer is about — a pair of buttons over a grid of forty is a pair whose subject the user
has to work out. The group's own EDIT and DELETE sit beside the group cards for the same reason.
-->
<Border Grid.Row="1" Padding="16,8" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
<Button Classes="ghost" Content="+ NEW GROUP" Command="{Binding NewGroupCommand}"
ToolTip.Tip="A heading for this grid, and the port, username and key the hosts under it inherit." />
</StackPanel>
</Border>
<StackPanel Grid.Row="1">
<StackPanel Grid.Row="2">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
through the one warning that matters.
-->
<Border Padding="12,10" Background="{StaticResource WarnWash}"
<Border Padding="16,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasPendingHostKey}">
IsVisible="{Binding HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
<SelectableTextBlock Classes="mono" Text="{Binding PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding Vault.TrustHostKeyCommand}" />
Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding Vault.RejectHostKeyCommand}" />
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="12,10" Background="{StaticResource DangerWash}"
<Border Padding="16,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasHostKeyMismatch}">
IsVisible="{Binding HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
@@ -93,20 +116,18 @@
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
and shown; without this panel it would be last-writer-wins with a longer explanation.
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
for as long as it did because no test could lay this markup out; that is the other half of why this
file exists.
Bounded and scrollable. It sits on an Auto row above a star row, and an ItemsControl with no
ceiling grows without limit — so a pass that merged twenty items would push the grid off the
bottom of a screen nobody could scroll.
-->
<Border Padding="12,10" Background="{StaticResource Panel}"
<Border Padding="16,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasConflicts}">
IsVisible="{Binding HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl ItemsSource="{Binding Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
@@ -114,7 +135,7 @@
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="12"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
@@ -124,174 +145,304 @@
</ItemsControl>
</ScrollViewer>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
Command="{Binding AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
</StackPanel>
<!--
The overview proper: what is known about the host the list has selected.
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
terminal until this screen stopped hosting one, and filling it with something that needed new state
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
list of forty.
-->
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
<Panel Margin="24">
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
VerticalAlignment="Center" />
<Border Classes="chip" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
<TextBlock Text="CONNECTED" />
</Border>
</StackPanel>
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
</StackPanel>
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
MaxWidth="440" TextWrapping="Wrap"
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
</Panel>
</ScrollViewer>
<!--
Groups: making them, renaming them, and taking them away.
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
in the host's own editor, on the left, for the same reason its key and its password are.
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
happen.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
</StackPanel>
<!-- ============ THE GRID ============ -->
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,14" Spacing="16">
<!-- ============ GROUPS ============ -->
<!--
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
third of this column to say what a row of chips says in one line.
Absent entirely from a vault nobody has filed anything in, which is what makes groups cost
nothing to ignore — the same rule the host list has always followed for its headings.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding Vault.HasGroups}">
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
Background="Transparent" MaxHeight="72">
<StackPanel Spacing="8" IsVisible="{Binding HasGroups}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="GROUPS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The group's own two actions, beside the group cards rather than in the toolbar, because they
act on the selected card and this is where the selection is made. Hidden rather than
disabled while DELETE's question is up, as every other pair in this application is, so it
cannot be pressed twice — see VaultViewModel.ShowsGroupActions.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="SHOW ALL"
Command="{Binding ClearGroupFilterCommand}"
IsVisible="{Binding IsFilteredByGroup}"
ToolTip.Tip="Stops narrowing the grid to one group." />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
</StackPanel>
</Grid>
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
A ListBox rather than an ItemsControl of buttons, and that is what marks the chosen group for
free: selection is a state the control already has, and the tile style draws it the same way
every other list in this application draws a selected row. Buttons would have needed a
Classes.active binding per card and a second copy of "which one".
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
GroupFilter assigns SelectedGroup — but only one of them may narrow the grid; see the property
for why the phone's own use of SelectedGroup must not.
-->
<ListBox Classes="tiles" x:Name="GroupGrid"
ItemsSource="{Binding Groups}"
SelectedItem="{Binding GroupFilter}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<StackPanel Margin="2,4" Spacing="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
FontSize="11.5" />
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
<Border Classes="tile">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Accent}">
<TextBlock Text="▤" FontSize="14" Foreground="{StaticResource AccentInk}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</StackPanel>
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="2">
<!-- A Grid rather than a horizontal StackPanel; see the note on the host card. -->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium"
Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<TextBlock Text="{Binding Description}" FontSize="11"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
<!--
A group is no longer only a name, so this is no longer only a box. The four fields under the
name are what the hosts inside inherit when they say nothing themselves; every one of them may
be left empty, and empty means "lend nothing" rather than "unset". The parent picker leaves out
this group and everything beneath it, so a cycle cannot be made here — which is a courtesy
rather than the guarantee, because one assembled offline on two machines was never offered this
list. See HostInheritance.
-->
<StackPanel Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorParentChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedParent}"
MinWidth="150" FontSize="11" MinHeight="26">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<NumericUpDown Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False" PlaceholderText="default port"
Width="120" FontSize="11" MinHeight="26" />
<TextBox Text="{Binding Vault.GroupEditorDefaultUsername}"
PlaceholderText="default username" Width="150"
FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}"
MinWidth="180" FontSize="11" MinHeight="26">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="10"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Content="EDIT SELECTED" Command="{Binding Vault.EditGroupCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
</StackPanel>
</StackPanel>
<!--
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
two panels share one pending deletion, and the sidebar checks the same way.
-->
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
<!-- ============ HOSTS ============ -->
<StackPanel Spacing="8">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="HOSTS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!-- The collection's own count, so it follows both filters with no second number to keep in step. -->
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding VisibleHosts.Count}"
FontSize="11" Foreground="{StaticResource TextFaint}"
VerticalAlignment="Center" />
</Grid>
<!--
Named because it is where keyboard focus lands when the terminal gives it back, and because
every gesture on it is wired in the code-behind.
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty
list has none. Without it the release-the-keyboard path is a measured no-op: it takes Win32
focus off the terminal's child window and then calls Focus() on something that refuses it,
leaving the window with nothing focused and the keystrokes going nowhere.
It binds SidebarRows rather than VisibleHosts, so the group headings are still in it. Two
reasons, and neither is decoration: a grid showing every group at once has to say which cards
belong to which, and a heading is the drop target that takes a host out of one group and into
another. See VaultViewModel.RebuildSidebarRows for why a heading is a row rather than a
container, and the code-behind for what a drag does with one.
-->
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
DragDrop.AllowDrop="True"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
The three things you can do to a host, on the host itself.
On the list rather than in the item template, and that is what makes it one menu rather than
one per card: a ContextMenu inside a DataTemplate would have the row for its data context,
and the commands here are the vault's. The code-behind selects whatever was right-clicked
before the menu opens, so the selection-based commands act on the card under the pointer,
and cancels the menu outright over a group heading, which has no host to act on.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.DataTemplates>
<!--
A heading, and it takes a whole row of the wrap rather than sitting in the flow as another
card: it names the run of cards under it, and a heading the width of one card would read
as the first of them. The stretch is what MinWidth buys — a WrapPanel gives a child the
width it asks for and starts a new line when the line cannot hold it, so a child asking
for more than one line holds is alone on its own.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" MinWidth="640"
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Text="{Binding Chevron}"
Foreground="{StaticResource TextFaint}" FontSize="9"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="8,0,0,0" HorizontalAlignment="Left" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<Border Classes="tile">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="2">
<!--
◆ A GRID AND NOT A HORIZONTAL StackPanel, on both of these rows, and the two
look interchangeable until the text is too long for the card.
A horizontal StackPanel measures every child with infinite width, so a
TextBlock inside one never learns it is short of room and TextTrimming never
fires — the text simply runs on past the card's border and over the card beside
it. The card is a fixed 248 and a host name is not, so that is the ordinary
case rather than the edge one.
The star column is the part that gives way and the Auto column is the part that
must not: a badge saying "not synced" and the word that says how a host
authenticates are both short and both meaningless trimmed, where a hostname
with its tail cut is still the machine you were looking for.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium"
Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<!--
The address and how it authenticates, on one line. Which of the three ways is
here because only one of them wants a password typed, and an empty password box
on a key-authenticated host is otherwise indistinguishable from one somebody
forgot to fill in.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}"
FontSize="10.5" Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Authentication}"
FontSize="10.5" Margin="6,0,0,0"
Foreground="{StaticResource TextFaint}" />
</Grid>
<!--
Which vault this host is in, and only when there is more than one to be in. It
decides who else can see the host and where an edit goes back to, so on a grid
spanning several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
<!--
Connected, and nothing more. Green means a terminal is open on this host right
now; grey means there is not one. It is deliberately not reachability — nothing
here pings anything, and a dot that meant "up" would be a claim this application
never checks.
-->
<Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}"
VerticalAlignment="Top" Margin="6,3,0,0" />
</Grid>
<!--
The tags this host wears. Under the summary rather than beside it, because a host
can wear several and the line above is three fixed-width facts where these are not.
Names, not ids: a tag the vault cannot resolve is left out rather than drawn. See
HostRowViewModel.TagLabels.
-->
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip" Padding="5,0" Margin="0,0,4,2">
<TextBlock Text="{Binding}" FontSize="9.5" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
<!--
Nothing to show, and the two reasons it can happen are different questions. An empty keychain
is an invitation; a filter that matches nothing is a filter that has gone too far, and saying
"add a host" to somebody who has thirty would be answering the wrong one.
-->
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap" MaxWidth="480"
HorizontalAlignment="Left" Margin="0,4,0,0"
IsVisible="{Binding !HasVisibleHosts}"
Text="{Binding NoVisibleHostsMessage}" />
</StackPanel>
</StackPanel>
</Border>
</ScrollViewer>
</Grid>
<!--
The drawer, and it takes its 304 pixels only while there is something in it — which is what gives the
grid the full width for most of the time anybody is looking at it.
Wrapped so that its own IsVisible is the shell's binding and its data context is the vault; see
MainWindow.axaml's note on why the two cannot be put on one element.
-->
<Panel Grid.Column="1" IsVisible="{Binding IsDrawerOpen}">
<views:HostDrawer x:Name="Drawer" />
</Panel>
</Grid>
</UserControl>
+343 -13
View File
@@ -1,27 +1,357 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The hosts screen: the host list, and an overview of the one that is selected.
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
/// </summary>
/// <remarks>
/// Its data context is the shell rather than the vault, unlike <see cref="HostSidebar"/> and
/// <see cref="VaultScreen"/>. The sidebar is handed the vault from inside the markup; everything else here
/// reaches it through <c>Vault.*</c>. That split is not tidiness — this element's visibility is the shell's
/// business and the sidebar's bindings are the vault's, and an element carrying both resolves the first
/// against the second, where it does not exist.
/// <para>
/// All of this was <c>HostSidebar</c>'s until the host list became a grid of cards. It moved with the list
/// rather than staying with the editor: every handler here is about the thing that was clicked, dragged or
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
/// </para>
/// <para>
/// Its data context is the <c>VaultViewModel</c>, as <see cref="VaultScreen"/>'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
/// beside the grid inherits the same one.
/// </para>
/// </remarks>
internal sealed partial class HostsScreen : UserControl
{
public HostsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <summary>
/// How a host travels from the card it was picked up on to the heading it is dropped on.
/// </summary>
/// <remarks>
/// Forwarded to the sidebar, which answers for itself: the host list can be folded away, and
/// <c>Focus()</c> on a collapsed control is measurably a no-op that is not replayed when the control is
/// revealed. Nothing in the right column can take the keyboard — it is a heading and three sentences.
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
/// this window — there is nothing outside it that could accept a host — and the row is what the drop
/// needs: it knows which vault the edit has to return to, which an id on its own does not.
/// </remarks>
internal IInputElement KeyboardTarget => Sidebar.KeyboardTarget;
private static readonly DataFormat<HostRowViewModel> HostFormat =
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
/// <remarks>
/// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
/// those into a drag gesture the user never asked for.
/// </remarks>
private const double DragThreshold = 5;
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
/// <remarks>
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
/// over to the operating system.
/// </remarks>
private PointerPressedEventArgs? press;
private HostRowViewModel? pickedUp;
private Point origin;
/// <summary>The card or heading the pointer is currently over, while a drag is in flight.</summary>
private ListBoxItem? marked;
public HostsScreen()
{
InitializeComponent();
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
// every other client of this kind does, and CONNECT stays: it is the one in the drawer with the
// password box above it, and a host that asks for a password still needs it typed first.
HostGrid.DoubleTapped += OnHostActivated;
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
// Bubbling would work for the drag but not for the menu: by then the control has already decided
// what is selected, and the menu is about to open against it.
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
HostGrid.PointerMoved += OnPointerMoved;
HostGrid.PointerReleased += OnPointerReleased;
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
DragDrop.AddDragOverHandler(HostGrid, OnDragOver);
DragDrop.AddDragLeaveHandler(HostGrid, OnDragLeave);
DragDrop.AddDropHandler(HostGrid, OnDrop);
}
/// <remarks>
/// Null before the window has handed one over, and while the previewer is showing this control with no
/// data context at all. Every handler below checks rather than assuming.
/// </remarks>
private VaultViewModel? Vault => DataContext as VaultViewModel;
/// <summary>
/// Where the keyboard should land when the terminal hands it back.
/// </summary>
/// <remarks>
/// <para>
/// Exposed as a property rather than left for the window to find by name, because the name is inside
/// this control's template and the window cannot see it.
/// </para>
/// <para>
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
/// something that is not there would swallow it: the terminal would let go and nothing would take it.
/// The grid no longer folds away as the sidebar's list could, but an empty grid is still a
/// <c>ListBox</c> with no item to take focus — and an empty grid is exactly what a filter that matches
/// nothing produces, which is a state somebody typing is very likely to be in. The find box is the
/// answer then, and it is a good one: it is where they were typing.
/// </para>
/// </remarks>
internal IInputElement KeyboardTarget =>
Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
/// <remarks>
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
/// returning a task nothing observes.
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
// Only over a card. A double-tap on a group heading folds it and unfolds it again, and must not also
// connect to whichever host was selected before — which is what an unguarded handler would do, on a
// machine the user is not even pointing at.
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
{
_ = vault.ConnectCommand.ExecuteAsync(null);
}
}
/// <summary>
/// Points the menu at whatever was right-clicked.
/// </summary>
/// <remarks>
/// <para>
/// The menu's three commands all read the vault's host selection, and a right click does not move it —
/// which would mean a menu that quietly acted on whichever host happened to be selected instead of the
/// one under the pointer. Deleting the wrong machine is the version of that mistake worth designing
/// against.
/// </para>
/// <para>
/// Cancelled outright over a group heading and over the space around the cards. Neither is a host, and a
/// menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or act
/// on something else entirely.
/// </para>
/// </remarks>
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
{
e.Handled = true;
return;
}
vault.SelectedSidebarRow = row;
}
/// <remarks>
/// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
/// until the pointer moves, so this is the point at which both are still possible.
/// </remarks>
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
press = null;
pickedUp = null;
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
}
press = e;
pickedUp = row;
origin = e.GetPosition(HostGrid);
}
/// <remarks>
/// The drag is started from the remembered press once the pointer has travelled far enough — see
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
/// </remarks>
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (press is not { } pressed || pickedUp is not { } row)
{
return;
}
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed)
{
Forget();
return;
}
var moved = e.GetPosition(HostGrid) - origin;
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
{
return;
}
Forget();
_ = DragAsync(pressed, row);
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
/// <summary>Carries one host for as long as the user holds it.</summary>
private async Task DragAsync(PointerPressedEventArgs pressed, HostRowViewModel row)
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, row));
try
{
// ConfigureAwait(true): what follows touches the grid's own containers, and those are the UI
// thread's.
await DragDrop
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
.ConfigureAwait(true);
}
finally
{
// Whatever the drop did or did not do. A mark left behind would be a card that looks like a
// target for a drag that ended somewhere else entirely.
Unmark();
}
}
/// <summary>
/// Says whether what is under the pointer would take this host, and marks it if it would.
/// </summary>
/// <remarks>
/// A host over its own group's heading is refused, which is not pedantry: <c>DragDropEffects.None</c> is
/// what turns the cursor into the "no" one, and a drag that looks like it would do something and then
/// does nothing is worse than one that says so while it is still in the air.
/// </remarks>
private void OnDragOver(object? sender, DragEventArgs e)
{
e.Handled = true;
if (Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
Unmark();
return;
}
e.DragEffects = DragDropEffects.Move;
Mark(target.Container);
}
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
/// <remarks>
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
/// task nothing observes.
/// </remarks>
private void OnDrop(object? sender, DragEventArgs e)
{
e.Handled = true;
Unmark();
if (Vault is not { } vault || Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
return;
}
e.DragEffects = DragDropEffects.Move;
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
}
/// <summary>
/// Where a drag currently is, or null if it is over nothing that would take it.
/// </summary>
/// <remarks>
/// <para>
/// A heading is the obvious target and a card is the useful one: dropping onto a machine files the
/// dragged host beside it, which means the whole band of cards under a heading is a target rather than
/// one line of text. The ungrouped heading is a target like any other, and it is how a host is taken out
/// of a group without opening the editor.
/// </para>
/// <para>
/// A group the vault no longer has is read as no group at all, which is what the grid already does with
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
/// rather than here, so the rule has one home.
/// </para>
/// </remarks>
private static DropTarget? Target(DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|| Container(e.Source) is not { } container)
{
return null;
}
Guid? group = container.DataContext switch
{
SidebarGroupHeader header => header.GroupId,
HostRowViewModel row => row.Host.GroupId,
_ => null,
};
if (container.DataContext is not (SidebarGroupHeader or HostRowViewModel)
|| dragged.Host.GroupId == group)
{
return null;
}
return new DropTarget(dragged, group, container);
}
private void Mark(ListBoxItem container)
{
if (ReferenceEquals(marked, container))
{
return;
}
Unmark();
marked = container;
marked.Classes.Add("droptarget");
}
private void Unmark()
{
marked?.Classes.Remove("droptarget");
marked = null;
}
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
private void Forget()
{
press = null;
pickedUp = null;
}
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
private static object? RowUnder(object? source) => Container(source)?.DataContext;
/// <remarks>
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
/// is what an event's source is. Anything not inside an item, which is the space around the cards,
/// yields null.
/// </remarks>
private static ListBoxItem? Container(object? source) => source is Visual visual
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
: null;
/// <summary>A drag in flight, and where it would land.</summary>
private sealed record DropTarget(HostRowViewModel Host, Guid? GroupId, ListBoxItem Container);
}
+14 -14
View File
@@ -22,10 +22,10 @@
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="11"
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="9.5"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
@@ -34,7 +34,7 @@
</Grid>
</Border>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="11" Margin="14,12,14,0"
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="14,12,14,0"
TextWrapping="Wrap" />
<!--
@@ -48,7 +48,7 @@
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="10"
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -58,10 +58,10 @@
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ScrollViewer Grid.Row="1">
@@ -71,21 +71,21 @@
<StackPanel Margin="14,0">
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="11"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="9.5"
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="10.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="9.5" Margin="34,0,0,8"
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="10.5" Margin="34,0,0,8"
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
IsVisible="{Binding HasWarnings}" />
</StackPanel>
@@ -104,7 +104,7 @@
the difference between a bookmark that connects and one that asks for a password, and somebody
who is not told will conclude the import was broken.
-->
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain. Nothing here reaches into ~/.ssh for private key material." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
@@ -24,10 +24,10 @@
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="11"
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="9.5"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
@@ -43,12 +43,12 @@
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
IsVisible="{Binding HasVisiblePins}">
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1"
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
@@ -58,12 +58,12 @@
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="11"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="12"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="9.5"
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="9"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="10"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
@@ -72,17 +72,17 @@
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
mechanism exists to replace.
-->
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="9.5"
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="9"
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="340"
IsVisible="{Binding !HasVisiblePins}" />
@@ -94,12 +94,12 @@
<ScrollViewer>
<StackPanel Margin="14,16" Spacing="6">
<TextBlock Classes="hint" FontSize="11"
<TextBlock Classes="hint" FontSize="12"
Text="Choose a pinned key to see it in full, and to withdraw it."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<Border Classes="chip warn" HorizontalAlignment="Left"
@@ -111,22 +111,22 @@
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="10"
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="11"
Foreground="{StaticResource TextDim}" />
<!--
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
the item's own version 7 id — which records when the pin was created and knows nothing about
it being re-approved since. Presenting that as "last used" would be inventing a fact.
-->
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,12,0,0"
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,12,0,0"
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
+27 -27
View File
@@ -25,7 +25,7 @@
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="11" FontWeight="SemiBold"
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,14,0" />
@@ -38,7 +38,7 @@
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Activity}" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="9.5"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
@@ -52,12 +52,12 @@
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
IsVisible="{Binding HasConnections}">
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
@@ -67,23 +67,23 @@
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="11" FontWeight="Medium"
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="9.5"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="10.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="9.5"
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="10.5"
Foreground="{StaticResource TextDim}" />
</StackPanel>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="9"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="9"
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="9"
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
<!--
@@ -91,7 +91,7 @@
here; one that was refused says so, and that is the row worth finding in a long list.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
<TextBlock Text="{Binding Outcome}" FontSize="8.5" />
<TextBlock Text="{Binding Outcome}" FontSize="9.5" />
</Border>
</StackPanel>
</Grid>
@@ -99,7 +99,7 @@
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasConnections}" />
@@ -110,40 +110,40 @@
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
IsVisible="{Binding HasActivity}">
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="11"
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="12"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="9"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="9.5"
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The names of the fields that changed, and never what they changed to. A log that recorded
an old password would be a plaintext credential store with a vault drawn around it.
-->
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="9.5"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
IsVisible="{Binding HasChangedFields}" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="9"
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasActivity}" />
+42 -17
View File
@@ -6,6 +6,7 @@
x:Class="DodoSSH.Client.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="DodoSSH"
Icon="/Assets/dodossh.ico"
Width="1180"
Height="760"
MinWidth="1016"
@@ -76,33 +77,57 @@
<Panel Grid.Row="1">
<!-- The unlocked application. -->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
<views:NavRail Grid.Column="0" />
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
<!--
The rail is full height and the strip is not, so the strip spans exactly the area it navigates.
The other arrangement — strip above rail — would put a row of tabs over a column of destinations
they have nothing to do with.
◆ THE STRIP IS ABOVE THE RAIL, and it used to be beside it.
It was the other way round for a reason that stopped being true: while every tab was a terminal,
the strip navigated only the area to the right of a full-height rail, and putting it over the rail
would have been a row of tabs above a column of destinations they had nothing to do with.
The three fixed tabs are what changed that. The rail is now one tab's contents rather than the
window's own furniture — Vaults owns it, SFTP and S3 do not have it, and a terminal does not
either — so a rail drawn beside the strip would outrank the thing that decides whether it is
showing at all. Above and full width is the arrangement that matches what selects what.
-->
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<views:TerminalTabs Grid.Row="0" />
<views:TerminalTabs Grid.Row="0" />
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<Panel Grid.Row="1">
<!--
The Vaults tab's own navigation, and it collapses with that tab. Its width is 190 either way, so
SFTP, S3 and a terminal each get the full window rather than the 826 a page gets.
-->
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
<Panel Grid.Column="1">
<!-- ============ THE PAGES ============ -->
<Panel IsVisible="{Binding IsShowingPages}">
<!--
Bound directly rather than wrapped, unlike the two below it: this screen's data context is
the shell's, so IsHostsScreen resolves. It hands the vault to the sidebar from inside its
own markup.
Wrapped, like every other screen here. It used to be bound directly — its data context was
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
its visibility goes on the wrapper.
-->
<views:HostsScreen x:Name="HostsPane" IsVisible="{Binding IsHostsScreen}" />
<Panel IsVisible="{Binding IsHostsScreen}">
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ FILES ============ -->
<!-- ============ SFTP ============ -->
<!--
Inside this Panel although it is a tab rather than a rail screen, and that is not an
oversight. IsShowingPages means "the Avalonia page area, not the WebView", which is the
occlusion question and is true of all three fixed tabs; which of them is showing is the
separate question each child below answers. Keeping the two apart is what lets the terminal
stay collapsed under one rule rather than under four.
What differs from a rail screen is only the rail: NavRail collapses on IsVaultsTab above, so
this screen is laid out at the full window width.
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
visibility is the shell's business and its data context is the transfers view model, and
putting both on one element resolves IsVisible against that view model, where
@@ -114,10 +139,10 @@
<!-- ============ S3 ============ -->
<!--
The same screen as FILES above, over the same view model, because an object store and an
The same screen as SFTP above, over the same view model, because an object store and an
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
What differs is which picker is offered, and that is decided by the destination rather than
by a toggle inside the screen — see ShowFiles, and the sidebar entry that calls it.
by a toggle inside the screen — see ShowFiles, and the tab in the strip that calls it.
-->
<Panel IsVisible="{Binding IsBucketsScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
@@ -266,7 +291,7 @@
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="6" Padding="14">
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
FontSize="16" Foreground="{StaticResource Accent}"
FontSize="17" Foreground="{StaticResource Accent}"
TextWrapping="Wrap" />
</Border>
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
+14 -30
View File
@@ -18,10 +18,20 @@
each carries a count, which is the one genuinely new fact: how many hosts, how many keys, how many pins
is a question you would otherwise have to open the screen to answer.
A count is drawn only where one is real. SFTP, Logs and Preferences have none — a transfer queue's depth
is not "how many files this screen holds", a log has no total until it is read, and preferences are not
counted — so those three show nothing rather than a zero. The design draws a number on every row; a zero
beside Logs would be a fact this application never computed.
A count is drawn only where one is real. Logs and Preferences have none — a log has no total until it is
read, and preferences are not counted — so those two show nothing rather than a zero. The design draws a
number on every row; a zero beside Logs would be a fact this application never computed.
── SFTP AND S3 ARE NOT HERE, and that is the tab strip's doing. ──────────────────────────────────────
Both were rail entries until the strip grew fixed tabs for them. They are the two destinations that are
not about the keychain — they are a place you leave the keychain to work in, and you stay there while a
transfer runs — which is exactly what a tab is for and what a rail entry is not. The rail is drawn only
under the Vaults tab now, so an entry here for either of them would be a route out of the tab it lives
in. See MainWindowViewModel.IsVaultsTab.
What this costs is that the S3 count has nowhere to go: the strip's tabs are one word each, and the rail
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
counting buckets is going anyway.
One of the destinations — TEAM — reaches a screen that says it is not built. It is in the list anyway
rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it
@@ -61,32 +71,6 @@
</Grid>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}"
ToolTip.Tip="Move files to and from a host over SFTP">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="⇅" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="SFTP" />
</Grid>
</Button>
<!--
Buckets are their own destination now, as they are on the phone, rather than a toggle inside the
files screen. Same screen behind both — an object store and an SFTP host are both an
IRemoteFileStore — and the entry chosen is what decides which picker is offered. See ShowFiles.
-->
<Button Classes="flat nav" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="◳" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="S3" />
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.ObjectStores.Count}" />
</Grid>
</Button>
<!--
TotalItemCount is keys plus passwords and deliberately excludes buckets, which used to disagree
with the list under it. It no longer does: buckets have their own entry above, so this number and
@@ -18,14 +18,14 @@
<StackPanel MaxWidth="560" Margin="28,26" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="mono" Name="TitleText" FontSize="16" FontWeight="SemiBold" LetterSpacing="0.5"
<TextBlock Classes="mono" Name="TitleText" FontSize="17" FontWeight="SemiBold" LetterSpacing="0.5"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Border Classes="chip warn">
<TextBlock Name="MilestoneText" />
</Border>
</StackPanel>
<TextBlock Name="SummaryText" Classes="hint" FontSize="12" Margin="0,10,0,0" />
<TextBlock Name="SummaryText" Classes="hint" FontSize="13" Margin="0,10,0,0" />
<TextBlock Classes="label" Text="WHAT IS MISSING" Margin="0,24,0,8" />
<ItemsControl Name="MissingList">
@@ -34,7 +34,7 @@
<Grid ColumnDefinitions="Auto,*" Margin="0,0,0,7">
<TextBlock Grid.Column="0" Classes="mono" Text="·" Foreground="{StaticResource TextFaint}"
Margin="0,0,8,0" />
<TextBlock Grid.Column="1" Classes="hint" FontSize="11" Text="{Binding}" />
<TextBlock Grid.Column="1" Classes="hint" FontSize="12" Text="{Binding}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -42,10 +42,10 @@
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,18,0,0">
<TextBlock Name="InsteadText" Classes="hint" FontSize="11" />
<TextBlock Name="InsteadText" Classes="hint" FontSize="12" />
</Border>
<TextBlock Classes="hint" FontSize="10" Margin="0,14,0,0"
<TextBlock Classes="hint" FontSize="11" Margin="0,14,0,0"
Text="Everything this design asked for and this build does not have is written down in docs/design-import-gaps.md, with which project each piece would land in." />
</StackPanel>
@@ -28,14 +28,14 @@
<ScrollViewer>
<StackPanel MaxWidth="620" Margin="28,26" HorizontalAlignment="Left">
<TextBlock Classes="mono" Text="THIS MACHINE" FontSize="13" FontWeight="SemiBold"
<TextBlock Classes="mono" Text="THIS MACHINE" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="12"
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
<TextBlock Classes="hint" FontSize="11"
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
@@ -55,13 +55,13 @@
</Grid>
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
<TextBlock Classes="hint" FontSize="10" Margin="0,8,0,0"
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
IsVisible="{Binding HasNoDeviceKeyOption}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="TERMINAL" FontSize="13" FontWeight="SemiBold"
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<!--
@@ -73,16 +73,16 @@
-->
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Text size" Foreground="{StaticResource Text}" FontSize="12"
<TextBlock Text="Text size" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
<TextBlock Classes="hint" FontSize="11"
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" VerticalAlignment="Top">
<Button Classes="ghost" Content="A" Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}"
ToolTip.Tip="Smaller · Ctrl+minus" />
<TextBlock Classes="mono" FontSize="12" MinWidth="26" VerticalAlignment="Center"
<TextBlock Classes="mono" FontSize="13" MinWidth="26" VerticalAlignment="Center"
TextAlignment="Center" Foreground="{StaticResource Text}"
Text="{Binding TerminalFontSize}" />
<Button Classes="ghost" Content="A+" Command="{Binding EnlargeTerminalFontCommand}"
@@ -95,14 +95,14 @@
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="13" FontWeight="SemiBold"
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="12"
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
<TextBlock Classes="hint" FontSize="11"
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
@@ -110,9 +110,9 @@
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
<TextBlock Classes="hint" FontSize="11"
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
@@ -125,9 +125,9 @@
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="12"
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
<TextBlock Classes="hint" FontSize="11"
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
@@ -137,17 +137,17 @@
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11" Margin="0,8,0,0"
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="12" Margin="0,8,0,0"
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
<TextBlock Classes="hint" FontSize="11"
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
</StackPanel>
<!--
@@ -168,9 +168,9 @@
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="13" FontWeight="SemiBold"
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
<TextBlock Classes="hint" FontSize="12" Margin="0,8,0,0"
Text="These are on the design and have nothing behind them. They are listed rather than left out, so that what this screen does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
<ItemsControl Margin="0,12,0,0">
@@ -37,11 +37,11 @@
<Border Padding="12,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;" FontSize="12"
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;" FontSize="13"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
PlaceholderText="search hosts" Margin="8,0,0,0"
FontFamily="{StaticResource MonoFont}" FontSize="13"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Background="Transparent" BorderThickness="0" />
</Grid>
</Border>
@@ -63,24 +63,24 @@
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,8,12,8">
<Border Grid.Column="0" Classes="rowmark" />
<StackPanel Grid.Column="1" Spacing="1" Margin="12,0,0,0">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12" FontWeight="Medium"
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9.5"
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Authentication}" FontSize="9"
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Authentication}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="11" Margin="14,16"
<TextBlock Classes="hint" FontSize="12" Margin="14,16"
Text="No host matches that."
IsVisible="{Binding !HasSearchResults}" />
<Border Padding="12,7" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="9" Foreground="{StaticResource TextFaint}"
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
@@ -22,12 +22,12 @@
<StackPanel Spacing="10">
<TextBlock Classes="heading" FontSize="15" Text="Sign out of this machine?" />
<TextBlock Classes="heading" FontSize="16" Text="Sign out of this machine?" />
<TextBlock Text="{Binding SignOutWarning}" Foreground="{StaticResource WarnText}"
TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap"
Text="This deletes this machine's copy of the keychain — the profile, the cached hosts, keys and passwords, and this machine's device key. Your keychain is on the server and is not touched: signing in again brings it all back." />
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
@@ -38,7 +38,7 @@
the tab strip and the phone's own lock screen all paint green. -->
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Live}"
FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap"
Text="Signing out does not close them, exactly as locking does not. Quit DodoSSH to end them." />
</StackPanel>
</Border>
@@ -48,7 +48,7 @@
a connection this machine holds rather than a window it shows: a transfer in flight authenticated
before any of this and keeps writing into its part file afterwards.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding Transfers.IsConnected, FallbackValue=False}"
Text="A file-transfer session is open on this machine. Signing out does not close it either — it goes when DodoSSH does." />
@@ -58,7 +58,7 @@
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
</StackPanel>
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Your session at the identity provider is not ended by this — DodoSSH has no way to end it — so on a machine that is not yours, sign out there too." />
</StackPanel>
@@ -24,10 +24,10 @@
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="11"
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="9.5"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
@@ -49,7 +49,7 @@
<Border Grid.Column="0" Classes="rowmark" />
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11" FontWeight="Medium"
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<!--
The flag, where the decision is made. A snippet that presses Enter for you is not the
@@ -57,18 +57,18 @@
them.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
<TextBlock Text="runs immediately" FontSize="8.5" />
<TextBlock Text="runs immediately" FontSize="9.5" />
</Border>
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</StackPanel>
<!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
text reads as a single command, which is the thing being decided about on this row.
-->
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="9.5"
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
@@ -76,7 +76,7 @@
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" />
@@ -109,12 +109,12 @@
-->
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
FontSize="11" />
FontSize="12" />
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
Content="Press Enter after inserting this" />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
@@ -125,23 +125,23 @@
<!-- ============ The selected snippet ============ -->
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
<TextBlock Classes="hint" FontSize="11"
<TextBlock Classes="hint" FontSize="12"
Text="Choose a snippet to see it in full and put it into a terminal."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="10"
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="11"
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
@@ -160,7 +160,7 @@
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,10,0,0"
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
</StackPanel>
</StackPanel>
+2 -2
View File
@@ -35,7 +35,7 @@
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="5" VerticalAlignment="Center"
Margin="0,0,14,0" IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
<Ellipse Classes="dot" Classes.live="{Binding SelectedTab.IsLive}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5"
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
</StackPanel>
@@ -43,7 +43,7 @@
Everything the vault has to say. Trimmed rather than wrapped, because this bar is one line high and
a message that grew it would move the whole window's contents up.
-->
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Vault.Status}" FontSize="9.5"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Vault.Status}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
+193 -25
View File
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:contracts="using:DodoSSH.Contracts"
x:Class="DodoSSH.Client.App.Views.TeamsScreen"
x:DataType="vm:TeamsViewModel">
@@ -13,9 +14,12 @@
and the vaults table are side by side, 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: pending invitations (there is no outbound mail path and
no invitation token), two-factor state and last-active (the server records neither), and avatars (no
picture is stored anywhere). None of them is drawn with invented data.
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
both are narrower than the design drew. Nothing is sent — there is no outbound mail path and no token,
so an invitation is a standing instruction that the next account signing in with that address joins the
team, and there is consequently nothing to resend. Last-active is recorded at most once per account per
hour, so it is drawn coarsely rather than to the minute. None of it is drawn with invented data.
-->
<Grid ColumnDefinitions="268,*">
@@ -26,7 +30,7 @@
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="11" FontWeight="SemiBold"
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="ghost" Content="NEW"
Command="{Binding NewTeamCommand}" IsEnabled="{Binding !IsBusy}" />
@@ -41,18 +45,18 @@
<DataTemplate x:DataType="vm:TeamRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="12" FontWeight="Medium"
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
<TextBlock Classes="hint" FontSize="10" Text="{Binding Detail}" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding Detail}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10" Margin="14,12" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="11" Margin="14,12" TextWrapping="Wrap"
IsVisible="{Binding !HasTeams}"
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." />
</StackPanel>
@@ -65,7 +69,7 @@
<TextBlock Classes="label" Text="NEW TEAM" />
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
@@ -82,13 +86,71 @@
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<TextBlock Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<!--
The team's own operations. RENAME is an admin's; the other two are the owner's alone, and
that is the line the server draws as well — an admin the owner promoted must not be able
to archive the team or take it from them.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding ShowsTeamActions}">
<Button Classes="ghost" Content="RENAME" Command="{Binding RenameTeamCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
<Button Classes="ghost" Content="HAND OVER" Command="{Binding TransferOwnershipCommand}"
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." />
<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>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
<StackPanel Margin="14,14" Spacing="18">
<!-- The rename form, in place, exactly as the create form on the left is. -->
<Border Padding="12" CornerRadius="4" BorderThickness="1"
BorderBrush="{StaticResource Border}" IsVisible="{Binding IsEditingTeam}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="RENAME TEAM" />
<TextBox PlaceholderText="Name" Text="{Binding EditTeamName}" />
<TextBox PlaceholderText="What this team is for (optional)"
Text="{Binding EditTeamDescription}" />
<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." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameTeamCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
The armed confirmation, drawn where the buttons that armed it were. The vault screen's
idiom, and for the same reason: there is no modal anywhere in this window.
-->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="4" Padding="12"
IsVisible="{Binding IsConfirming}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding PendingAction.Question}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding PendingAction.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="CONFIRM" Command="{Binding ConfirmActionCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Members -->
<StackPanel Spacing="8">
@@ -98,15 +160,18 @@
Background="Transparent" BorderThickness="0" MaxHeight="240">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
<Grid ColumnDefinitions="*,168,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding Email}" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding Email}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="hint" FontSize="10" VerticalAlignment="Center"
Text="{Binding KeyState}" TextWrapping="Wrap" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="hint" FontSize="11" Text="{Binding KeyState}"
TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding LastActive}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
@@ -114,21 +179,96 @@
</ListBox.ItemTemplate>
</ListBox>
<!--
Buttons and a command rather than a selector bound to the role, which is the choice the
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
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.
-->
<StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
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." />
<Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May read and change this team's vaults." />
<Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May also manage members, create vaults and share vault keys." />
</StackPanel>
</StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER"
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." />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
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." />
</Grid>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
<StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="THEY ARRIVE AS" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Classes.active="{Binding AddsAsViewer}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
<Button Classes="flat choice" Content="MEMBER" Classes.active="{Binding AddsAsMember}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
<Button Classes="flat choice" Content="ADMIN" Classes.active="{Binding AddsAsAdmin}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}" />
</StackPanel>
</StackPanel>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
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." />
</StackPanel>
<!--
Invitations, drawn only when there are any. An empty INVITED heading on every team would be
a permanent reminder of a feature most teams never use.
-->
<StackPanel Spacing="8" IsVisible="{Binding HasInvitations}">
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<TextBlock Classes="label" Text="INVITED" />
<ListBox ItemsSource="{Binding Invitations}" SelectedItem="{Binding SelectedInvitation}"
Background="Transparent" BorderThickness="0" MaxHeight="160">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Email}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Classes="danger" Content="WITHDRAW INVITATION" HorizontalAlignment="Left"
Command="{Binding RevokeInvitationCommand}" IsEnabled="{Binding !IsBusy}"
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." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Vaults -->
@@ -146,15 +286,15 @@
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding !HasSelection}"
Text="Select a team to see its vaults." />
@@ -167,20 +307,48 @@
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
</StackPanel>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
<!--
Who can open the selected vault — the design's "shared with" avatars, as names and a
state. Under the vault rather than beside the member, because a grant is per vault: a
count on a member row would imply per-item sharing, which is M5 and does not exist.
Withdrawn and stale grants stay listed and say which they are, because a list that
quietly dropped them would show a departed colleague as merely absent rather than as
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" />
<ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0"
MaxHeight="150">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamGrantRowViewModel">
<Grid ColumnDefinitions="10,*" Margin="0,3">
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
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." />
</StackPanel>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="1" Classes="hint" FontSize="11" Margin="20" TextWrapping="Wrap"
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="20" TextWrapping="Wrap"
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." />
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding Status}" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11.5" Text="{Binding Status}" TextWrapping="Wrap" />
</Border>
</Grid>
+91 -16
View File
@@ -5,14 +5,36 @@
x:DataType="vm:MainWindowViewModel">
<!--
The tab strip, above every screen.
The window's tab strip: three fixed tabs, then one per open terminal.
Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to
── IT IS NOT ONLY TERMINALS ANY MORE, and the type is still called TerminalTabs. ─────────────────────
Vaults, SFTP and S3 sit at the head of the strip and are always there. The name stays because the strip
is named in MainWindow, in the layout harness's height budget and in its own suite, and renaming a type
to track what it grew into is a rename across four files that leaves the product identical. What the
name now under-describes is written here instead.
── THE THREE FIXED TABS ─────────────────────────────────────────────────────────────────────────────
None of the three can be closed, and that is the difference between them and a terminal rather than a
styling choice. A terminal tab owns a shell and closing it ends that shell; these three own nothing —
they are three places this window goes, and a close box on one would be asking whether to destroy a
destination.
Vaults is first and is the only one with anything under it: the nav rail, and whichever of its screens
the rail points at. SFTP and S3 were rail entries until this strip existed, and they moved because they
are the two destinations you *stay in* while something runs. The rail is drawn only under Vaults; see
MainWindowViewModel.IsVaultsTab for why that is expressed as a page test rather than as a surface.
S3 carries no count although the rail entry it replaces did. There is room for one, and a number on two
of five tabs reads as a fact about those two rather than as the tab's own state — a terminal tab has
nothing to count, and the eye reads the strip left to right expecting the same shape.
── THE TERMINAL TABS ────────────────────────────────────────────────────────────────────────────────
Every one is one pane in the one WebView, so switching is a single frame telling the page which pane to
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
one is the only thing in this application that deliberately ends a session.
It spans the whole window rather than the hosts screen, which is what the strip is for: a connection you
The strip spans the whole window rather than one screen, which is what it is for: a connection you
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
@@ -26,23 +48,76 @@
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
Tabs became pills: taller, rounded, each with its own outline, on the sidebar's surface rather than the
chrome's. The design also puts a "Hosts" pill at the head of this strip and hides the sidebar while a
session is showing, so that pill is the only way back. This window keeps the sidebar up instead — it is
beside both surfaces rather than inside one — so the way back from a terminal is every destination at
once rather than a single pill leading to one of them. That makes the design's pill redundant, and a
redundant control on a strip this narrow is one the user has to rule out before finding the tabs.
chrome's. The design puts a "Hosts" pill at the head of this strip and hides the sidebar while a session
is showing, so that pill is the only way back. The three fixed tabs are that idea taken at its word and
one step further: the rail is not hidden, but it belongs to the Vaults tab, and the head of the strip is
where you go to get back to it.
-->
<Border Height="42" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<!--
Everything in one scrolling row: the tabs, then the button that opens another, then the sentence for
when there are none. The strip stays rather than collapsing — a row of chrome that appears and
disappears would move every screen up and down by 34 pixels each time the last tab closed.
Everything in one scrolling row: the three fixed tabs, a rule, the terminals, the button that opens
another, then the sentence for when there are none. The strip stays rather than collapsing — a row of
chrome that appears and disappears would move every screen up and down by 42 pixels each time the last
tab closed.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Margin="8,0,0,0">
<!--
The three that are always here. Buttons with no close box, marked active from the shell's own
state rather than holding a selection of their own — the same reason the terminal tabs below are
buttons and not a TabStrip.
Each is lit by a different property and the three are exclusive by construction: IsVaultsTab is
"a page, and not one of these two", and the other two are the existing IsTransfersShowing and
IsBucketsShowing that both heads already use. Nothing here can light two at once.
-->
<Button Classes="flat tab fixed" Classes.active="{Binding IsVaultsTab}"
Command="{Binding ShowVaultsCommand}"
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="Vaults" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Classes="flat tab fixed" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}"
ToolTip.Tip="Move files to and from a host over SFTP">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="⇅" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="SFTP" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
The same screen as SFTP over the same view model — an object store and an SFTP host are both an
IRemoteFileStore — and a separate tab anyway, because which picker is offered is decided by the
destination rather than by a toggle inside the screen. See ShowFiles, which also explains why
pressing this while an SFTP session is open refuses instead of arriving.
-->
<Button Classes="flat tab fixed" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="◳" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="S3" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
What separates the fixed tabs from the terminals. Without it the strip is five pills of the same
shape and the user has to read all five to learn that three of them are places and two are
machines. It is a rule rather than a gap because a gap at this width reads as the strip having
been laid out carelessly.
-->
<Border Width="1" Height="18" Margin="6,0,10,0" VerticalAlignment="Center"
Background="{StaticResource Border}" />
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
@@ -92,7 +167,7 @@
whole point of not blocking the window is that the user is somewhere else — the strip is
the one piece of chrome that is on screen wherever that is.
-->
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" FontSize="9.5"
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" FontSize="10.5"
MaxWidth="180" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !HasSession}" />
@@ -107,7 +182,7 @@
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="✕" FontSize="9" HorizontalAlignment="Center"
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
@@ -131,7 +206,7 @@
<Button Classes="flat tab plus" Width="30"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<!--
@@ -139,7 +214,7 @@
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
in it is drawn underneath.
-->
<TextBlock Classes="mono" FontSize="9.5"
<TextBlock Classes="mono" FontSize="10.5"
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
+10 -10
View File
@@ -29,7 +29,7 @@
same one the launcher icon carries, so the three cannot drift.
-->
<Border Width="20" Height="20" CornerRadius="6" Background="{StaticResource Accent}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="9" FontWeight="Bold"
<TextBlock Classes="mono" Text="&gt;_" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource AccentInk}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
@@ -37,7 +37,7 @@
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
sans face while the address, the account and the fingerprint beside it stay monospaced.
-->
<TextBlock Text="DodoSSH" FontSize="13" FontWeight="SemiBold"
<TextBlock Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<!--
The design puts an organisation here — "dodotech / platform". There are no organisations: the
@@ -48,7 +48,7 @@
<Border Classes="chip" IsVisible="{Binding IsUnlocked}">
<TextBlock Text="{Binding Vault.VaultName}" />
</Border>
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="10"
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" MaxWidth="220" />
</StackPanel>
@@ -72,18 +72,18 @@
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="8" Padding="10,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⌕" FontSize="12"
<TextBlock Grid.Column="0" Text="⌕" FontSize="13"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<!--
"Search or connect…", which is what it does: the palette connects on Enter. Not the design's
wider promise of running a command — a snippet is inserted from its own screen, and a box that
offered to run one would be offering something this palette does not do.
-->
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="12" Margin="8,0"
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13" Margin="8,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<Border Grid.Column="2" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
CornerRadius="4" Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="CTRL K" FontSize="9"
<TextBlock Classes="mono" Text="CTRL K" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</Border>
</Grid>
@@ -101,7 +101,7 @@
IsVisible="{Binding IsUnlocked}"
ToolTip.Tip="Green means a connection is held and nothing this machine changed is still waiting to be sent. It does not mean a colleague's change has arrived — that is pulled on a timer.">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding SyncLabel}" FontSize="8.5" FontWeight="SemiBold"
<TextBlock Classes="mono" Text="{Binding SyncLabel}" FontSize="9.5" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
</StackPanel>
@@ -111,17 +111,17 @@
<StackPanel Orientation="Horizontal" Spacing="2">
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
ToolTip.Tip="Minimise">
<TextBlock Text="" FontSize="12" Foreground="{StaticResource TextDim}"
<TextBlock Text="" FontSize="13" Foreground="{StaticResource TextDim}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
ToolTip.Tip="Maximise">
<TextBlock Text="▢" FontSize="10" Foreground="{StaticResource TextDim}"
<TextBlock Text="▢" FontSize="11" Foreground="{StaticResource TextDim}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
<TextBlock Text="✕" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock Text="✕" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</StackPanel>
@@ -79,21 +79,22 @@
<!--
The screen names whichever remote it is offering, because since v2 it is reached as two
destinations rather than one: SFTP and S3 are separate entries in the sidebar and this control
draws both. A single "FILES" heading over a bucket picker would name neither of them.
destinations rather than one: SFTP and S3 are separate tabs in the strip — they were rail entries
until v3 — and this control draws both. A single "FILES" heading over a bucket picker would name
neither of them.
-->
<TextBlock Grid.Column="0" Classes="mono" Text="SFTP" FontSize="11" FontWeight="SemiBold"
<TextBlock Grid.Column="0" Classes="mono" Text="SFTP" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,12,0" IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Grid.Column="0" Classes="mono" Text="S3" FontSize="11" FontWeight="SemiBold"
<TextBlock Grid.Column="0" Classes="mono" Text="S3" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,12,0" IsVisible="{Binding ShowsBucketPicker}" />
<!--
The HOST / BUCKET pair that used to sit here is gone: which sort of remote this screen offers is
now the destination you chose in the sidebar, and a toggle that silently moved you to the other
one would leave the lit sidebar entry naming a screen you are no longer on. What sets it is
ShowFiles on the shell, which is what the sidebar calls.
now the tab you chose, and a toggle that silently moved you to the other one would leave the lit
tab naming a screen you are no longer on. What sets it is ShowFiles on the shell, which is what
the tab calls.
-->
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
@@ -104,9 +105,9 @@
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9"
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
@@ -121,9 +122,9 @@
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9"
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
@@ -160,7 +161,7 @@
mostly read before connecting; it is not now, because refusing to switch between SFTP and S3 while
a session is live reports itself here, which is precisely when the chip is on screen.
-->
<TextBlock Grid.Column="6" Classes="hint" Text="{Binding Status}" FontSize="10.5"
<TextBlock Grid.Column="6" Classes="hint" Text="{Binding Status}" FontSize="11.5"
Margin="12,0,0,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
@@ -219,10 +220,10 @@
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
Foreground="{StaticResource TextDim}" />
</Button>
<TextBlock Classes="mono" Text="" FontSize="10"
<TextBlock Classes="mono" Text="" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
@@ -230,9 +231,9 @@
</ItemsControl>
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
</Grid>
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
@@ -242,18 +243,18 @@
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="11"
Text="{Binding Name}" FontSize="12"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="3" Classes="hint" FontSize="11" Margin="24" MaxWidth="260"
<TextBlock Grid.Row="3" Classes="hint" FontSize="12" Margin="24" MaxWidth="260"
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" />
@@ -322,12 +323,12 @@
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConfirmingRemoteDeletion}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<SelectableTextBlock Classes="mono" FontSize="10.5" TextWrapping="Wrap"
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.FullPath}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="DELETE ON THE HOST"
@@ -347,12 +348,12 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<StackPanel Orientation="Horizontal">
<TextBlock Classes="mono" Text="/" FontSize="10"
<TextBlock Classes="mono" Text="/" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
Foreground="{StaticResource TextDim}" />
</Button>
</StackPanel>
@@ -373,10 +374,10 @@
</Grid>
<Grid Grid.Row="3" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
</Grid>
<ListBox Grid.Row="4" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
@@ -387,14 +388,14 @@
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="11"
Text="{Binding Name}" FontSize="12"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Text="{Binding Permissions}" FontSize="9.5" VerticalAlignment="Center" />
Text="{Binding Permissions}" FontSize="10.5" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -403,10 +404,10 @@
<StackPanel Grid.Row="4" Spacing="10" Margin="24" MaxWidth="300"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !HasRemoteEntries}">
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
Text="Connect to a host to browse its files. This opens its own SFTP connection, so the host records a second login — it is not the same channel as a terminal."
IsVisible="{Binding !IsConnected}" />
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
Text="Nothing in this directory."
IsVisible="{Binding IsConnected}" />
</StackPanel>
@@ -424,7 +425,7 @@
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="11"
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
Foreground="{StaticResource Danger}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
@@ -441,7 +442,7 @@
<Border Grid.Row="0" Padding="12,7">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5" Margin="10,0,0,0"
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="one at a time · nothing lands at its final name until it is complete" />
</Grid>
@@ -449,7 +450,7 @@
<ScrollViewer Grid.Row="1">
<StackPanel>
<TextBlock Classes="hint" FontSize="10.5" Margin="12,4,12,14"
<TextBlock Classes="hint" FontSize="11.5" Margin="12,4,12,14"
IsVisible="{Binding !HasTransfers}"
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
@@ -457,12 +458,12 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="11"
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="12"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="0,0,8,0">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10.5"
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11.5"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="9"
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
@@ -472,7 +473,7 @@
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="9.5"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="10.5"
Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextDim}" />
@@ -517,7 +518,7 @@
<TextBlock Classes="heading" Text="Check this host's fingerprint" />
<TextBlock Classes="hint"
Text="This host has not been seen before. Compare the fingerprint with what the operator published. Trusting it here also trusts it for terminals, and on your other machines." />
<SelectableTextBlock Classes="mono" FontSize="11" Foreground="{StaticResource Text}"
<SelectableTextBlock Classes="mono" FontSize="12" Foreground="{StaticResource Text}"
TextWrapping="Wrap" Text="{Binding PendingHostKey.Fingerprint}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT" Command="{Binding TrustHostKeyCommand}" />
@@ -56,7 +56,7 @@
</StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap"
Text="This works with no network: the salt and the wrapped key are already on this machine." />
<!--
@@ -74,7 +74,7 @@
the tab strip and the phone's own lock screen all paint green. -->
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Live}"
FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap"
Text="Locking closes the keychain, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the keychain, not the connections. Quit DodoSSH to end them." />
</StackPanel>
</Border>
@@ -89,7 +89,7 @@
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<StackPanel Spacing="6">
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap"
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the keychain is on the server and comes back." />
<Button Classes="ghost" Content="RESET THIS MACHINE"
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
@@ -11,7 +11,7 @@ internal sealed partial class UnlockCard : UserControl
/// Where the keyboard goes when the vault is locked.
/// </summary>
/// <remarks>
/// Exposed the way <see cref="HostSidebar.KeyboardTarget"/> is, and for the same reason: the window
/// Exposed the way <see cref="HostsScreen.KeyboardTarget"/> is, and for the same reason: the window
/// owns the focus policy — it has to take the keyboard off the terminal's native child window first —
/// and the control it hands it to belongs to whichever screen is showing.
/// </remarks>
+48 -32
View File
@@ -116,7 +116,7 @@
-->
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10"
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="11"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
@@ -131,11 +131,11 @@
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="11" />
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="9.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." />
</StackPanel>
@@ -158,21 +158,37 @@
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SectionTitle}" FontSize="11"
<!--
◆ THE SUMMARY IS THE COLUMN THAT GIVES WAY, and that is what stops this header overflowing again.
It used to be Auto,Auto,*,Auto — a fixed title, a fixed summary, slack, then the buttons — so the
slack column was the only thing absorbing a change of width, and the strip fell off the right edge
the moment the five buttons wanted more than it had. That is not hypothetical: it is why GENERATE
lost the word KEY (see below), and it happened again the moment the type scale went up a point.
Buying pixels by shortening a caption fixes one instance of a shape that keeps producing them.
So the summary sits in the star column and trims, and the buttons are Auto and always get their
full width. That is the rule worth encoding rather than the pixels: a trimmed summary is a fact
you can read by widening the window, and a clipped button is a dead end. The titlebar's search box
is arranged this way for the same reason.
-->
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SectionTitle}" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<!--
Always offered. Every category left on this screen is one things can be added to — the one
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
either. See KnownHostsScreen.
GENERATE lost the word KEY when a fifth button arrived: five of these overflow an 1016-wide
window's header by a few pixels, and the layout suite catches it. Its tooltip carries what the
word did, and the two key buttons are adjacent, so which one generates is not in doubt.
GENERATE carries no KEY, which it lost when a fifth button arrived and the strip could still
overflow. The arrangement above is what keeps it inside now; the short caption stays because
its tooltip carries what the word did and the two key buttons are adjacent, so which one
generates is not in doubt.
-->
<Button Classes="ghost" Content="GENERATE" Command="{Binding NewGeneratedKeyCommand}"
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
@@ -196,11 +212,11 @@
-->
<Grid Grid.Row="1" ColumnDefinitions="2,1.3*,74,*,88" Margin="0,6,14,6"
IsVisible="{Binding HasVaultItems}">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1"
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="DETAIL" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="DETAIL" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="2" x:Name="ItemList" Focusable="True"
@@ -210,17 +226,17 @@
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="2,1.3*,74,*,88" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="11"
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="9"
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Detail}" FontSize="9.5"
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Detail}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="4" Classes="chip warn" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
</DataTemplate>
@@ -231,7 +247,7 @@
The empty state says which category is empty and what to do about it, rather than showing an empty
grid that reads as a list still loading.
-->
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptySectionMessage}" FontSize="11"
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptySectionMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="320"
IsVisible="{Binding !HasVaultItems}" />
@@ -245,12 +261,12 @@
<StackPanel Margin="14,16">
<!-- Nothing selected. -->
<TextBlock Classes="hint" FontSize="11"
<TextBlock Classes="hint" FontSize="12"
Text="Choose something on the left to see what is known about it."
IsVisible="{Binding !HasSelectedVaultItem}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelectedVaultItem}">
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Name}" FontSize="12"
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Name}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="chip">
@@ -265,7 +281,7 @@
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding SelectedVaultItem.Detail}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
@@ -275,7 +291,7 @@
nothing can exercise a credential without a host to exercise it against. Rather than five
empty rows, this says what is missing in one line.
-->
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
<TextBlock Classes="hint" FontSize="10.5" Margin="0,12,0,0"
Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
@@ -336,7 +352,7 @@
</StackPanel>
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
<!--
@@ -344,7 +360,7 @@
bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
already makes: a passphrase protects a key file on a disk, and this key is never on one.
-->
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap" Margin="0,4,0,0"
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,4,0,0"
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
@@ -366,13 +382,13 @@
<TextBox Text="{Binding KeyEditorPrivateKey}"
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
FontFamily="{StaticResource MonoFont}" FontSize="10.5" />
FontFamily="{StaticResource MonoFont}" FontSize="11.5" />
<TextBox Text="{Binding KeyEditorPassphrase}"
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
<TextBox Text="{Binding KeyEditorPublicKey}" PlaceholderText="public half (optional)" />
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
<TextBlock Classes="hint" FontSize="10.5"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
@@ -399,7 +415,7 @@
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
<TextBlock Classes="hint" FontSize="10.5"
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to this in the host's own editor, under how it authenticates." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCredentialCommand}" />
@@ -419,7 +435,7 @@
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="hint" FontSize="9.5"
<TextBlock Classes="hint" FontSize="10.5"
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveTagCommand}" />
@@ -452,11 +468,11 @@
Said where the decision is made. Getting this wrong produces a DNS failure whose message
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
-->
<TextBlock Classes="hint" FontSize="9.5"
<TextBlock Classes="hint" FontSize="10.5"
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
<TextBlock Classes="hint" FontSize="10.5"
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
+16 -4
View File
@@ -82,11 +82,23 @@
<!--
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
── The ramp is white-topped, and every step below it moved with the top. ─────────────────────────────
The design's #E3E7F4 is a blue-tinted white, and against these blue-black surfaces the tint costs
contrast twice: once because it is darker than white, and once because it shares a hue with what it is
drawn on. Pure white against #0E1220 is 18.3:1, where #E3E7F4 was 15.5:1.
Raising only the top would have been the wrong half of the change. The three steps are a ramp rather
than three colours, and their job is that a glance can tell them apart — so lifting Text and leaving
TextDim where it was would have widened the first gap and left the second two crowded at the bottom.
Every step is raised by roughly what the top gained, which keeps the intervals the design chose and
brings the lower steps up to where a secondary label is comfortably readable rather than merely
present. TextDim clears 9:1 now, against 6.4:1 before.
-->
<SolidColorBrush x:Key="Text" Color="#E3E7F4" />
<SolidColorBrush x:Key="TextDim" Color="#8B93B0" />
<SolidColorBrush x:Key="TextFaint" Color="#565E7E" />
<SolidColorBrush x:Key="TextGhost" Color="#414A6B" />
<SolidColorBrush x:Key="Text" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextDim" Color="#B4BCD4" />
<SolidColorBrush x:Key="TextFaint" Color="#7B849F" />
<SolidColorBrush x:Key="TextGhost" Color="#5A6280" />
<!--
The accent, and it is two colours rather than one. The design fills with #5B8CFF — the button, the
@@ -73,7 +73,7 @@ internal enum ShellScreen
/// <summary>Everything in the vault that is not a host.</summary>
Vault = 2,
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
/// <summary>Shared vaults and the people in them. Both heads draw it.</summary>
Team = 3,
/// <summary>Preferences.</summary>
@@ -418,25 +418,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </summary>
/// <remarks>
/// <para>
/// Two defaults, because the two audiences never overlap. A release build is installed by somebody
/// signing in to the hosted deployment, and typing its address is the only thing standing between
/// them and a working application. A debug build is run from a clone, next to
/// <c>dotnet run --project src/DodoSSH.Api</c>, and shipping the hosted address there would point
/// every development launch at production — which is worse than an inconvenience, since sign-in is
/// the step that enrolls a device.
/// </para>
/// <para>
/// Note the schemes. <c>http</c> locally is not an oversight: the API's first launch profile — the
/// one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing an
/// HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
/// One default for every build. The hosted deployment is what all but a handful of launches are
/// aiming at, and typing its address is the only thing standing between an installed application and
/// a working one. Running against a clone means replacing this with
/// <c>http://localhost:5233</c> by hand — note the scheme, because the API's first launch profile —
/// the one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing
/// an HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
/// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
/// </para>
/// <para>
/// This was once split on <c>DEBUG</c> so a development launch could not enroll a device against
/// production by accident. That protection is gone: a debug build now offers the hosted address like
/// any other, and the first sign-in accepted unread lands there.
/// </para>
/// </remarks>
#if DEBUG
internal const string DefaultServerUrl = "http://localhost:5233";
#else
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
#endif
[ObservableProperty]
private string serverUrl = DefaultServerUrl;
@@ -784,18 +780,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// tabs are each exactly one thing, and this one is seven.
///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this.
/// the rail and never asks this. <see cref="ShellScreen.Team"/> 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
/// 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.
///
/// <b>The keychain joined it, and that is why the bar went from four entries to three.</b> A phone's
/// bottom bar is for the places a session moves between, and the keychain is not one of those: hosts
/// and connections are what somebody opens the application to do, and keys, credentials and tags are
/// what they go and manage occasionally. The desktop keeps its rail entry — it has room for nine — so
/// this is the second thing the two heads deliberately arrange differently, after the hub itself.
/// <b>The keychain joined it too, and that is why the bar went from four entries to three.</b> Unlike
/// the two above, that one is a move rather than an addition: a phone's bottom bar is for the places a
/// session moves between, and the keychain is not one of those — hosts and connections are what
/// somebody opens the application to do, and keys, credentials and tags are what they go and manage
/// occasionally. The desktop keeps its rail entry, having room for nine, so this is the second thing
/// the two heads deliberately arrange differently, after the hub itself.
/// </remarks>
internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
or ShellScreen.Vault;
or ShellScreen.Team or ShellScreen.Vault;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
@@ -1028,6 +1028,60 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
vault.ManualStatus = string.Empty;
}
// ---- The desktop's fixed tabs ----
/// <summary>
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
/// </summary>
/// <remarks>
/// <para>
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
/// which is the screen you are looking at.
/// </para>
/// <para>
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
/// 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.
/// </para>
/// </remarks>
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
private static bool IsVaultsPage(ShellScreen screen) =>
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
/// <summary>
/// Which page the Vaults tab returns to.
/// </summary>
/// <remarks>
/// <para>
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
/// screen.
/// </para>
/// <para>
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
/// worth stating because the two look alike. That one would have been a second copy of "which page",
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
/// remembering its page, the way any tab does — and nothing else reads it.
/// </para>
/// </remarks>
private ShellScreen vaultsScreen = ShellScreen.Hosts;
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
[RelayCommand]
private void ShowVaults() => ShowScreen(vaultsScreen);
// ---- The phone's connect menu ----
/// <summary>
/// Whether the phone's connect menu is open over the terminal.
/// </summary>
@@ -2663,6 +2717,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
RaiseSurfaceState();
// What the Vaults tab comes back to; see the field.
if (IsVaultsPage(value))
{
vaultsScreen = value;
}
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
@@ -2762,6 +2822,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsBucketsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsVaultsTab));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsVaultShowing));
@@ -50,18 +50,119 @@ internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsS
/// What the account can be given, in one phrase.
/// </summary>
/// <remarks>
/// Not a two-factor column, not a last-active column. The server records neither: there is no
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
/// Not a two-factor column: there is no second-factor concept anywhere in the server. What is
/// true and worth a column is whether a vault key can be wrapped to them at all.
/// </remarks>
internal string KeyState => Member.IsEnrolled
? "key published"
: "no key yet — cannot be given a vault";
/// <summary>
/// The day they were last here, or that they never have been.
/// </summary>
/// <remarks>
/// A date to the day, not a time and not a "3 hours ago". Two reasons, and they point the same
/// way: the server writes this at most once an hour, so anything finer would be reading a
/// precision into it that is not there — and a relative phrase would have to be recomputed against
/// a clock, which this row does not have and which the pinned-host list already decided against by
/// rendering its own dates the same way.
/// </remarks>
internal string LastActive => Member.LastActiveAt is { } seen
? "last here " + seen.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
: "never signed in";
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
/// <summary>Whether this member's role can be changed at all.</summary>
/// <remarks>
/// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is
/// only meaningful as half of a transfer. That is its own command.
/// </remarks>
internal bool CanChangeRole => Member.Role != TeamMemberRole.Owner;
}
/// <summary>One vault key grant, as a row under the vault it opens.</summary>
/// <remarks>
/// This is the "shared with" list the design drew as a row of avatars. It is drawn as names and a
/// state instead, and it is a list rather than a count for a reason worth keeping: a grant is per
/// vault, so a number on an item row would imply per-item sharing, which does not exist.
/// </remarks>
internal sealed record TeamGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration)
{
internal Guid UserId => Grant.RecipientUserId;
internal string Name => Grant.DisplayName ?? Grant.Email ?? Grant.RecipientUserId.ToString();
/// <summary>
/// What this grant is worth, in one phrase.
/// </summary>
/// <remarks>
/// Staleness is decided by comparing generations rather than by reading
/// <see cref="VaultGrantState"/> alone, which is what <c>VaultGrantsResponse.KeyGeneration</c>
/// exists for: a grant can be Active and still open nothing, because it was wrapped to a key the
/// vault has since moved past.
/// </remarks>
internal string State => Grant.State switch
{
VaultGrantState.Revoked => "withdrawn — blocks future reads only",
VaultGrantState.AwaitingRewrap => "needs wrapping again — their key changed",
_ when Grant.KeyGeneration < VaultGeneration => "stale — wrapped to an older key, opens nothing",
_ => "holds a key",
};
/// <summary>Whether this row still represents somebody who can read the vault.</summary>
internal bool IsLive =>
Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration;
}
/// <summary>One invitation, as a row under the members it will join.</summary>
internal sealed record TeamInvitationRowViewModel(TeamInvitationSummary Invitation)
{
internal Guid InvitationId => Invitation.InvitationId;
internal string Email => Invitation.Email;
internal string Role => Invitation.Role.ToString().ToUpperInvariant();
/// <summary>
/// What has become of it, said as a sentence rather than a status word.
/// </summary>
/// <remarks>
/// The pending case has to carry the whole mechanism, because there is nothing else on this screen
/// that could: nothing was sent, so somebody reading "invited" would reasonably wait for an email
/// that is never coming.
/// </remarks>
internal string State => Invitation.State switch
{
TeamInvitationState.Accepted => "joined",
TeamInvitationState.Revoked => "withdrawn",
TeamInvitationState.Expired => "expired — invite them again if they still need it",
_ => "waiting — they join when they first sign in here. Nothing was sent; tell them yourself.",
};
/// <summary>Whether this invitation can still be withdrawn.</summary>
internal bool IsPending => Invitation.State == TeamInvitationState.Pending;
}
/// <summary>
/// A destructive team operation, armed and waiting to be confirmed.
/// </summary>
/// <remarks>
/// The armed-state idiom the vault screen uses, and for the same reason: this window has no modal, so
/// a confirmation is drawn in place of the buttons that armed it. The target id is carried here rather
/// than read from the selection at confirm time — otherwise selecting a different row between arming
/// and confirming would apply the answer to something else.
/// </remarks>
/// <param name="TeamId">The team the action is aimed at.</param>
/// <param name="MemberId">The member it is aimed at, for a transfer.</param>
/// <param name="Question">What is being asked.</param>
/// <param name="Consequence">What will actually happen, stated honestly.</param>
internal sealed record TeamActionRequest(
Guid TeamId,
Guid MemberId,
string Question,
string Consequence);
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
{
@@ -108,6 +209,17 @@ internal sealed partial class TeamsViewModel(
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
internal ObservableCollection<TeamVaultRowViewModel> Vaults { get; } = [];
/// <summary>Who holds a key to the selected vault.</summary>
/// <remarks>
/// Read from the server rather than from the session, and it is the one list on this screen that
/// has to be: the keyring can only answer whether <em>this</em> machine can open a vault, and this
/// question is about everybody else.
/// </remarks>
internal ObservableCollection<TeamGrantRowViewModel> Grants { get; } = [];
/// <summary>Invitations to addresses that are not accounts here yet.</summary>
internal ObservableCollection<TeamInvitationRowViewModel> Invitations { get; } = [];
[ObservableProperty]
private TeamRowViewModel? selectedTeam;
@@ -117,6 +229,9 @@ internal sealed partial class TeamsViewModel(
[ObservableProperty]
private TeamVaultRowViewModel? selectedVault;
[ObservableProperty]
private TeamInvitationRowViewModel? selectedInvitation;
[ObservableProperty]
private string status = string.Empty;
@@ -134,26 +249,92 @@ internal sealed partial class TeamsViewModel(
[ObservableProperty]
private string newTeamSlug = string.Empty;
// ---- Renaming a team ----
[ObservableProperty]
private bool isEditingTeam;
[ObservableProperty]
private string editTeamName = string.Empty;
[ObservableProperty]
private string editTeamDescription = string.Empty;
// ---- Adding a member ----
[ObservableProperty]
private string inviteEmail = string.Empty;
/// <summary>
/// The role a newly added or invited account gets.
/// </summary>
/// <remarks>
/// Member by default, which is the role somebody adding a colleague almost always means. Viewer
/// would be safer and would be the wrong default: an interface whose default is wrong teaches
/// people to change it without reading it.
/// </remarks>
[ObservableProperty]
private TeamMemberRole newMemberRole = TeamMemberRole.Member;
// ---- Confirming something that cannot be undone ----
[ObservableProperty]
private TeamActionRequest? pendingAction;
/// <summary>Whether there is a server to talk to at all.</summary>
internal bool IsOnline => connection() is not null;
/// <summary>Whether the selected team can be administered by this account.</summary>
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
/// <summary>Whether this account owns the selected team.</summary>
/// <remarks>
/// A narrower gate than <see cref="CanAdministerSelected"/>, and the server draws the same line:
/// archiving a team and handing it over decide whether it goes on existing and who controls it,
/// so an admin the owner promoted must not be able to do either.
/// </remarks>
internal bool OwnsSelected => SelectedTeam?.Team.Role == TeamMemberRole.Owner;
/// <summary>Whether there is anything to show below the team list.</summary>
internal bool HasSelection => SelectedTeam is not null;
internal bool HasTeams => Teams.Count > 0;
/// <summary>Whether a destructive action is armed and waiting for an answer.</summary>
internal bool IsConfirming => PendingAction is not null;
/// <summary>Whether the ordinary team buttons should be showing.</summary>
/// <remarks>
/// The inverse of <see cref="IsConfirming"/>, so the confirmation replaces the buttons that armed
/// it rather than appearing beneath them still pressable.
/// </remarks>
internal bool ShowsTeamActions => !IsConfirming;
/// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
internal bool HasInvitations => Invitations.Count > 0;
internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;
internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member;
internal bool AddsAsAdmin => NewMemberRole == TeamMemberRole.Admin;
/// <summary>Reads the teams this account belongs to, and the selected one's detail.</summary>
internal Task LoadAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
/// <summary>Reads it all again.</summary>
/// <remarks>
/// The same work as <see cref="LoadAsync"/>, exposed as a command because markup cannot invoke a
/// method. The phone needs it and the desktop does not: this screen is loaded on arrival, and on
/// the desktop leaving the rail and coming back is one click, where on the phone it is a trip out
/// to MORE and back. Nothing on this screen is cached, so a re-read is the only way to see a change
/// somebody else made.
/// </remarks>
[RelayCommand]
private Task RefreshAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
/// <summary>
/// The reload itself, without the busy gate.
/// </summary>
@@ -289,16 +470,14 @@ internal sealed partial class TeamsViewModel(
if (found.Count == 0)
{
Status = $"No account here has the address '{email}'. They have to sign in to this "
+ "server once before they can be added — that is what publishes the key a vault "
+ "would be shared with.";
await InviteAsync(server, team, email, cancellationToken).ConfigureAwait(true);
return;
}
var member = await server.Teams
.AddTeamMemberAsync(
team.TeamId,
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
new AddTeamMemberRequest(found[0].UserId, NewMemberRole),
cancellationToken)
.ConfigureAwait(true);
@@ -313,6 +492,282 @@ internal sealed partial class TeamsViewModel(
}).ConfigureAwait(true);
}
/// <summary>
/// Invites an address the directory does not know.
/// </summary>
/// <remarks>
/// <para>
/// Reached by falling through from <see cref="AddMemberAsync"/> rather than from a second button,
/// because the person typing an address does not know or care which of the two applies — that is a
/// fact about the server's account table, not about what they are trying to do. Which one happened
/// is reported afterwards, because the difference decides what they have to do next.
/// </para>
/// <para>
/// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
/// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
/// better.
/// </para>
/// </remarks>
private async Task InviteAsync(
IVaultServer server,
TeamRowViewModel team,
string email,
CancellationToken cancellationToken)
{
var invitation = await server.Teams
.CreateTeamInvitationAsync(
team.TeamId,
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
cancellationToken)
.ConfigureAwait(true);
InviteEmail = string.Empty;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"No account here has the address '{email}' yet, so it has been invited instead. "
+ $"They join this team as {invitation.Role.ToString().ToLowerInvariant()} the first time "
+ "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — "
+ "and their identity provider has to confirm the address is theirs.";
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
[RelayCommand]
private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
|| SelectedTeam is not { } team
|| SelectedInvitation is not { } invitation)
{
return;
}
await RunAsync(async () =>
{
var revoked = await server.Teams
.RevokeTeamInvitationAsync(team.TeamId, invitation.InvitationId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = revoked
? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them "
+ "in this team."
: $"The invitation to {invitation.Email} was already taken up or withdrawn. If they "
+ "are a member now, remove them instead.";
}).ConfigureAwait(true);
}
/// <summary>Picks the role a newly added or invited account will get.</summary>
[RelayCommand]
private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;
/// <summary>Changes the selected member's role.</summary>
/// <remarks>
/// Owner is not offered, and the command refuses it rather than relying on the view not to send
/// it: the server refuses it too, and a button that produced a server error would be reporting a
/// rule the interface should have known.
/// </remarks>
[RelayCommand]
private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken)
{
if (connection() is not { } server
|| SelectedTeam is not { } team
|| SelectedMember is not { } member)
{
return;
}
if (role is TeamMemberRole.Owner or TeamMemberRole.Unspecified)
{
Status = "Ownership is handed over rather than assigned. Use HAND OVER below.";
return;
}
if (member.Member.Role == role)
{
return;
}
await RunAsync(async () =>
{
var changed = await server.Teams
.ChangeTeamMemberRoleAsync(
team.TeamId,
member.UserId,
new ChangeTeamMemberRoleRequest(role),
cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedMember = Members.FirstOrDefault(row => row.UserId == member.UserId);
// What a role does and does not reach. A viewer still holds whatever key they were
// wrapped, so demoting somebody is not a way of taking a vault back from them.
Status = $"{member.Name} is now {changed.Role.ToString().ToLowerInvariant()}. This changes "
+ "what the server will serve them; it does not withdraw a vault key they already "
+ "hold — use WITHDRAW KEY for that.";
}).ConfigureAwait(true);
}
/// <summary>Opens the rename form for the selected team.</summary>
[RelayCommand]
private void RenameTeam()
{
if (SelectedTeam is not { } team)
{
return;
}
EditTeamName = team.Name;
EditTeamDescription = team.Team.Description ?? string.Empty;
IsEditingTeam = true;
Status = string.Empty;
}
/// <summary>Abandons the rename form.</summary>
[RelayCommand]
private void CancelRenameTeam()
{
IsEditingTeam = false;
Status = string.Empty;
}
/// <summary>Saves the renamed team.</summary>
[RelayCommand]
private async Task SaveTeamAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server || SelectedTeam is not { } team)
{
return;
}
var name = EditTeamName.Trim();
if (name.Length == 0)
{
Status = "A team needs a name.";
return;
}
var description = EditTeamDescription.Trim();
await RunAsync(async () =>
{
await server.Teams
.UpdateTeamAsync(
team.TeamId,
new UpdateTeamRequest(name, description.Length == 0 ? null : description),
cancellationToken)
.ConfigureAwait(true);
IsEditingTeam = false;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// The slug is named because it did not change and somebody expecting it to would
// otherwise find out from a URL much later.
Status = $"Renamed to '{name}'. Its slug is still '{team.Slug}' — that is what URLs and "
+ "the server's own records use, and it does not change.";
}).ConfigureAwait(true);
}
/// <summary>Arms the archive confirmation for the selected team.</summary>
[RelayCommand]
private void ArchiveTeam()
{
if (SelectedTeam is not { } team)
{
return;
}
PendingAction = new TeamActionRequest(
team.TeamId,
Guid.Empty,
$"Archive '{team.Name}'?",
"Everybody loses sight of it at once, and only somebody with database access can bring it "
+ "back. It is refused outright if the team still owns any vault.");
}
/// <summary>Arms the hand-over confirmation for the selected member.</summary>
[RelayCommand]
private void TransferOwnership()
{
if (SelectedTeam is not { } team || SelectedMember is not { } member)
{
return;
}
if (member.IsSelf)
{
Status = "You already own this team.";
return;
}
PendingAction = new TeamActionRequest(
team.TeamId,
member.UserId,
$"Hand '{team.Name}' to {member.Name}?",
"They become the owner and you become an admin. You will not be able to take it back "
+ "yourself — only the new owner can hand it on.");
}
/// <summary>Cancels an armed action.</summary>
[RelayCommand]
private void CancelAction() => PendingAction = null;
/// <summary>
/// Carries out whichever action was armed.
/// </summary>
/// <remarks>
/// Disarmed before the work rather than after it, so the card goes the moment it is answered and a
/// second press during a slow round trip has nothing left to agree to.
/// </remarks>
[RelayCommand]
private async Task ConfirmActionAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server || PendingAction is not { } request)
{
return;
}
PendingAction = null;
await RunAsync(async () =>
{
if (request.MemberId == Guid.Empty)
{
var archived = await server.Teams
.ArchiveTeamAsync(request.TeamId, cancellationToken)
.ConfigureAwait(true);
SelectedTeam = null;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = archived
? "Archived. It is gone from everybody's list; the rows are still in the database "
+ "and only an operator can bring them back."
: "There was no such team to archive.";
return;
}
await server.Teams
.TransferTeamOwnershipAsync(
request.TeamId,
new TransferTeamOwnershipRequest(request.MemberId),
cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = "Handed over. You are an admin of this team now, and only its new owner can hand "
+ "it on again.";
}).ConfigureAwait(true);
}
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
[RelayCommand]
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
@@ -432,17 +887,70 @@ internal sealed partial class TeamsViewModel(
{
RaiseState();
// An armed confirmation names the team it was armed for, so a selection change has to disarm
// it — otherwise the card stays on screen above a different team and reads as being about it.
PendingAction = null;
IsEditingTeam = false;
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
// from a list box, which has no cancellation token and no way to await. Failures land in Status
// through RunAsync exactly as a command's would.
_ = LoadSelectedAsync(CancellationToken.None);
}
/// <summary>Reads the selected team's members and vaults.</summary>
partial void OnPendingActionChanged(TeamActionRequest? value)
{
OnPropertyChanged(nameof(IsConfirming));
OnPropertyChanged(nameof(ShowsTeamActions));
}
partial void OnNewMemberRoleChanged(TeamMemberRole value)
{
OnPropertyChanged(nameof(AddsAsViewer));
OnPropertyChanged(nameof(AddsAsMember));
OnPropertyChanged(nameof(AddsAsAdmin));
}
/// <remarks>
/// The grants list belongs to a vault rather than to a team, so it is reloaded on selection here
/// rather than in <see cref="LoadSelectedAsync"/> — which would leave it showing the previous
/// vault's key-holders after a click.
/// </remarks>
partial void OnSelectedVaultChanged(TeamVaultRowViewModel? value) =>
_ = LoadGrantsAsync(CancellationToken.None);
/// <summary>Reads who holds a key to the selected vault.</summary>
private async Task LoadGrantsAsync(CancellationToken cancellationToken)
{
Grants.Clear();
if (connection() is not { } server || SelectedVault is not { } vault)
{
return;
}
await RunAsync(async () =>
{
var response = await server.Grants
.ListVaultGrantsAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true);
Grants.Clear();
foreach (var grant in response.Grants)
{
Grants.Add(new TeamGrantRowViewModel(grant, response.KeyGeneration));
}
}).ConfigureAwait(true);
}
/// <summary>Reads the selected team's members, invitations and vaults.</summary>
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
{
Members.Clear();
Invitations.Clear();
Vaults.Clear();
Grants.Clear();
if (connection() is not { } server || SelectedTeam is not { } team)
{
@@ -461,6 +969,19 @@ internal sealed partial class TeamsViewModel(
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
}
var invitations = await server.Teams
.ListTeamInvitationsAsync(team.TeamId, cancellationToken)
.ConfigureAwait(true);
foreach (var invitation in invitations)
{
Invitations.Add(new TeamInvitationRowViewModel(invitation));
}
SelectedInvitation = Invitations.FirstOrDefault(row => row.IsPending);
OnPropertyChanged(nameof(HasInvitations));
if (open is null)
{
return;
@@ -485,6 +1006,8 @@ internal sealed partial class TeamsViewModel(
OnPropertyChanged(nameof(HasTeams));
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanAdministerSelected));
OnPropertyChanged(nameof(OwnsSelected));
OnPropertyChanged(nameof(HasInvitations));
OnPropertyChanged(nameof(IsOnline));
}
@@ -333,7 +333,13 @@ internal sealed partial class HostRowViewModel(
/// <summary>What a host can authenticate with.</summary>
internal enum AuthenticationKind
{
/// <summary>Typed at the moment of connecting, and never stored.</summary>
/// <summary>Typed at the moment of connecting.</summary>
/// <remarks>
/// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it
/// creates a credential and moves the host to <see cref="Credential"/>, so a stored password is always
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
/// </remarks>
Typed,
/// <summary>An SSH key in this vault.</summary>
@@ -1022,6 +1028,34 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
/// <summary>Whether the grid has anything to draw.</summary>
/// <remarks>
/// A property rather than <c>{Binding !VisibleHosts.Count}</c> in the markup. Avalonia's <c>!</c> is a
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
/// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts.
/// </remarks>
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
/// <summary>
/// What the hosts grid says when it has nothing in it.
/// </summary>
/// <remarks>
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
/// matches what you typed" are three different situations and only the first is an invitation to add
/// something. Telling somebody with thirty machines to add their first one is answering a question they
/// did not ask.
/// </remarks>
internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
{
(0, _, _) =>
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
+ "~/.ssh/config from Preferences.",
(_, not null, 0) =>
"Nothing is filed under this group yet. Drag a host onto its heading in the grid, or choose the "
+ "group in a host's own editor.",
_ => "No host matches that. The name, the address and the notes are all searched.",
};
/// <summary>
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
/// </summary>
@@ -1068,10 +1102,6 @@ internal sealed partial class VaultViewModel(
internal string HostsHeading =>
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
/// <summary>Whether the host list under the heading is folded away.</summary>
[ObservableProperty]
private bool areHostsExpanded = true;
/// <summary>The SSH keys to show, unpushed local state included.</summary>
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
@@ -1153,6 +1183,40 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private HostGroupRowViewModel? selectedGroup;
/// <summary>
/// The group the hosts grid is narrowed to, or null for every host.
/// </summary>
/// <remarks>
/// <para>
/// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
/// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
/// an explicit control rather than a second press on the chosen card: the cards are a
/// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
/// application uses, and a <c>ListBox</c> does not unselect on a second click.
/// </para>
/// <para>
/// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
/// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
/// card means both, which is why the change handler assigns one from the other. They are not one
/// property because the phone sets <see cref="SelectedGroup"/> on its own account:
/// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
/// would have made opening that editor silently filter the phone's host list to the group being renamed.
/// </para>
/// </remarks>
[ObservableProperty]
private HostGroupRowViewModel? groupFilter;
/// <summary>Whether the grid is showing one group rather than every host.</summary>
internal bool IsFilteredByGroup => GroupFilter is not null;
/// <summary>Shows every host again.</summary>
[RelayCommand]
private void ClearGroupFilter()
{
GroupFilter = null;
SelectedGroup = null;
}
/// <summary>What the group name box holds, for both creating and renaming.</summary>
[ObservableProperty]
private string groupEditorLabel = string.Empty;
@@ -1424,6 +1488,8 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
private bool isEditing;
/// <summary>
@@ -1431,10 +1497,17 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// <para>
/// The desktop does not need this: its group editor is a bar under the group list that is always there,
/// and <see cref="EditingGroupId"/> is enough to tell adding from saving. The phone has no room for a
/// permanent bar, so its group editor is a card that replaces the list — and "is the card showing" is a
/// different question from "which group is being edited", because adding one has no id.
/// It was the phone's alone. The phone has no room for a permanent bar, so its group editor is a card
/// that replaces the list — and "is the card showing" is a different question from "which group is being
/// edited", because adding one has no id.
/// </para>
/// <para>
/// <b>The desktop sets it too now.</b> Its group editor used to be a bar under the group list that was
/// always on screen, which is why <see cref="EditingGroupId"/> was enough there. The hosts screen has no
/// such bar since it became a grid of cards: the group editor is a panel in the drawer, raised by
/// <c>+ NEW GROUP</c> or by <c>EDIT</c>, and "is it raised" is exactly this. So
/// <see cref="AGroupEditorIsInTheWay"/> now answers for both heads rather than being false on one of
/// them.
/// </para>
/// <para>
/// Held here rather than on the phone's own control so that the two heads cannot disagree about
@@ -1444,8 +1517,36 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
private bool isEditingGroup;
/// <summary>
/// Whether the hosts screen's right-hand drawer is open.
/// </summary>
/// <remarks>
/// <para>
/// The drawer is where everything that is about <em>one</em> thing lives: what a host is, the host
/// editor, and the group editor. The grid beside it is about all of them. Splitting the screen that way
/// is what let the 268-pixel host list go — the list was carrying both jobs, and neither at full size.
/// </para>
/// <para>
/// It stays open while a host deletion is in question, because the question is asked in the drawer and a
/// deletion does not clear the selection. There is no separate term for that here: a pending deletion
/// always has a selected host behind it.
/// </para>
/// <para>
/// It occupies a column of the hosts screen rather than floating over it, which is the occlusion rule
/// rather than a preference — see <c>MainWindow.axaml</c>. Nothing on this screen may be laid over the
/// terminal's rectangle, and a drawer that slid over the grid would be doing exactly that on the day
/// somebody moved the grid.
/// </para>
/// </remarks>
internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null;
/// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary>
internal bool IsShowingHostDetail => !IsEditing && !IsEditingGroup && SelectedHost is not null;
/// <summary>
/// Whether the add sheet is showing over the host list.
/// </summary>
@@ -1984,10 +2085,11 @@ internal sealed partial class VaultViewModel(
// ---- Connecting ----
/// <remarks>
/// Typed per connection, never persisted, and now only reached by a host bound to nothing. It stays because
/// not every password is worth storing — a one-off on a machine somebody will never open again, or one
/// they would rather this vault did not hold — and because a credential has to be created before it can be
/// bound, which means the first connection to a new host happens through this box.
/// Typed per connection, not persisted unless <see cref="RemembersConnectPassword"/> says otherwise, and
/// only reached by a host bound to nothing. It stays because not every password is worth storing — a
/// one-off on a machine somebody will never open again, or one they would rather this vault did not hold
/// — and because a credential has to be created before it can be bound, which means the first connection
/// to a new host happens through this box.
/// </remarks>
[ObservableProperty]
private string connectPassword = string.Empty;
@@ -2030,6 +2132,27 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry;
/// <summary>
/// Whether a password typed here should be kept, so this host stops asking for it.
/// </summary>
/// <remarks>
/// <para>
/// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a
/// password can live. The two-step chore it replaces — add a password under Keychain, then open the host
/// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand
/// means typing the secret into a second screen while the first one already has it.
/// </para>
/// <para>
/// <b>Off by default, and it stays a decision.</b> The reason a typed password exists at all is that not
/// every password belongs in a synchronised vault; remembering silently would move each of them there and
/// tell nobody. It also only takes effect once the handshake has succeeded — see
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
/// the one not worth keeping.
/// </para>
/// </remarks>
[ObservableProperty]
private bool remembersConnectPassword;
/// <summary>
/// Whether the selected host will want something typed into the password box.
/// </summary>
@@ -2593,6 +2716,7 @@ internal sealed partial class VaultViewModel(
private void RebuildGroups()
{
var selectedId = SelectedGroup?.EntityId;
var filteredId = GroupFilter?.EntityId;
Groups.Clear();
@@ -2608,6 +2732,19 @@ internal sealed partial class VaultViewModel(
// chose.
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
// Re-resolved by id for the reason the selection above is: every row object here is replaced on
// every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
// longer in the list — and the card the user could press to clear it would be a different object
// that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
// grid comes back to every host rather than to none.
//
// This assignment is a new row object whenever there is a filter at all, so it always fires
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
// the same thing — and it is left rather than dodged by writing the backing field, because writing
// the field would skip SelectedGroup and IsFilteredByGroup with it.
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
OnPropertyChanged(nameof(HasGroups));
}
@@ -2643,6 +2780,11 @@ internal sealed partial class VaultViewModel(
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
// above is a null that has to be overwritten rather than read.
SelectedSidebarRow = SelectedHost;
// The grid's empty state. Both of these are computed rather than stored, and neither has a change
// notification of its own — VisibleHosts raises collection changes, which is not the same event.
OnPropertyChanged(nameof(HasVisibleHosts));
OnPropertyChanged(nameof(NoVisibleHostsMessage));
}
/// <summary>
@@ -2842,6 +2984,14 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private bool Matches(HostRowViewModel row)
{
// The group cards, and they narrow before the box does — a host outside the chosen group is out
// whatever was typed. The two are deliberately not one control: the box is what you type when you
// know the name, and the cards are what you press when you do not.
if (GroupFilter is { } group && row.Host.GroupId != group.EntityId)
{
return false;
}
var filter = HostFilter.Trim();
if (filter.Length == 0)
@@ -3429,9 +3579,10 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>Folds the host list away, or brings it back.</summary>
[RelayCommand]
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
// AreHostsExpanded and ToggleHosts were here, and they went with the control that used them. They folded
// the sidebar's whole host list away under its one heading — an affordance that existed because that
// list was 268 pixels wide and the editor beneath it needed the room. The grid has neither the heading
// nor the problem. Folding one *group* away is a different thing and is still here: see ToggleGroup.
/// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
/// <remarks>
@@ -3590,9 +3741,9 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
/// It answers only for the phone, where the group editor is a raised card. On the desktop the bar is
/// always present and <see cref="IsEditingGroup"/> is never set by anything the user can reach, so this
/// is false there and nothing is refused.
/// It answered only for the phone while the desktop's group editor was a bar that was always present.
/// Both heads raise a card now — the desktop's is the panel in the hosts drawer — so this refuses on
/// both, which is what it was always meant to do.
/// </remarks>
private bool AGroupEditorIsInTheWay()
{
@@ -4950,7 +5101,7 @@ internal sealed partial class VaultViewModel(
}
await ConnectToAsync(
new ConnectionTarget(row.Label, row.EntityId, row.Host.Hostname, row.Resolved.Port.Value),
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
authentication,
cancellationToken).ConfigureAwait(true);
}
@@ -5007,7 +5158,6 @@ internal sealed partial class VaultViewModel(
// the log entry this also names.
new ConnectionTarget(
$"{endpoint.Username}@{endpoint.Hostname}",
HostId: null,
endpoint.Hostname,
endpoint.Port),
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
@@ -5451,6 +5601,102 @@ internal sealed partial class VaultViewModel(
sessionId,
target.Label,
Dialled(target, authentication)));
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear.
//
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
// screen it is typed on says so.
if (target.Row is { } row)
{
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
}
}
/// <summary>
/// Turns the password that just worked into a keychain credential bound to this host.
/// </summary>
/// <remarks>
/// <para>
/// <b>Only after a handshake the remote accepted.</b> Storing a password the moment it is typed would
/// bind whatever was in the box — including the typo that is about to be refused — and the host would
/// then stop asking, leaving a machine that cannot be connected to until somebody works out that the
/// keychain is where the wrong password now lives.
/// </para>
/// <para>
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
/// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type
/// exists — bound to the other nineteen machines that share the account. See <see cref="HostSecret"/>
/// on why the binding is an id and not a copy.
/// </para>
/// <para>
/// The credential carries no username of its own, so it keeps taking the host's — which is what the
/// connection that just succeeded did. Copying the resolved username into it would pin whatever the
/// group happened to say at this moment, and quietly stop following the group afterwards.
/// </para>
/// <para>
/// Every failure is reported and swallowed. The caller's <c>catch</c> blocks describe a connection that
/// did not happen, and this one did: a vault write that fails here must not tell the user their terminal
/// was abandoned, and a cancellation must not report it as cancelled.
/// </para>
/// </remarks>
private async Task RememberTypedPasswordAsync(
HostRowViewModel row,
HostAuthentication authentication,
CancellationToken cancellationToken)
{
// The password as dialled, not as the box currently reads: the two can differ by now, because a
// handshake takes time and the box stays typeable throughout it.
if (!RemembersConnectPassword
|| row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword
|| authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed)
{
return;
}
if (row.IsReadOnly)
{
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
return;
}
var credential = new CredentialSecret { Label = row.Label, Password = typed.Password };
try
{
var credentialId = await session.Credentials
.CreateAsync(row.VaultId, credential, cancellationToken)
.ConfigureAwait(true);
// Into the same vault as the host, deliberately: a credential in the personal vault bound to a
// team's host is a binding every other member can see and none of them can resolve.
await session.Hosts
.UpdateAsync(
row.VaultId,
row.EntityId,
row.Host with { CredentialId = credentialId, AsksForPassword = null },
cancellationToken)
.ConfigureAwait(true);
}
catch (Exception exception)
{
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
return;
}
// Cleared together. The box is about to disappear — the host answers "credential" now — and a tick
// left behind would apply to the next host somebody selects.
RemembersConnectPassword = false;
ConnectPassword = string.Empty;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
+ "will not be asked for again.";
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>The address as actually dialled.</summary>
@@ -5551,17 +5797,36 @@ internal sealed partial class VaultViewModel(
/// The machine a connection is being made to, however it was named.
/// </summary>
/// <param name="Label">What to call it — a keychain host's alias, or what was typed.</param>
/// <param name="HostId">The keychain item, or null for somewhere that is not in it.</param>
/// <param name="Hostname">The address to dial.</param>
/// <param name="Port">The port to dial, already resolved through any group.</param>
/// <param name="Row">
/// The keychain host this came from, or null for a machine that is not in the keychain.
/// </param>
/// <remarks>
/// <para>
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
/// below the resolution needs four facts and a row carries dozens; taking the four is what let a
/// below the resolution needs three facts and a row carries dozens; taking the three is what let a
/// connection to an address that has no keychain item share the ladder rather than grow a second one.
/// <see cref="ConnectionRecorder.Record"/> and <c>Identify</c> both take a nullable id already, so the
/// log has always been able to hold a connection with no item behind it.
/// </para>
/// <para>
/// <b>The row is still here, and only two things read it.</b> Both are things that can only be done to
/// a keychain item rather than to an address: identifying the log entry, and binding the password that
/// just worked. Null is not missing data — it is the whole of what makes the manual path different, and
/// having it here rather than as a separate id keeps "was this a keychain host" one question with one
/// answer.
/// </para>
/// </remarks>
private sealed record ConnectionTarget(string Label, Guid? HostId, string Hostname, int Port);
private sealed record ConnectionTarget(
string Label,
string Hostname,
int Port,
HostRowViewModel? Row = null)
{
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
internal Guid? HostId => Row?.EntityId;
}
/// <summary>
/// Works out how a host authenticates, or says why it cannot.
@@ -6200,6 +6465,10 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
OnPropertyChanged(nameof(ShowsConnectBar));
// The drawer opens on a selection and closes when there is none, so both of these move with it.
OnPropertyChanged(nameof(IsDrawerOpen));
OnPropertyChanged(nameof(IsShowingHostDetail));
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
// other.
@@ -6245,6 +6514,20 @@ internal sealed partial class VaultViewModel(
DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
}
/// <remarks>
/// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
/// property. Assigning the same value again is a no-op, so this and
/// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
/// </remarks>
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
{
SelectedGroup = value;
OnPropertyChanged(nameof(IsFilteredByGroup));
RebuildVisibleHosts();
}
partial void OnPendingDeletionChanged(DeletionRequest? value)
{
OnPropertyChanged(nameof(IsConfirmingDeletion));
@@ -43,10 +43,15 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(TeamSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamSummary>))]
[JsonSerializable(typeof(CreateTeamRequest))]
[JsonSerializable(typeof(UpdateTeamRequest))]
[JsonSerializable(typeof(TransferTeamOwnershipRequest))]
[JsonSerializable(typeof(TeamMemberSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
[JsonSerializable(typeof(AddTeamMemberRequest))]
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
[JsonSerializable(typeof(CreateTeamInvitationRequest))]
[JsonSerializable(typeof(TeamInvitationSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
+25 -1
View File
@@ -102,10 +102,34 @@ public static class ProblemCodes
/// </summary>
/// <remarks>
/// Refused rather than allowed, because a team with no owner has nobody who can appoint one —
/// and the only route back would be an operator editing the database by hand.
/// and the only route back would be an operator editing the database by hand. The way past it is
/// <c>POST /api/v1/teams/{teamId}/owner</c>, which moves ownership and the outgoing owner's
/// demotion in one transaction; a client that gets this code can offer that.
/// </remarks>
public const string LastTeamOwner = "last-team-owner";
/// <summary>
/// A team cannot be archived while it still owns vaults.
/// </summary>
/// <remarks>
/// Its own code because the remedy is neither "fix what you typed" nor "pick another value": it is
/// to deal with the vaults first. Archiving anyway would hide vaults from every member including
/// the ones holding keys to them, and this product has no way to delete a vault, so the refusal is
/// the honest end of that road rather than a step on it.
/// </remarks>
public const string TeamNotEmpty = "team-not-empty";
/// <summary>
/// An invitation was rejected: a malformed address, an unknown or ownership role, an expiry the
/// server will not issue, or an address that already has an account here.
/// </summary>
/// <remarks>
/// Separate from <see cref="InvalidTeam"/> because the most common cause has its own remedy that a
/// client can act on — an address that already has an account should be added through the
/// directory instead, which is the path that shows the caller the key they are about to trust.
/// </remarks>
public const string InvalidTeamInvitation = "invalid-team-invitation";
/// <summary>
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
+81 -3
View File
@@ -9,6 +9,7 @@ const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeamInvitation = "invalid-team-invitation" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
@@ -16,6 +17,7 @@ const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large"
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string!
const DodoSSH.Contracts.ProblemCodes.TeamNotEmpty = "team-not-empty" -> string!
const DodoSSH.Contracts.ProblemCodes.TeamSlugTaken = "team-slug-taken" -> string!
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
@@ -35,6 +37,17 @@ DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest
DodoSSH.Contracts.CreateTeamInvitationRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamInvitationRequest!
DodoSSH.Contracts.CreateTeamInvitationRequest.CreateTeamInvitationRequest(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.get -> string!
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(DodoSSH.Contracts.CreateTeamInvitationRequest? other) -> bool
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.get -> System.Guid
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamRequest
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
@@ -433,12 +446,12 @@ DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ActivityLogEntry = 12 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ConnectionLogEntry = 11 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Credential = 2 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Host = 1 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostCredential = 7 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostGroup = 4 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostTag = 6 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.KnownHostKey = 10 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType
@@ -554,6 +567,33 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
DodoSSH.Contracts.SyncPushResult.Version.get -> int?
DodoSSH.Contracts.SyncPushResult.Version.init -> void
DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Accepted = 2 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Expired = 4 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Pending = 1 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Revoked = 3 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Unspecified = 0 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary
DodoSSH.Contracts.TeamInvitationSummary.<Clone>$() -> DodoSSH.Contracts.TeamInvitationSummary!
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamInvitationState State, out System.Guid InvitedByUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset ExpiresAt, out System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamInvitationSummary.Email.get -> string!
DodoSSH.Contracts.TeamInvitationSummary.Email.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Equals(DodoSSH.Contracts.TeamInvitationSummary? other) -> bool
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamInvitationSummary.Role.init -> void
DodoSSH.Contracts.TeamInvitationSummary.State.get -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary.State.init -> void
DodoSSH.Contracts.TeamInvitationSummary.TeamInvitationSummary(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamInvitationState State, System.Guid InvitedByUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset ExpiresAt, System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
@@ -567,7 +607,7 @@ DodoSSH.Contracts.TeamMemberStatus.Revoked = 3 -> DodoSSH.Contracts.TeamMemberSt
DodoSSH.Contracts.TeamMemberStatus.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary
DodoSSH.Contracts.TeamMemberSummary.<Clone>$() -> DodoSSH.Contracts.TeamMemberSummary!
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt, out System.DateTimeOffset? LastActiveAt) -> void
DodoSSH.Contracts.TeamMemberSummary.DisplayName.get -> string?
DodoSSH.Contracts.TeamMemberSummary.DisplayName.init -> void
DodoSSH.Contracts.TeamMemberSummary.Email.get -> string?
@@ -577,11 +617,13 @@ DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.get -> bool
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.init -> void
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.init -> void
DodoSSH.Contracts.TeamMemberSummary.LastActiveAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamMemberSummary.LastActiveAt.init -> void
DodoSSH.Contracts.TeamMemberSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberSummary.Role.init -> void
DodoSSH.Contracts.TeamMemberSummary.Status.get -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary.Status.init -> void
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt, System.DateTimeOffset? LastActiveAt = null) -> void
DodoSSH.Contracts.TeamMemberSummary.UserId.get -> System.Guid
DodoSSH.Contracts.TeamMemberSummary.UserId.init -> void
DodoSSH.Contracts.TeamSummary
@@ -605,6 +647,22 @@ DodoSSH.Contracts.TeamSummary.TeamId.init -> void
DodoSSH.Contracts.TeamSummary.TeamSummary(System.Guid TeamId, string! Name, string! Slug, string? Description, DodoSSH.Contracts.TeamMemberRole Role, int MemberCount, int VaultCount, System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.TeamSummary.VaultCount.get -> int
DodoSSH.Contracts.TeamSummary.VaultCount.init -> void
DodoSSH.Contracts.TransferTeamOwnershipRequest
DodoSSH.Contracts.TransferTeamOwnershipRequest.<Clone>$() -> DodoSSH.Contracts.TransferTeamOwnershipRequest!
DodoSSH.Contracts.TransferTeamOwnershipRequest.Deconstruct(out System.Guid UserId) -> void
DodoSSH.Contracts.TransferTeamOwnershipRequest.Equals(DodoSSH.Contracts.TransferTeamOwnershipRequest? other) -> bool
DodoSSH.Contracts.TransferTeamOwnershipRequest.TransferTeamOwnershipRequest(System.Guid UserId) -> void
DodoSSH.Contracts.TransferTeamOwnershipRequest.UserId.get -> System.Guid
DodoSSH.Contracts.TransferTeamOwnershipRequest.UserId.init -> void
DodoSSH.Contracts.UpdateTeamRequest
DodoSSH.Contracts.UpdateTeamRequest.<Clone>$() -> DodoSSH.Contracts.UpdateTeamRequest!
DodoSSH.Contracts.UpdateTeamRequest.Deconstruct(out string! Name, out string? Description) -> void
DodoSSH.Contracts.UpdateTeamRequest.Description.get -> string?
DodoSSH.Contracts.UpdateTeamRequest.Description.init -> void
DodoSSH.Contracts.UpdateTeamRequest.Equals(DodoSSH.Contracts.UpdateTeamRequest? other) -> bool
DodoSSH.Contracts.UpdateTeamRequest.Name.get -> string!
DodoSSH.Contracts.UpdateTeamRequest.Name.init -> void
DodoSSH.Contracts.UpdateTeamRequest.UpdateTeamRequest(string! Name, string? Description) -> void
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
@@ -671,6 +729,9 @@ override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamInvitationRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamInvitationRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
@@ -761,12 +822,21 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
override DodoSSH.Contracts.TeamInvitationSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamInvitationSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamInvitationSummary.ToString() -> string!
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
override DodoSSH.Contracts.TeamSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamSummary.ToString() -> string!
override DodoSSH.Contracts.TransferTeamOwnershipRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.TransferTeamOwnershipRequest.GetHashCode() -> int
override DodoSSH.Contracts.TransferTeamOwnershipRequest.ToString() -> string!
override DodoSSH.Contracts.UpdateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.UpdateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.UpdateTeamRequest.ToString() -> string!
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
@@ -780,6 +850,8 @@ static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddT
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator !=(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator ==(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
@@ -843,10 +915,16 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator !=(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator ==(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator ==(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
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.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
+157 -8
View File
@@ -49,10 +49,18 @@ public enum TeamMemberStatus
/// Invited but not yet accepted.
/// </summary>
/// <remarks>
/// Nothing writes this today. An invitation needs a token with a lifetime and an outbound mail
/// path, and this server has neither — so a member is added by looking their account up in the
/// directory, which requires that they have signed in here at least once. Retained because the
/// column exists and a client must not fail on a value a later server may send.
/// <para>
/// Still nothing writes this, and invitations shipping is the reason rather than an exception to
/// it. A membership names an account: <c>team_membership.user_id</c> is not nullable and carries a
/// foreign key, so somebody who has never signed in has nothing for that row to point at. An
/// invitation is therefore its own record against an <em>address</em>
/// (<see cref="TeamInvitationSummary"/>), and it becomes a membership at
/// <see cref="Active"/> the moment an account with that address first signs in.
/// </para>
/// <para>
/// Retained because the column exists and a client must not fail on a value a later server may
/// send — a server that grew a second invitation model would use it.
/// </para>
/// </remarks>
Invited = 1,
@@ -124,11 +132,51 @@ public sealed record CreateTeamRequest(
string Slug,
string? Description);
/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// <para>
/// The slug is not here and cannot be changed. It is what a URL, an operator's query and any bookmark
/// name, and it is unique only among <em>live</em> teams — so a rename could take a slug an archived
/// team is still holding on to, and the archived one could then never be brought back. Renaming the
/// display name is the operation people actually want; renaming the identifier is a migration.
/// </para>
/// <para>
/// A whole replacement rather than a patch: both fields are always sent, so clearing a description is
/// sending null rather than a distinct verb. There is nowhere to record <em>when</em> a team was last
/// renamed — <c>team</c> has no updated-at column — so no client can show "edited", and this contract
/// does not pretend one can.
/// </para>
/// </remarks>
/// <param name="Name">Display name. Required.</param>
/// <param name="Description">Optional description. Null clears it.</param>
public sealed record UpdateTeamRequest(string Name, string? Description);
/// <summary>Hands a team's ownership to another member.</summary>
/// <remarks>
/// <para>
/// Its own operation rather than a role change, because it is two writes that must not be separable:
/// the recipient becomes owner and the outgoing owner becomes an admin, in one transaction. Ownership
/// is sole, so doing it as two role changes would leave the team either briefly ownerless or briefly
/// owned twice, and <see cref="ChangeTeamMemberRoleRequest"/> refuses
/// <see cref="TeamMemberRole.Owner"/> outright for exactly that reason.
/// </para>
/// <para>
/// The outgoing owner is demoted to <see cref="TeamMemberRole.Admin"/> rather than removed. Removing
/// them would revoke their vault key grants and flag every team vault for rekey, which is a far larger
/// act than the one being asked for — and somebody handing over a team is usually staying in it.
/// </para>
/// </remarks>
/// <param name="UserId">
/// The member to hand it to. Must already be an active member: adding somebody and making them owner
/// in one step would let an address typed once take the team.
/// </param>
public sealed record TransferTeamOwnershipRequest(Guid UserId);
/// <summary>One member of a team.</summary>
/// <remarks>
/// Carries no last-active time and no avatar. <c>UserAccount.LastSeenAtUtc</c> is written at
/// provisioning and at enrollment and at no other point, so a column labelled "last active" would
/// be reporting something else entirely; and no picture is stored anywhere.
/// Carries no avatar, because no picture is stored anywhere. It does now carry a last-active time —
/// see <see cref="LastActiveAt"/>, which names precisely what it measures, because the useful version
/// of that column and the misleading one differ only in what the server bothered to write down.
/// </remarks>
/// <param name="UserId">The member.</param>
/// <param name="Email">Email, for display.</param>
@@ -141,6 +189,15 @@ public sealed record CreateTeamRequest(
/// rather than offering a share that would fail.
/// </param>
/// <param name="JoinedAt">When the membership became active.</param>
/// <param name="LastActiveAt">
/// When this account last made an authenticated request, or null if it never has.
/// <para>
/// It is deliberately coarse. The server records it at most once per account per hour, so a value an
/// hour old means "recently" rather than "at that instant" — which is the granularity the question is
/// actually asked at, and a far smaller thing to know about a colleague than a per-request timeline
/// would be. Displaying it to the minute would be reading precision into it that is not there.
/// </para>
/// </param>
public sealed record TeamMemberSummary(
Guid UserId,
string? Email,
@@ -148,7 +205,8 @@ public sealed record TeamMemberSummary(
TeamMemberRole Role,
TeamMemberStatus Status,
bool IsEnrolled,
DateTimeOffset? JoinedAt);
DateTimeOffset? JoinedAt,
DateTimeOffset? LastActiveAt = null);
/// <summary>Adds a member to a team.</summary>
/// <remarks>
@@ -165,6 +223,97 @@ public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
/// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
/// <summary>What has become of an invitation.</summary>
/// <remarks>
/// Derived from the invitation's own timestamps rather than stored, so — unlike every other enum in
/// this file — it has no <c>DodoSSH.Domain</c> twin and no numbering to keep in step. That is the
/// point of computing it: <see cref="Expired"/> is a fact about the clock, and a stored state would
/// have to be swept by something that remembered to run.
/// </remarks>
public enum TeamInvitationState
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Waiting. It becomes a membership when an account with this address signs in.</summary>
Pending = 1,
/// <summary>Taken up. The address signed in and is now a member.</summary>
Accepted = 2,
/// <summary>Withdrawn before it was taken up.</summary>
Revoked = 3,
/// <summary>Its lifetime ran out. It will not become a membership.</summary>
Expired = 4,
}
/// <summary>Invites an address that has no account here yet.</summary>
/// <remarks>
/// <para>
/// <b>By email, where <see cref="AddTeamMemberRequest"/> is by user id.</b> Adding a member resolves an
/// address through the directory first, so the caller sees the public key they are about to wrap a
/// vault to. An invitation cannot do that — there may be no account and therefore no key — so it grants
/// nothing readable and is never a step towards sharing.
/// </para>
/// <para>
/// <b>An address that already has an account is accepted rather than refused</b>, and only an address
/// already belonging to a member of this team is turned away. Refusing on the strength of an account
/// existing would make this endpoint an oracle for which addresses have accounts here, answerable by
/// anybody willing to create a team first — and it would be answering a question the caller did not
/// ask. Whether the account exists changes only how soon the invitation is taken up: an existing one
/// picks it up on its next request.
/// </para>
/// <para>
/// <b>There is no token and nothing is sent.</b> This server has no outbound mail path, so the
/// invitation is not a link: it is a standing instruction that the next account to sign in with this
/// address joins the team. Telling them to sign in is the caller's job, over a channel this server
/// does not carry. That also means the address has to be one the identity provider will assert and
/// mark verified — an unverified email is refused at claim time, because an invitation that anybody
/// could take by naming somebody else's address is a way in.
/// </para>
/// </remarks>
/// <param name="InvitationId">
/// Client-generated UUIDv7, for the reason a team id is client-generated: a create whose response was
/// lost can be re-sent verbatim rather than leaving two invitations to the same address.
/// </param>
/// <param name="Email">The address to invite. Matched case-insensitively.</param>
/// <param name="Role">
/// Role to grant on arrival. May not be <see cref="TeamMemberRole.Owner"/> — ownership is sole and is
/// handed over deliberately, never conferred by an address signing in.
/// </param>
public sealed record CreateTeamInvitationRequest(
Guid InvitationId,
string Email,
TeamMemberRole Role);
/// <summary>One invitation, as the teams interface sees it.</summary>
/// <remarks>
/// The address is in plaintext here, as it is on <see cref="TeamMemberSummary"/>. It is readable by
/// the team's members, who are the people it concerns; the server stores it in plaintext either way
/// and docs/crypto.md §10 already records that membership metadata is not encrypted.
/// </remarks>
/// <param name="InvitationId">The invitation.</param>
/// <param name="Email">The address invited.</param>
/// <param name="Role">The role it will grant.</param>
/// <param name="State">What has become of it.</param>
/// <param name="InvitedByUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="ExpiresAt">
/// When it stops being claimable. An invitation that never expired would be a standing offer on an
/// address somebody may hand on or lose.
/// </param>
/// <param name="AcceptedAt">When an account with this address signed in and took it up, if one has.</param>
public sealed record TeamInvitationSummary(
Guid InvitationId,
string Email,
TeamMemberRole Role,
TeamInvitationState State,
Guid InvitedByUserId,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
DateTimeOffset? AcceptedAt);
/// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary>
+65
View File
@@ -33,6 +33,9 @@ public sealed class Team
/// <summary>Members.</summary>
public ICollection<TeamMembership> Memberships { get; } = [];
/// <summary>Invitations to addresses that have no account here yet.</summary>
public ICollection<TeamInvitation> Invitations { get; } = [];
}
/// <summary>
@@ -77,3 +80,65 @@ public sealed class TeamMembership
/// <summary>Soft-delete marker.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
}
/// <summary>
/// A standing offer of membership to an email address that has no account here yet.
/// </summary>
/// <remarks>
/// <para>
/// <b>Its own table rather than a <see cref="TeamMembership"/> with
/// <see cref="MembershipStatus.Invited"/>.</b> A membership names an account —
/// <c>team_membership.user_id</c> is not nullable and carries a foreign key to
/// <see cref="UserAccount"/> — so an invitee who has never signed in has nothing for that row to
/// point at. Widening that column would make the unique index on (team, user) meaningless, because
/// PostgreSQL counts every NULL as distinct, and would silently change what every
/// <c>m.UserId == user.Id</c> query in the server means.
/// </para>
/// <para>
/// <b>There is no token.</b> Nothing is sent, because this server has no outbound mail path; the row
/// is an instruction to the sign-in path rather than a secret somebody presents. That is why it is
/// keyed on the address and why the address has to be one the identity provider marks verified before
/// the claim is honoured — an unclaimable invitation is an inconvenience, but one claimable by
/// anybody who can assert an address is a way into the team.
/// </para>
/// <para>
/// Revoked and accepted rows are retained rather than deleted, as <see cref="VaultKeyGrant"/> is and
/// for the same reason: the uniqueness that matters is among <em>pending</em> invitations, and the
/// history of who invited whom stays resolvable.
/// </para>
/// </remarks>
public sealed class TeamInvitation
{
/// <summary>Primary key.</summary>
public Guid Id { get; set; }
/// <summary>The team.</summary>
public Guid TeamId { get; set; }
/// <summary>The team.</summary>
public Team? Team { get; set; }
/// <summary>The address invited. Case-insensitive.</summary>
public string Email { get; set; } = string.Empty;
/// <summary>Role the membership will carry when it is claimed.</summary>
public TeamRole Role { get; set; }
/// <summary>Who issued it.</summary>
public Guid InvitedByUserId { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>When it stops being claimable.</summary>
public DateTimeOffset ExpiresAtUtc { get; set; }
/// <summary>When an account with this address signed in and took it up.</summary>
public DateTimeOffset? AcceptedAtUtc { get; set; }
/// <summary>Which account took it up.</summary>
public Guid? AcceptedByUserId { get; set; }
/// <summary>Revocation timestamp.</summary>
public DateTimeOffset? RevokedAtUtc { get; set; }
}
@@ -62,6 +62,47 @@ public sealed class TeamMembershipConfiguration : IEntityTypeConfiguration<TeamM
}
}
/// <summary>Maps <see cref="TeamInvitation"/>.</summary>
public sealed class TeamInvitationConfiguration : IEntityTypeConfiguration<TeamInvitation>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TeamInvitation> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("team_invitation");
builder.HasKey(i => i.Id);
builder.Property(i => i.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
// citext, matching user_account.email: the claim at sign-in compares an address the identity
// provider chose the casing of against one a person typed, and lower() on both sides of that
// is a rule somebody eventually forgets on one side.
builder.Property(i => i.Email).HasColumnType("citext").HasMaxLength(320).IsRequired();
builder.Property(i => i.Role).HasConversion<int>();
builder.HasOne(i => i.Team)
.WithMany(t => t.Invitations)
.HasForeignKey(i => i.TeamId)
.OnDelete(DeleteBehavior.Cascade);
// One live invitation per address per team. Filtered on the two tombstones rather than on a
// deletion marker, as vault_key_grant is: an accepted or withdrawn invitation is kept, and
// re-inviting an address whose invitation lapsed has to be possible.
//
// Expiry is deliberately not in this predicate. A partial index predicate must be IMMUTABLE,
// so now() cannot appear in one; an expired invitation therefore still holds the slot, and
// the service treats replacing one as a revoke-and-reissue rather than a second insert.
builder.HasIndex(i => new { i.TeamId, i.Email })
.IsUnique()
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
// The claim at sign-in knows the address and nothing else — it is looking for every team that
// invited this person, across all of them — so the address is the hot direction here.
builder.HasIndex(i => i.Email);
}
}
/// <summary>Maps <see cref="Vault"/>.</summary>
public sealed class VaultConfiguration : IEntityTypeConfiguration<Vault>
{
@@ -45,6 +45,9 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>Team memberships.</summary>
public DbSet<TeamMembership> TeamMemberships => Set<TeamMembership>();
/// <summary>Invitations to addresses with no account here yet.</summary>
public DbSet<TeamInvitation> TeamInvitations => Set<TeamInvitation>();
/// <summary>Vaults.</summary>
public DbSet<Vault> Vaults => Set<Vault>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTeamInvitation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "team_invitation",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
team_id = table.Column<Guid>(type: "uuid", nullable: false),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
invited_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
expires_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
accepted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
accepted_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
revoked_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_team_invitation", x => x.id);
table.ForeignKey(
name: "fk_team_invitation_team_team_id",
column: x => x.team_id,
principalSchema: "dodo",
principalTable: "team",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_team_invitation_email",
schema: "dodo",
table: "team_invitation",
column: "email");
migrationBuilder.CreateIndex(
name: "ix_team_invitation_team_id_email",
schema: "dodo",
table: "team_invitation",
columns: new[] { "team_id", "email" },
unique: true,
filter: "accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "team_invitation",
schema: "dodo");
}
}
}
@@ -316,6 +316,70 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("team", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset?>("AcceptedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("accepted_at_utc");
b.Property<Guid?>("AcceptedByUserId")
.HasColumnType("uuid")
.HasColumnName("accepted_by_user_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("citext")
.HasColumnName("email");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at_utc");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid")
.HasColumnName("invited_by_user_id");
b.Property<DateTimeOffset?>("RevokedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at_utc");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("TeamId")
.HasColumnType("uuid")
.HasColumnName("team_id");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_team_invitation");
b.HasIndex("Email")
.HasDatabaseName("ix_team_invitation_email");
b.HasIndex("TeamId", "Email")
.IsUnique()
.HasDatabaseName("ix_team_invitation_team_id_email")
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
b.ToTable("team_invitation", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{
b.Property<Guid>("Id")
@@ -1580,6 +1644,18 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.HasOne("DodoSSH.Domain.Team", "Team")
.WithMany("Invitations")
.HasForeignKey("TeamId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_team_invitation_team_team_id");
b.Navigation("Team");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{
b.HasOne("DodoSSH.Domain.Team", "Team")
@@ -1782,6 +1858,8 @@ namespace DodoSSH.Infrastructure.Migrations
modelBuilder.Entity("DodoSSH.Domain.Team", b =>
{
b.Navigation("Invitations");
b.Navigation("Memberships");
});
+8 -2
View File
@@ -81,12 +81,18 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
}
/// <summary>Creates a client carrying a valid token for the given subject.</summary>
public HttpClient CreateClientFor(string subject, string? email = null)
/// <remarks>
/// <paramref name="emailVerified"/> defaults to true, which is what a provider asserts about an
/// address it has checked and what every ordinary sign-in means. Passing false mints a token that
/// carries the address and no <c>email_verified</c> claim — the shape a team invitation has to
/// refuse, and the only way a test can present it.
/// </remarks>
public HttpClient CreateClientFor(string subject, string? email = null, bool emailVerified = true)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
IdentityProvider.MintToken(subject, email));
IdentityProvider.MintToken(subject, email, emailVerified: emailVerified));
return client;
}
+15
View File
@@ -33,6 +33,21 @@ internal static class ContractJson
return client.PostAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken);
}
/// <remarks>
/// The same options as <see cref="PostContractAsync"/>, and here for the same reason rather than
/// for symmetry: a PUT that serialised its enums differently from a POST would let a wire form the
/// specification never described reach exactly the endpoints nothing else covers.
/// </remarks>
internal static Task<HttpResponseMessage> PutContractAsync<T>(
this HttpClient client,
string url,
T value)
{
ArgumentNullException.ThrowIfNull(client);
return client.PutAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken);
}
internal static Task<T?> ReadContractAsync<T>(this HttpContent content)
{
ArgumentNullException.ThrowIfNull(content);
@@ -74,6 +74,23 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
// Administering a team you are already in, and none of it moves key material — so Authenticated
// for the same reason the membership routes above are. Two of the three are gated harder inside
// the handler than this table can show: archiving and handing the team over check for the owner
// rather than for an admin, because an admin the owner promoted must not be able to take the
// team from them. See TeamAccess.IsOwner.
"PUT /api/v1/teams/{teamId:guid} name=UpdateTeam tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False",
// Authenticated, and pointedly not Enrolled. An invitation names an address that may have no
// account at all and certainly holds no key; gating these on Enrolled would be demanding a key
// of the one participant the feature exists for. Membership is not readability — somebody still
// has to wrap the vault key afterwards — so no key is involved on either side.
"GET /api/v1/teams/{teamId:guid}/invitations name=ListTeamInvitations tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/invitations name=CreateTeamInvitation tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/invitations/{invitationId:guid} name=RevokeTeamInvitation tags=Teams policies=Authenticated anon=False",
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
// in it, and neither is reachable without a key of one's own.
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
@@ -67,13 +67,22 @@ public sealed class StubIdentityProvider : IDisposable
/// <param name="audience">Override the audience, to test rejection.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param>
/// <param name="emailVerified">
/// Whether the token asserts <c>email_verified</c> over <paramref name="email"/>. True by
/// default, because that is what a provider says about an address it has checked and every
/// existing caller means a genuine sign-in. False omits the claim outright rather than sending
/// <c>false</c>: an absent claim is what a provider that was never configured to send one
/// produces, and it is the case the server must not read as verified. The claim is emitted only
/// alongside an address, since on its own it asserts nothing about anybody.
/// </param>
public string MintToken(
string subject,
string? email = null,
string? name = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null)
DateTime? expires = null,
bool emailVerified = true)
{
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
@@ -85,6 +94,17 @@ public sealed class StubIdentityProvider : IDisposable
if (email is not null)
{
claims.Add(new System.Security.Claims.Claim("email", email));
if (emailVerified)
{
// Boolean, so the handler writes a JSON boolean rather than a quoted string. A real
// provider sends one, and the server parses rather than compares — so a test that
// sent a string would agree with an implementation that only handled strings.
claims.Add(new System.Security.Claims.Claim(
"email_verified",
"true",
System.Security.Claims.ClaimValueTypes.Boolean));
}
}
if (name is not null)
+753 -50
View File
@@ -5,7 +5,7 @@ using DodoSSH.Contracts;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Teams, membership and the vault key grants that make a team vault readable.
/// Teams, membership, invitations and the vault key grants that make a team vault readable.
/// </summary>
/// <remarks>
/// <para>
@@ -21,13 +21,23 @@ namespace DodoSSH.Api.Tests;
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
/// outside.
/// </para>
/// <para>
/// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible
/// kind. An admin who can archive a team or hand it away is an admin who can take it from the person
/// who promoted them, and nothing about the response would say so. An invitation claimed on an address
/// the identity provider never vouched for is a way into somebody else's team, and every other part of
/// that request succeeds. Neither has a symptom; each has a test.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class TeamEndpointTests(ApiFixture fixture)
{
private const string TeamsUrl = "/api/v1/teams";
private const string MeUrl = "/api/v1/me";
private const string EnrollUrl = "/api/v1/me/enrollment";
// ---- Creating and listing ----
[Fact]
public async Task CreatingATeam_MakesTheCallerItsOwner()
{
@@ -79,11 +89,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var response = await client.PostContractAsync(
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.TeamSlugTaken);
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamSlugTaken);
}
/// <remarks>
@@ -98,18 +104,325 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var team = await CreateTeamAsync(owner, "Private");
var real = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
var invented = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{Guid.CreateVersion7()}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
var real = await GetAsync(stranger, MembersUrl(team.TeamId));
var invented = await GetAsync(stranger, MembersUrl(Guid.CreateVersion7()));
real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
invented.StatusCode.ShouldBe(real.StatusCode);
}
// ---- Renaming ----
/// <remarks>
/// The rename is read back from the list rather than from the response body, because the list is
/// what a member's screen is built from and it is assembled by different code. The slug is asserted
/// unchanged because it is unique only among live teams: a rename that moved it could take a slug an
/// archived team still holds, and that archived team could then never be brought back.
/// </remarks>
[Fact]
public async Task RenamingATeam_ChangesTheListedNameAndLeavesTheSlugAlone()
{
var owner = await EnrolledClientAsync("rename-owner");
var team = await CreateTeamAsync(owner, "Before");
var response = await owner.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("After", "A description it did not have."));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
var renamed = listed.Where(row => row.TeamId == team.TeamId).ShouldHaveSingleItem();
renamed.Name.ShouldBe("After");
renamed.Description.ShouldBe("A description it did not have.");
renamed.Slug.ShouldBe(team.Slug);
}
/// <remarks>
/// Null clears the description rather than leaving it alone. A PUT that treated an absent value as
/// "no change" would make a cleared description impossible to express at all, since there is no
/// other verb for it.
/// </remarks>
[Fact]
public async Task RenamingWithoutADescription_ClearsTheOneThatWasThere()
{
var owner = await EnrolledClientAsync("rename-clear-owner");
var team = await PostAsync<CreateTeamRequest, TeamSummary>(
owner,
TeamsUrl,
new CreateTeamRequest(
Guid.CreateVersion7(), "Described", $"team-{Guid.CreateVersion7():N}", "Something."));
team.Description.ShouldBe("Something.");
var response = await owner.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("Described", null));
response.EnsureSuccessStatusCode();
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
listed.Where(row => row.TeamId == team.TeamId)
.ShouldHaveSingleItem()
.Description.ShouldBeNull();
}
/// <remarks>
/// A member is refused with 403 rather than 404: the team is visible to them, so naming the reason
/// leaks nothing and "you are not an admin" is a more useful answer than "no such team".
/// </remarks>
[Fact]
public async Task APlainMember_CanRenameNothingAndCanNeitherArchiveNorHandOverTheTeam()
{
var owner = await EnrolledClientAsync("member-limits-owner", "mlowner@example.com");
var member = await EnrolledClientAsync("member-limits-member", "mlmember@example.com");
var team = await CreateTeamAsync(owner, "Limits");
var entry = await LookupAsync(owner, "mlmember@example.com");
var memberMe = await ReadAsync<MeResponse>(member, MeUrl);
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var renamed = await member.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("Theirs now", null));
await ShouldBeProblemAsync(renamed, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
var archived = await DeleteAsync(member, TeamUrl(team.TeamId));
await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
var transferred = await member.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(memberMe.UserId));
await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
/// <remarks>
/// <para>
/// <b>The <c>TeamAccess.IsOwner</c> boundary, and the most important authorization test in this
/// feature.</b> An admin may manage members and vaults, and that is deliberately not the same
/// permission as deciding whether the team continues to exist or who controls it. If either of
/// these checks were widened to <c>CanAdminister</c> — which is what every neighbouring endpoint
/// uses, so it is the easy mistake — anybody the owner promoted could archive the team out from
/// under them or take it outright, and nothing in the response of any other test would change.
/// </para>
/// <para>
/// The rename is asserted in the same test on purpose: it is what shows the admin genuinely holds
/// administrative rights here, so the two refusals are the boundary rather than a broken role.
/// </para>
/// </remarks>
[Fact]
public async Task AnAdminWhoIsNotTheOwner_MayRenameButMayNotArchiveOrHandOverTheTeam()
{
var owner = await EnrolledClientAsync("admin-boundary-owner", "abowner@example.com");
var admin = await EnrolledClientAsync("admin-boundary-admin", "abadmin@example.com");
var team = await CreateTeamAsync(owner, "Boundary");
var entry = await LookupAsync(owner, "abadmin@example.com");
var adminMe = await ReadAsync<MeResponse>(admin, MeUrl);
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
// They really are an admin: this one succeeds.
var renamed = await admin.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("Renamed by an admin", null));
renamed.StatusCode.ShouldBe(HttpStatusCode.OK);
var archived = await DeleteAsync(admin, TeamUrl(team.TeamId));
await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
var transferred = await admin.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(adminMe.UserId));
await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
// And nothing moved: the team is still there and still owned by the person who made it.
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
members.Where(row => row.UserId == ownerMe.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
// ---- Archiving ----
/// <remarks>
/// Archiving hides a team from every member's list at once, and a team vault resolves through
/// membership — so archiving one that still owned vaults would take those vaults away from
/// everybody holding a key, including the caller, with no way back because nothing in this product
/// deletes a vault. The refusal is the end of that road rather than a step on it, which is why the
/// team is asserted still listed afterwards.
/// </remarks>
[Fact]
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndLeavesTheTeamListed()
{
var owner = await EnrolledClientAsync("archive-vault-owner");
var team = await CreateTeamAsync(owner, "Occupied");
await CreateVaultAsync(owner, team.TeamId);
var response = await DeleteAsync(owner, TeamUrl(team.TeamId));
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamNotEmpty);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
listed.ShouldContain(row => row.TeamId == team.TeamId);
}
/// <remarks>
/// Every member's list, not just the caller's. Memberships are archived with the team in the same
/// transaction, and a live membership pointing at an archived team would leave the other member
/// still seeing it — which is the shape a half-applied archive takes.
/// </remarks>
[Fact]
public async Task ArchivingAnEmptyTeam_RemovesItFromEveryMembersList()
{
var owner = await EnrolledClientAsync("archive-owner", "aowner@example.com");
var member = await EnrolledClientAsync("archive-member", "amember@example.com");
var team = await CreateTeamAsync(owner, "Wound up");
var entry = await LookupAsync(owner, "amember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
(await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
.ShouldContain(row => row.TeamId == team.TeamId);
var response = await DeleteAsync(owner, TeamUrl(team.TeamId));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
.ShouldNotContain(row => row.TeamId == team.TeamId);
(await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
.ShouldNotContain(row => row.TeamId == team.TeamId);
// And it is gone the way a team nobody is in is gone, rather than merely unlisted.
(await GetAsync(owner, MembersUrl(team.TeamId))).StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
// ---- Ownership ----
/// <remarks>
/// <b>Both roles, because either alone would pass while the team was broken.</b> Asserting only
/// that the recipient is now owner would pass with the team owned twice; asserting only that the
/// outgoing owner is an admin would pass with it owned by nobody. Ownership is sole and the two
/// writes are one transaction precisely so that neither of those states can exist, so the count of
/// owners is asserted too.
/// </remarks>
[Fact]
public async Task TransferringOwnership_MakesTheTargetOwnerAndTheOutgoingOwnerAnAdmin()
{
var owner = await EnrolledClientAsync("transfer-owner", "towner@example.com");
var successor = await EnrolledClientAsync("transfer-successor", "tsuccessor@example.com");
var team = await CreateTeamAsync(owner, "Handover");
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
var entry = await LookupAsync(owner, "tsuccessor@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
members.Where(row => row.UserId == entry.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
members.Where(row => row.UserId == ownerMe.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Admin, "the outgoing owner is demoted, not removed");
members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);
// And the recipient is told so by the endpoint their own screen reads.
(await ReadAsync<IReadOnlyList<TeamSummary>>(successor, TeamsUrl))
.Where(row => row.TeamId == team.TeamId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// The thing that was impossible before this endpoint existed. An owner could not be removed and
/// could not be demoted, so somebody who left the company owning a team left it owned by them for
/// ever — a state only an operator with database access could fix.
/// </remarks>
[Fact]
public async Task AfterATransfer_TheFormerOwnerCanFinallyBeRemoved()
{
var owner = await EnrolledClientAsync("departing-owner", "downer@example.com");
var successor = await EnrolledClientAsync("departing-successor", "dsuccessor@example.com");
var team = await CreateTeamAsync(owner, "Departure");
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
var entry = await LookupAsync(owner, "dsuccessor@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var transferred = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
transferred.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var removed = await DeleteAsync(successor, MemberUrl(team.TeamId, ownerMe.UserId));
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
.ShouldNotContain(row => row.TeamId == team.TeamId);
}
/// <remarks>
/// Adding somebody and handing them the team in one step would let an id supplied once take it, so
/// the recipient has to be an active member already. This is the same refusal that stops a stranger
/// being made owner by pasting their id.
/// </remarks>
[Fact]
public async Task TransferringToSomebodyWhoIsNotAMember_IsRefused()
{
var owner = await EnrolledClientAsync("transfer-closed-owner", "tcowner@example.com");
await EnrolledClientAsync("transfer-outsider", "toutsider@example.com");
var team = await CreateTeamAsync(owner, "Closed handover");
var entry = await LookupAsync(owner, "toutsider@example.com");
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
}
[Fact]
public async Task TransferringToYourself_IsRefused()
{
var owner = await EnrolledClientAsync("transfer-self-owner");
var team = await CreateTeamAsync(owner, "Already mine");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(me.UserId));
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
}
// ---- Membership ----
/// <remarks>
/// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
/// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
@@ -128,7 +441,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var me = await ReadAsync<MeResponse>(member, "/api/v1/me");
var me = await ReadAsync<MeResponse>(member, MeUrl);
var vault = me.Vaults.SingleOrDefault(summary => summary.VaultId == vaultId);
vault.ShouldNotBeNull("membership is what makes a team vault visible");
@@ -163,11 +476,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var push = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
push.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await push.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.Forbidden);
await ShouldBeProblemAsync(push, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
/// <remarks>
@@ -187,16 +496,14 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var before = await ReadAsync<MeResponse>(member, "/api/v1/me");
var before = await ReadAsync<MeResponse>(member, MeUrl);
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
var removed = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
var removed = await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var after = await ReadAsync<MeResponse>(member, "/api/v1/me");
var after = await ReadAsync<MeResponse>(member, MeUrl);
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
var pull = await member.PostContractAsync(
@@ -223,9 +530,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
@@ -233,9 +538,11 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
}
/// <remarks>
/// The owner cannot be removed or demoted, because nothing can appoint a replacement yet. Refused
/// with a code the client can act on rather than a bare 400, since "you cannot do that" and "you did
/// that wrong" lead somewhere different.
/// The owner cannot be removed even now that ownership can be handed over, and this is the standing
/// guarantee rather than a leftover: transferring is what makes the team's next owner exist, so
/// removing the current one first would still leave it with nobody who can manage it. Refused with a
/// code the client can act on rather than a bare 400, since "you cannot do that" and "you did that
/// wrong" lead somewhere different.
/// </remarks>
[Fact]
public async Task TheOwner_CannotBeRemoved()
@@ -243,19 +550,328 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
var team = await CreateTeamAsync(owner, "Sole");
var me = await ReadAsync<MeResponse>(owner, "/api/v1/me");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var response = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{me.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
var response = await DeleteAsync(owner, MemberUrl(team.TeamId, me.UserId));
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.LastTeamOwner);
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);
}
/// <remarks>
/// The other half of the same guarantee, and the one a transfer endpoint could plausibly have
/// loosened. Demoting the owner through the role endpoint cannot appoint a replacement in the same
/// breath, so it would leave the team ownerless — which is exactly the state
/// <c>TransferOwnershipAsync</c> does two writes in one transaction to avoid.
/// </remarks>
[Fact]
public async Task TheOwner_CannotBeDemotedOnTheirOwn()
{
var owner = await EnrolledClientAsync("demote-owner", "demote@example.com");
var team = await CreateTeamAsync(owner, "Undemotable");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var response = await owner.PutContractAsync(
MemberRoleUrl(team.TeamId, me.UserId),
new ChangeTeamMemberRoleRequest(TeamMemberRole.Admin));
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
members.Where(row => row.UserId == me.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// A real value, not a placeholder. <c>LastActiveAt</c> is the field the teams screen uses to say
/// whether a colleague has been here at all, and the failure it guards against is the one that made
/// the column impossible to offer before: a null for somebody who has plainly been making requests
/// reads as "never", which is a lie about a person.
/// </remarks>
[Fact]
public async Task AMemberListing_ReportsWhenAnAccountWasLastActive()
{
var owner = await EnrolledClientAsync("last-seen-owner");
var team = await CreateTeamAsync(owner, "Last seen");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
var self = members.Where(row => row.UserId == me.UserId).ShouldHaveSingleItem();
self.LastActiveAt.ShouldNotBeNull(
"this account has made several authenticated requests already");
// Within the window the server writes on, which is the whole of the precision this carries.
self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1));
}
// ---- Invitations ----
/// <remarks>
/// The expiry is asserted rather than merely present. An invitation that never lapsed would be a
/// standing offer against an address, and company addresses are handed to the next person to hold
/// the job — so the person who inherits the mailbox would inherit the team.
/// </remarks>
[Fact]
public async Task InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry()
{
var owner = await EnrolledClientAsync("invite-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Invitations");
var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin);
var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId);
listed.Email.ShouldBe(address);
listed.Role.ShouldBe(TeamMemberRole.Admin);
listed.State.ShouldBe(TeamInvitationState.Pending);
listed.AcceptedAt.ShouldBeNull();
(listed.ExpiresAt - listed.CreatedAt).ShouldBe(TimeSpan.FromDays(14));
}
/// <remarks>
/// The same body twice, as a client whose response was lost would send it — the shape team and
/// vault creation already have. Two invitations to one address would show the same person twice on
/// the teams screen and take two revocations to withdraw.
/// </remarks>
[Fact]
public async Task RepeatingAnInvitation_ReturnsTheSameOneRatherThanASecond()
{
var owner = await EnrolledClientAsync("invite-retry-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Retried invitations");
var request = new CreateTeamInvitationRequest(
Guid.CreateVersion7(), address, TeamMemberRole.Member);
var first = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
owner, InvitationsUrl(team.TeamId), request);
var second = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
owner, InvitationsUrl(team.TeamId), request);
second.InvitationId.ShouldBe(first.InvitationId);
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
owner, InvitationsUrl(team.TeamId));
// Case-insensitively, because the column is citext and two addresses differing only in case
// are one address — a second row under a different casing would still be a second invitation.
listed.Count(row => string.Equals(row.Email, address, StringComparison.OrdinalIgnoreCase))
.ShouldBe(1);
}
[Fact]
public async Task ASecondInvitationToTheSameAddress_IsRefused()
{
var owner = await EnrolledClientAsync("invite-duplicate-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Duplicate invitations");
await InviteAsync(owner, team.TeamId, address);
// A different id, so this is a second invitation rather than a retry of the first.
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(Guid.CreateVersion7(), address, TeamMemberRole.Admin));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
/// <remarks>
/// Ownership is sole and is handed over deliberately. An invitation that conferred it would let an
/// address typed once take the team the moment somebody signed in with it — and the invitee is by
/// definition somebody nobody here has met.
/// </remarks>
[Fact]
public async Task InvitingSomebodyAsOwner_IsRefused()
{
var owner = await EnrolledClientAsync("invite-owner-role-owner");
var team = await CreateTeamAsync(owner, "Not for sale");
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Owner));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
[Fact]
public async Task AMalformedAddress_IsRefused()
{
var owner = await EnrolledClientAsync("invite-malformed-owner");
var team = await CreateTeamAsync(owner, "Shapes");
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), "not an address", TeamMemberRole.Member));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
/// <remarks>
/// A withdrawn invitation stays in the listing rather than vanishing, so the screen can show that it
/// was withdrawn rather than letting it read as never sent. Withdrawing it twice is 404, for the
/// reason revoking a device grant gives: a caller driving towards "that invitation will not let
/// anybody in" can treat 404 as having arrived.
/// </remarks>
[Fact]
public async Task RevokingAnInvitation_MarksItRevokedAndCannotBeDoneTwice()
{
var owner = await EnrolledClientAsync("invite-revoke-owner");
var team = await CreateTeamAsync(owner, "Withdrawals");
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
var revoked = await DeleteAsync(
owner, InvitationUrl(team.TeamId, invitation.InvitationId));
revoked.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Revoked);
var again = await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
again.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// An invitation is a fact about the team, so any member may read the list — whoever is about to be
/// handed a vault key needs to see who else is on their way in — but only an admin may write one.
/// </remarks>
[Fact]
public async Task APlainMember_MayReadInvitationsAndMayNotIssueThem()
{
var owner = await EnrolledClientAsync("invite-reader-owner", "irowner@example.com");
var member = await EnrolledClientAsync("invite-reader-member", "irmember@example.com");
var team = await CreateTeamAsync(owner, "Readable invitations");
var entry = await LookupAsync(owner, "irmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
member, InvitationsUrl(team.TeamId));
listed.ShouldContain(row => row.InvitationId == invitation.InvitationId);
var refused = await member.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Member));
await ShouldBeProblemAsync(refused, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
// ---- Claiming an invitation at sign-in ----
/// <remarks>
/// The heart of the feature, and the only path by which an invitation becomes anything. There is no
/// token and no mail: the row says "the next account to sign in with this address joins this team",
/// and just-in-time provisioning is what reads it. What it creates is a membership and not a key —
/// the vault key still has to be wrapped to them from a machine that holds one.
/// </remarks>
[Fact]
public async Task SigningInWithAnInvitedAddress_JoinsTheTeamAndMarksTheInvitationAccepted()
{
var owner = await EnrolledClientAsync("claim-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Claimed");
var invitation = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Member);
var (invitee, inviteeUserId) = await SignInAsync(address);
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl))
.Where(row => row.TeamId == team.TeamId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Member);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
var joined = members.Where(row => row.UserId == inviteeUserId).ShouldHaveSingleItem();
joined.Status.ShouldBe(TeamMemberStatus.Active);
joined.Role.ShouldBe(TeamMemberRole.Member);
joined.IsEnrolled.ShouldBeFalse("membership is authorization, and they hold no key yet");
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Accepted);
listed.AcceptedAt.ShouldNotBeNull();
}
/// <remarks>
/// <b>The security test this feature stands on.</b> An invitation is authorization: claiming one is
/// what decides that this server will serve somebody a team's vaults. An address the identity
/// provider has not vouched for is an address anybody able to obtain a token can name, so an
/// unverified one must confer nothing — that is the same attack <c>AllowEmailLinking</c> exists to
/// refuse, arriving by a different door. The failure would be silent by construction: the account is
/// provisioned either way and every other part of the request succeeds, so nothing but this would
/// notice that the wrong person had walked into the team.
/// </remarks>
[Fact]
public async Task AnAddressTheProviderHasNotVerified_ClaimsNothing()
{
var owner = await EnrolledClientAsync("unverified-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Verified only");
var invitation = await InviteAsync(owner, team.TeamId, address);
var (impostor, _) = await SignInAsync(address, emailVerified: false);
(await ReadAsync<IReadOnlyList<TeamSummary>>(impostor, TeamsUrl)).ShouldBeEmpty();
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Pending);
listed.AcceptedAt.ShouldBeNull();
}
/// <remarks>
/// A withdrawn invitation is withdrawn, which the claim path has to honour independently — it reads
/// the invitation table directly rather than going back through the endpoint that refused.
/// </remarks>
[Fact]
public async Task SigningInAfterAnInvitationWasWithdrawn_JoinsNothing()
{
var owner = await EnrolledClientAsync("withdrawn-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Withdrawn");
var invitation = await InviteAsync(owner, team.TeamId, address);
await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
var (invitee, _) = await SignInAsync(address);
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl)).ShouldBeEmpty();
}
// ---- Vault key grants ----
/// <remarks>
/// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
/// does nothing, because the access check will go on refusing them the vault — and a sharing screen
@@ -283,11 +899,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.InvalidVaultGrant);
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
@@ -323,6 +936,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
// ---- The directory and the key log ----
/// <remarks>
/// The directory has no search. Asserting it rather than trusting the implementation, because a
/// prefix match added later for convenience turns a server that stores addresses in plaintext into a
@@ -394,6 +1009,29 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
}
}
// ---- Helpers ----
private static string TeamUrl(Guid teamId) => $"{TeamsUrl}/{teamId}";
private static string OwnerUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/owner";
private static string MembersUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/members";
private static string MemberUrl(Guid teamId, Guid userId) => $"{MembersUrl(teamId)}/{userId}";
private static string MemberRoleUrl(Guid teamId, Guid userId) =>
$"{MemberUrl(teamId, userId)}/role";
private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations";
private static string InvitationUrl(Guid teamId, Guid invitationId) =>
$"{InvitationsUrl(teamId)}/{invitationId}";
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
/// <summary>An address no account holds, uniquified because the container is shared.</summary>
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
{
var unique = $"{subject}-{Guid.CreateVersion7():N}";
@@ -416,6 +1054,24 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
return client;
}
/// <summary>Signs a brand-new account in, which is what claims any invitation to its address.</summary>
/// <remarks>
/// No enrollment, because an invitee has no key and the claim path must not need one — requiring it
/// would be requiring it of exactly the person who cannot yet supply it. Driving <c>/me</c> is what
/// runs just-in-time provisioning, and provisioning is where the claim happens.
/// </remarks>
private async Task<(HttpClient Client, Guid UserId)> SignInAsync(
string email,
bool emailVerified = true)
{
var client = fixture.CreateClientFor(
$"invitee-{Guid.CreateVersion7():N}", email, emailVerified);
var me = await ReadAsync<MeResponse>(client, MeUrl);
return (client, me.UserId);
}
/// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);
@@ -425,7 +1081,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
/// transformation the product does not perform. It is uniquified because the container is shared
/// across every class in this assembly and the slug is unique deployment-wide.
/// </remarks>
private Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
private static Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
PostAsync<CreateTeamRequest, TeamSummary>(
client,
TeamsUrl,
@@ -437,11 +1093,11 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
/// opaquely and verifies neither — see docs/crypto.md §6 — so a real seal here would be testing the
/// crypto library rather than the endpoint.
/// </remarks>
private async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
private static async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
{
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
client,
$"{TeamsUrl}/{teamId}/vaults",
TeamVaultsUrl(teamId),
new CreateTeamVaultRequest(
Guid.CreateVersion7(),
"Team vault",
@@ -469,11 +1125,37 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
TeamMemberRole role)
{
var response = await client.PostContractAsync(
$"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role));
MembersUrl(teamId), new AddTeamMemberRequest(userId, role));
response.EnsureSuccessStatusCode();
}
private static Task<TeamInvitationSummary> InviteAsync(
HttpClient client,
Guid teamId,
string email,
TeamMemberRole role = TeamMemberRole.Member) =>
PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
client,
InvitationsUrl(teamId),
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, role));
/// <remarks>
/// Filtered by id rather than taken from a position in the list, because the container is shared and
/// every other class's invitations are in the same table. A count over the whole listing would pass
/// or fail depending on what else ran.
/// </remarks>
private static async Task<TeamInvitationSummary> FindInvitationAsync(
HttpClient client,
Guid teamId,
Guid invitationId)
{
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
client, InvitationsUrl(teamId));
return listed.Where(row => row.InvitationId == invitationId).ShouldHaveSingleItem();
}
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
HttpClient client,
string url,
@@ -488,11 +1170,32 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
{
var response = await client.GetAsync(
new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
var response = await GetAsync(client, url);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<T>())!;
}
private static Task<HttpResponseMessage> GetAsync(HttpClient client, string url) =>
client.GetAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
private static Task<HttpResponseMessage> DeleteAsync(HttpClient client, string url) =>
client.DeleteAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
/// <remarks>
/// Asserts on the <c>code</c> extension and never on the prose, for the reason
/// <see cref="JsonProblem"/> gives: the code is the contract and the wording is not.
/// </remarks>
private static async Task ShouldBeProblemAsync(
HttpResponseMessage response,
HttpStatusCode expectedStatus,
string expectedCode)
{
response.StatusCode.ShouldBe(expectedStatus);
var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(expectedCode);
}
}
@@ -16,23 +16,29 @@ using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the host list answers a pointer.
/// How the grid of host cards answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures this control rather than driving it. What
/// is here is the one gesture that cannot be expressed as a binding and cannot be checked by measuring: a
/// right click has to move the selection <em>before</em> the menu opens, because all three of that menu's
/// commands read the vault's host selection. A menu that quietly acted on whichever host happened to be
/// selected would delete the wrong machine, which is the version of this mistake worth a suite.
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
/// <c>HostsScreen.axaml.cs</c>.
/// </para>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because all three of
/// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
/// suite.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
/// one: compiled bindings resolve against the declared type, and the list is built out of the vault's own
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
/// hosts and groups.
/// </para>
/// </remarks>
public sealed class HostSidebarTests : IAsyncLifetime
public sealed class HostGridTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
@@ -93,18 +99,18 @@ public sealed class HostSidebarTests : IAsyncLifetime
[Fact]
public async Task ARightClickSelectsTheHostUnderThePointer()
{
await OnTheSidebarAsync((sidebar, window) =>
await OnTheGridAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
vault.SelectedHost = first;
RightClick(RowFor(sidebar, other), window);
RightClick(CardFor(screen, other), window);
vault.SelectedHost.ShouldBeSameAs(other);
var menu = sidebar.HostList.ContextMenu.ShouldNotBeNull();
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
// The commands are the vault's, which is the other half of putting the menu on the list rather
@@ -121,19 +127,19 @@ public sealed class HostSidebarTests : IAsyncLifetime
}
/// <remarks>
/// A heading is a row in the same list and the control will happily select it, but it is not a host —
/// A heading is an item in the same list and the control will happily select it, but it is not a host —
/// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing
/// or act on a machine somewhere else in the list.
/// or act on a machine somewhere else in the grid.
/// </remarks>
[Fact]
public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
{
await OnTheSidebarAsync((sidebar, window) =>
await OnTheGridAsync((screen, window) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
var heading = sidebar.HostList
var heading = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => item.DataContext is SidebarGroupHeader);
@@ -141,10 +147,39 @@ public sealed class HostSidebarTests : IAsyncLifetime
RightClick(heading, window);
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
sidebar.HostList.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
/// <remarks>
/// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
/// Driven through the property the card's <c>ListBox</c> binds rather than through a click, because
/// what is worth holding is the rule — the filter is a property of the grid, and it also moves the
/// selection the group's own EDIT and DELETE act on. A click would test Avalonia's <c>SelectedItem</c>
/// binding, which is not this application's code.
/// </remarks>
[Fact]
public async Task ChoosingAGroupNarrowsTheGridAndAimsTheGroupButtonsAtIt()
{
var production = vault.Groups.Single();
vault.MoveHostToGroupCommand.Execute(
new HostGroupMove(Row(vault, "prod-db"), production.EntityId));
vault.GroupFilter = production;
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["prod-db"], "only what is filed under the chosen group");
vault.SelectedGroup.ShouldBeSameAs(production, "what EDIT and DELETE act on");
vault.IsFilteredByGroup.ShouldBeTrue();
vault.ClearGroupFilterCommand.Execute(null);
vault.VisibleHosts.Count.ShouldBe(2, "SHOW ALL brings back the hosts outside the group");
vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off");
}
// ---- Helpers ----
private static void RightClick(Visual row, Visual window)
@@ -155,18 +190,18 @@ public sealed class HostSidebarTests : IAsyncLifetime
((Window)window).MouseUp(at, MouseButton.Right);
}
private Task OnTheSidebarAsync(Action<HostSidebar, Window> body) =>
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var sidebar = new HostSidebar { DataContext = vault };
var screen = new HostsScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight);
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
body(sidebar, window);
body(screen, window);
}
finally
{
@@ -175,8 +210,8 @@ public sealed class HostSidebarTests : IAsyncLifetime
},
Token);
private static ListBoxItem RowFor(Visual sidebar, HostRowViewModel host) =>
sidebar.GetVisualDescendants()
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
@@ -36,8 +36,13 @@ internal static class LayoutHarness
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 574;
/// <summary>The host sidebar's fixed width, from the hosts screen's <c>ColumnDefinitions</c>.</summary>
internal const double HostSidebarWidth = 268;
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
/// <remarks>
/// This was <c>HostSidebarWidth</c> at 268, taken from a column definition on the hosts screen. The
/// drawer states its own width instead — it is the only thing in its column and the column is
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it.
/// </remarks>
internal const double HostDrawerWidth = 304;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
@@ -137,22 +137,27 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
caches.Dispose();
}
// ---- The host sidebar ----
// ---- The hosts drawer ----
//
// This was the host sidebar's section. The control kept the half of that column that is about one host
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
// the right.
[Fact]
public async Task TheHostSidebarFitsWithNoEditorOpen()
public async Task TheHostDrawerFitsShowingAHost()
{
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
vault.SelectedHost = vault.Hosts[0];
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The tight one, and the reason this suite still exists. The sidebar is 268 pixels wide against the old
/// column's 340, and the host editor is the tallest thing in it: six fields, an authentication picker
/// with a two-line item template, a checkbox, a paragraph of hint text and three buttons, all sharing a
/// column with the list above them.
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
/// wrapped row of tag chips, a checkbox, a paragraph of hint text and three buttons.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithItsEditorOpen()
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
@@ -165,101 +170,41 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// Folding the list away is the one thing a user can do to this control that changes which of its parts
/// is on screen, so it is a shape worth laying out on its own.
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
/// three pickers are the same width as the host editor's and its labels are longer.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithItsListFoldedAway()
{
vault.ToggleHostsCommand.Execute(null);
vault.AreHostsExpanded.ShouldBeFalse();
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// Headings are rows in the same list as the hosts, drawn from a different template, and they are the
/// widest thing in a 268-pixel column: a name, a chevron and a count on one line. Measured with one group
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithGroupHeadingsInTheList()
public async Task TheHostDrawerFitsWithTheGroupEditorOpen()
{
await SeedGroupsAsync(3);
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
vault.GroupFilter = vault.Groups[0];
vault.EditGroupCommand.Execute(null);
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
vault.IsEditingGroup.ShouldBeTrue("the desktop raises this now, as the phone always did");
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
/// the keyboard off the terminal's native child window first and then focuses this target, so a target
/// that cannot take focus leaves the user with no focused element and no way back except the mouse.
/// The question in place of the three buttons. Its tallest shape is a host with a terminal open on it,
/// which adds a disclosure the ordinary case has not got.
/// </para>
/// <para>
/// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
/// A <c>ListBox</c> is not focusable by default, so the call returns false against a list that has not
/// asked to be — and <c>Focus()</c> on a collapsed control is a no-op that is not replayed when it is
/// revealed, which is exactly what the folded-away case would hit.
/// Still worth measuring although the drawer scrolls as a whole now — see <c>HostDrawer.axaml</c> — and
/// the reason has changed rather than gone. The harness skips anything inside a <c>ScrollViewer</c>, so
/// what this holds is not that the buttons are on screen but that the drawer itself does not blow its
/// column sideways. The question is the widest thing it draws: a sentence with a host name in it.
/// </para>
/// </remarks>
[Fact]
public async Task TheSidebarsKeyboardTargetTakesFocusInBothOfItsShapes()
{
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostList);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is showing");
});
vault.ToggleHostsCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostFilter);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is folded away, so the filter takes it");
});
}
/// <remarks>
/// The editor open with the list still on screen behind it, which is the state a user is most likely to
/// leave the sidebar in — so it is the state the keyboard answer most has to hold in.
/// </remarks>
[Fact]
public async Task TheSidebarsKeyboardTargetStillTakesFocusWithTheEditorOpen()
{
vault.NewHostCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.HostList.IsEffectivelyVisible.ShouldBeTrue();
sidebar.KeyboardTarget.Focus().ShouldBeTrue();
});
}
/// <remarks>
/// <para>
/// The strip along the sidebar's bottom edge with the question in it instead of the three buttons. Its
/// tallest shape is a host with a terminal open on it, which adds a disclosure the ordinary case has
/// not got — in a 268-pixel column whose middle is a list that has already taken every spare pixel.
/// </para>
/// <para>
/// Worth measuring rather than assuming, because this is the one card in the application a user cannot
/// scroll: the sidebar's only <c>ScrollViewer</c> is inside the host list, so a button pushed past the
/// bottom edge here would leave the question unanswerable in either direction.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithADeletionInQuestion()
public async Task TheHostDrawerFitsWithADeletionInQuestion()
{
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHost.IsConnected = true;
@@ -268,7 +213,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.IsConfirmingDeletion.ShouldBeTrue();
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
@@ -303,15 +248,15 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.SelectedHost = null;
vault.Status = string.Empty;
await OnTheSidebarAsync((sidebar, window) =>
await OnTheHostsScreenAsync((screen, window) =>
{
var row = sidebar.HostList.GetVisualDescendants()
var card = screen.HostGrid.GetVisualDescendants()
.OfType<ListBoxItem>()
.First();
.First(item => item.DataContext is HostRowViewModel);
var centre = row.TranslatePoint(
new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the row is not in this window's tree");
var centre = card.TranslatePoint(
new Point(card.Bounds.Width / 2, card.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the card is not in this window's tree");
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
@@ -320,7 +265,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
vault.SelectedHost.ShouldNotBeNull("a press on a card selects it");
vault.Status.ShouldContain(
"not in this keychain any more",
Case.Insensitive,
@@ -328,6 +273,145 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
});
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
/// the keyboard off the terminal's native child window first and then focuses this target, so a target
/// that cannot take focus leaves the user with no focused element and no way back except the mouse.
/// </para>
/// <para>
/// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
/// A <c>ListBox</c> is not focusable by default, so the call returns false against a list that has not
/// asked to be — and an empty one has no item to take it either, which is the second shape below.
/// </para>
/// <para>
/// The empty shape used to be the sidebar's folded-away list and is now a filter that matches nothing.
/// That is a state a user reaches far more often than the old one: it is one keystroke away from every
/// search.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsScreensKeyboardTargetTakesFocusInBothOfItsShapes()
{
await OnTheHostsScreenAsync((screen, _) =>
{
screen.KeyboardTarget.ShouldBeSameAs(screen.HostGrid);
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid has cards in it");
});
vault.HostFilter = "nothing matches this";
vault.HasVisibleHosts.ShouldBeFalse();
await OnTheHostsScreenAsync((screen, _) =>
{
screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid is empty, so the find box takes it");
});
}
/// <remarks>
/// The editor open with the grid still on screen beside it, which is the state a user is most likely to
/// leave this screen in — so it is the state the keyboard answer most has to hold in.
/// </remarks>
[Fact]
public async Task TheHostsScreensKeyboardTargetStillTakesFocusWithTheEditorOpen()
{
vault.NewHostCommand.Execute(null);
await OnTheHostsScreenAsync((screen, _) =>
{
screen.HostGrid.IsEffectivelyVisible.ShouldBeTrue();
screen.KeyboardTarget.Focus().ShouldBeTrue();
});
}
/// <remarks>
/// Headings are items in the same list as the cards, drawn from a different template, and they span a
/// whole row of the wrap rather than sitting in the flow as another card. Measured with one group
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithGroupHeadingsInTheGrid()
{
await SeedGroupsAsync(3);
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
}
/// <remarks>
/// The narrowest the grid ever gets, and the width the tile was sized against: the window at its
/// minimum, less the nav rail and less the drawer.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithTheDrawerOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.IsDrawerOpen.ShouldBeTrue();
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out"));
}
/// <summary>
/// The grid is still a grid at the window's minimum with the drawer open.
/// </summary>
/// <remarks>
/// <para>
/// <b>The harness cannot see this and never will.</b> Its one rule is that a control is inside the
/// window, so a wrap that has quietly collapsed to a single column reports perfectly clean — every card
/// is inside, just one above the other. That is exactly what happened: the tile's width was set from
/// arithmetic that left out the scrolling stack's own margins, and the grid became a list with extra
/// padding at precisely the size this application guarantees.
/// </para>
/// <para>
/// Two per row rather than a width assertion, because the number that matters is the number of columns.
/// A width is one of the inputs — the margins, the padding and the scrollbar are the others — and
/// pinning the input would go on passing while any of the rest moved.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
await OnTheHostsScreenAsync((screen, window) =>
{
var cards = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Where(item => item.DataContext is HostRowViewModel)
.Select(item => item.TranslatePoint(default, window)
?? throw new InvalidOperationException("a card is not in this window's tree"))
.ToList();
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host in the grid");
cards.GroupBy(point => Math.Round(point.Y))
.Max(row => row.Count())
.ShouldBeGreaterThanOrEqualTo(
2,
"at the window's minimum, with the drawer out, the cards still wrap two to a row");
});
}
/// <remarks>
/// The widest the drawer's own contents get while the grid is beside them: the host editor open, which
/// is what EDIT does to a screen that already has both columns up.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithTheDrawerEditingAHost()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the editor out beside the grid"));
}
// ---- The hosts screen ----
//
// Measurable for the first time. Every rectangle below lived in MainWindow.axaml until the terminal
@@ -394,22 +478,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The group panel is a row of its own at the foot of this screen, so it competes with the overview above
/// it for the same column — and it grows sideways as groups are added, which is the direction a
/// fixed-width column has least of. Six, because that is more than anybody's first three and enough to
/// need the horizontal scroller rather than to overflow silently.
/// The group cards are a wrap above the host cards, so more of them than a row holds is the case that
/// pushes the hosts down rather than one that overflows sideways. Six, because that is more than
/// anybody's first three and enough to need a second row at the window's minimum.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithMoreGroupsThanTheRowHasRoomFor()
public async Task TheHostsScreenFitsWithMoreGroupsThanARowHasRoomFor()
{
await SeedGroupsAsync(6);
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six groups along the bottom"));
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six group cards above the hosts"));
}
/// <remarks>
/// The question replaces the buttons rather than stacking under them — the same rule the sidebar's own
/// deletion follows — and it is the taller of the two, because it says how many hosts are about to move.
/// The question replaces the group's two buttons rather than stacking under them — the same rule every
/// other pair in this application follows — and it is the taller of the two, because it says how many
/// hosts are about to move.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
@@ -900,15 +984,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// Eight destinations in a 54-pixel column. The rail runs vertically, so what runs out here is height
/// rather than width — at the window's minimum the entries have to leave room for each other, which is
/// the same failure the old four-button selector was one label away from. It got tighter when the host
/// keys left the keychain screen and became a destination of their own, and tighter again with snippets
/// and then the logs — which is why the count is asserted rather than left to the fit check: an entry
/// silently dropping off the bottom would still pass every other assertion here.
/// <para>
/// The rail runs vertically, so what runs out here is height rather than width — at the window's minimum
/// the entries have to leave room for each other, which is the same failure the old four-button selector
/// was one label away from. It got tighter when the host keys left the keychain screen and became a
/// destination of their own, and tighter again with snippets and then the logs, which is why the count
/// is asserted rather than left to the fit check: an entry silently dropping off the bottom would still
/// pass every other assertion here.
/// </para>
/// <para>
/// Seven now, and it went down rather than up for the first time: SFTP and S3 became fixed tabs in the
/// strip, which is where a destination you stay in belongs. The number is asserted in both directions
/// for the same reason — an entry that reappeared here would be a route out of the tab the rail lives
/// in. See <c>NavRail.axaml</c>.
/// </para>
/// </remarks>
[Fact]
public async Task TheNavRailHoldsNineDestinationsAtTheWindowsMinimum()
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
@@ -921,7 +1013,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(9, "one per screen the rail reaches");
buttons.Count.ShouldBe(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
foreach (var button in buttons)
{
@@ -1062,22 +1154,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// ---- Helpers ----
/// <summary>Lays the sidebar out at the width the hosts screen gives it.</summary>
private Task MeasureSidebarAsync(Action<IReadOnlyList<string>> assert) =>
OnTheSidebarAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
/// <summary>Lays the drawer out at the width it declares for itself.</summary>
private Task MeasureDrawerAsync(Action<IReadOnlyList<string>> assert) =>
OnTheDrawerAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
private Task OnTheSidebarAsync(Action<HostSidebar, Window> body) =>
private Task OnTheDrawerAsync(Action<HostDrawer, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var sidebar = new HostSidebar { DataContext = vault };
var drawer = new HostDrawer { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight);
drawer, LayoutHarness.HostDrawerWidth, LayoutHarness.ScreenHeight);
try
{
body(sidebar, window);
body(drawer, window);
}
finally
{
@@ -1088,25 +1180,25 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the hosts screen out at the size it gets beside the nav rail and under the strip.</summary>
/// <remarks>
/// The shell is the data context, not the vault — the sidebar is handed the vault from inside the
/// screen's own markup. <see cref="MainWindowViewModel.Vault"/> is assigned rather than reached through
/// an unlock, which would be a second enrollment for no extra rectangle.
/// The vault is the data context and the shell is not, which it used to be. The screen handed the vault
/// to the sidebar from inside its own markup and needed the shell to do it; the drawer is a plain child
/// and inherits what the screen has, so the indirection went away with the sidebar.
/// </remarks>
private Task MeasureHostsAsync(Action<IReadOnlyList<string>> assert) =>
OnTheHostsScreenAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
private Task OnTheHostsScreenAsync(Action<HostsScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Vault = vault;
shell.State = ShellState.Unlocked;
var screen = new HostsScreen { DataContext = shell };
var screen = new HostsScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
body(screen, window);
}
finally
{
@@ -1403,6 +1495,88 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
failure)));
/// <summary>Lays the vault screen out at the width it gets once the nav rail has taken its column.</summary>
/// <remarks>
/// <para>
/// The teams screen had no entry in this suite at all until it grew four sections — a rename form, an
/// armed confirmation, an invitations list and a key-holders list — plus a second line in the member
/// row. Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav
/// rail takes 190 and the team list 268, leaving 558 for everything above.
/// </para>
/// <para>
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
/// <see cref="StubTeamServer"/>. The two states that hide half the screen, the rename form and the
/// confirmation, are measured in their own tests below rather than here, because a control that is
/// collapsed when the window is laid out is a control this suite has not checked.
/// </para>
/// </remarks>
[Fact]
public Task TheTeamsScreen_FitsWithEveryListPopulated() =>
OnTheTeamsScreenAsync(
teams => { },
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with members, invitations and key holders"));
/// <remarks>
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
/// </remarks>
[Fact]
public Task TheTeamsScreen_FitsWhileRenamingATeam() =>
OnTheTeamsScreenAsync(
teams => teams.RenameTeamCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with the rename form open"));
/// <remarks>
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
/// tallest thing that can appear above the members list, so it is the case most likely to push the
/// key-holders list off the bottom.
/// </remarks>
[Fact]
public Task TheTeamsScreen_FitsWhileConfirmingAnArchive() =>
OnTheTeamsScreenAsync(
teams => teams.ArchiveTeamCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with the archive confirmation armed"));
/// <remarks>
/// A real <c>TeamsViewModel</c> over a stub server rather than the unlocked vault the rest of this
/// suite uses, because nothing on this screen is vault content: it is read from the server on open.
/// The session function answers null, which is the state a member is in before anybody has wrapped
/// them a key — and it is also the one that draws the most text, since every vault row then carries
/// the "waiting for a key" sentence.
/// </remarks>
private static async Task OnTheTeamsScreenAsync(
Action<TeamsViewModel> arrange,
Action<Window> assert)
{
using var teamServer = new StubTeamServer();
var teams = new TeamsViewModel(() => teamServer, () => null);
await teams.LoadAsync(Token);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
arrange(teams);
var screen = new TeamsScreen { DataContext = teams };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(window);
}
finally
{
window.Close();
}
},
Token);
}
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
@@ -0,0 +1,235 @@
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The least server a <c>TeamsViewModel</c> needs in order to be laid out with something in it.
/// </summary>
/// <remarks>
/// <para>
/// The teams screen is the one screen in this suite whose content cannot come from an unlocked vault,
/// because none of it is vault content: a team, its members, its invitations and who holds a key to a
/// vault are all read from the server on open, and the suite's <c>FakeAccountServer</c> implements
/// <see cref="IAccountApi"/> and nothing else. Rather than teach that fake five more interfaces for one
/// screen, this serves fixed rows and refuses everything a layout test has no business calling.
/// </para>
/// <para>
/// The rows are deliberately the <em>long</em> ones. A layout suite that measured "Bob" in a column sized
/// for an email address would certify a shape no real team produces — so the names, addresses and status
/// sentences here are at or near the length the interface can really be handed, which is what makes an
/// overflow show up at the minimum window rather than on somebody's screen.
/// </para>
/// </remarks>
internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
{
private static readonly Guid OwnerId = Guid.CreateVersion7();
private static readonly Guid ColleagueId = Guid.CreateVersion7();
private static readonly Guid TeamId = Guid.CreateVersion7();
private static readonly Guid VaultId = Guid.CreateVersion7();
/// <inheritdoc />
public Uri ServerUrl { get; } = new("https://dodossh.example");
/// <inheritdoc />
public ITeamApi Teams => this;
/// <inheritdoc />
public IVaultGrantApi Grants => this;
/// <inheritdoc />
public IAccountApi Account => throw new NotSupportedException();
/// <inheritdoc />
public ISyncApi Sync => throw new NotSupportedException();
/// <inheritdoc />
public IDirectoryApi Directory => throw new NotSupportedException();
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => throw new NotSupportedException();
/// <inheritdoc />
public SyncOptions SyncOptions => new();
/// <inheritdoc />
public string? RefreshToken => null;
/// <summary>The vault whose key holders are listed, so a test can select it.</summary>
internal static Guid TeamVaultId => VaultId;
/// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamSummary>>(
[
new TeamSummary(
TeamId,
"Platform Engineering",
"platform-engineering",
"Everything that runs the estate.",
TeamMemberRole.Owner,
MemberCount: 2,
VaultCount: 1,
DateTimeOffset.UnixEpoch),
]);
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
[
new TeamMemberSummary(
OwnerId,
"alexandra.hollingsworth@dodotech.example",
"Alexandra Hollingsworth",
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch),
// The unenrolled case on purpose: its key-state phrase is the longest the column ever
// carries, and it is the row that decides whether that column is wide enough.
new TeamMemberSummary(
ColleagueId,
"bartholomew.fotheringay@dodotech.example",
"Bartholomew Fotheringay",
TeamMemberRole.Member,
TeamMemberStatus.Active,
IsEnrolled: false,
DateTimeOffset.UnixEpoch,
LastActiveAt: null),
]);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
[
// Pending, because its sentence is the long one — it has to carry the whole mechanism,
// since nothing was sent and there is nothing else on the screen that could say so.
new TeamInvitationSummary(
Guid.CreateVersion7(),
"wilhelmina.ashworth-blake@dodotech.example",
TeamMemberRole.Admin,
TeamInvitationState.Pending,
OwnerId,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddDays(14),
AcceptedAt: null),
]);
/// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
Task.FromResult(new VaultGrantsResponse(
vaultId,
KeyGeneration: 2,
RekeyRequired: true,
Grants:
[
new VaultGrantSummary(
OwnerId,
"alexandra.hollingsworth@dodotech.example",
"Alexandra Hollingsworth",
KeyGeneration: 2,
VaultGrantState.Active,
OwnerId,
DateTimeOffset.UnixEpoch,
RevokedAt: null),
// A generation behind, so the "stale" phrasing is the one being measured rather than
// the two-word happy case.
new VaultGrantSummary(
ColleagueId,
"bartholomew.fotheringay@dodotech.example",
"Bartholomew Fotheringay",
KeyGeneration: 1,
VaultGrantState.Active,
OwnerId,
DateTimeOffset.UnixEpoch,
RevokedAt: null),
]));
/// <inheritdoc />
public Task<TeamSummary> CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public void Dispose()
{
// Nothing held.
}
}
@@ -206,6 +206,69 @@ public sealed class TerminalTabsTests : IAsyncLifetime
});
}
/// <summary>
/// The three fixed tabs select what they name, and Vaults comes back to the page it was left on.
/// </summary>
/// <remarks>
/// <para>
/// The memory is the part worth a gesture rather than a property assertion. Vaults is the one tab with
/// sub-navigation, so it is the one that can come back to the wrong place — and the failure is silent:
/// a Vaults tab that always landed on Hosts looks like a working tab to anybody who was already on
/// Hosts, which is most of the time.
/// </para>
/// <para>
/// Driven through the strip rather than through the commands, because what is being checked is that
/// three buttons in the markup are wired to three different things. Three commands called directly
/// would pass on a strip whose SFTP tab was bound to the S3 one.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage()
{
await OnTheStripAsync((strip, window) =>
{
shell.ShowScreenCommand.Execute(ShellScreen.Snippets);
shell.IsVaultsTab.ShouldBeTrue("a rail screen is under the Vaults tab");
Click(FixedTab(strip, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
shell.IsVaultsTab.ShouldBeFalse("exactly one tab is lit at a time");
Click(FixedTab(strip, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Click(FixedTab(strip, "Vaults"), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.Screen.ShouldBe(
ShellScreen.Snippets,
"the Vaults tab comes back to the page it was left on, not to Hosts");
});
}
/// <remarks>
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
/// SFTP.
/// </remarks>
[Fact]
public async Task TheFixedTabsCarryNoCloseBox()
{
await OnTheStripAsync((strip, _) =>
{
foreach (var label in new[] { "Vaults", "SFTP", "S3" })
{
FixedTab(strip, label)
.GetVisualDescendants()
.OfType<Button>()
.ShouldBeEmpty($"{label} is a destination, not a session");
}
});
}
/// <remarks>
/// The strip is the one row of chrome every screen pays for, so its height is part of the layout budget
/// and this is what stops the budget drifting from the markup. See
@@ -352,6 +415,28 @@ public sealed class TerminalTabsTests : IAsyncLifetime
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
/// <summary>One of the three tabs that are always there, found by the word on it.</summary>
/// <remarks>
/// By its label rather than by its position in the strip, so that adding a fourth or reordering the
/// three does not silently point these tests at the wrong one. The class narrows it to a fixed tab
/// first, because a terminal tab could be opened on a host called SFTP.
/// </remarks>
private static Button FixedTab(Visual strip, string label) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("fixed")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static void Click(Visual control, Window window)
{
var at = Centre(control, window);
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
@@ -29,6 +29,7 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = [];
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
/// <inheritdoc />
public ITeamApi Teams => this;
@@ -107,12 +108,110 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch),
];
return Task.FromResult(team);
}
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such team.");
}
// The slug is deliberately not touched, matching the server: a rename changes the display
// name only. A fake that also moved the slug would let a test assert behaviour nothing has.
teams[index] = teams[index] with
{
Name = request.Name,
Description = request.Description,
};
return Task.FromResult(teams[index]);
}
/// <inheritdoc />
/// <remarks>
/// The vault refusal is reproduced rather than skipped, unlike the other server rules here. It is
/// the one whose consequence the shell has to render — a status line explaining why nothing
/// happened — so a fake that always succeeded would leave that path untested.
/// </remarks>
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
return Task.FromResult(false);
}
if (teamVaults.Values.Any(vault => vault.TeamId == teamId))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.Conflict,
ProblemCodes.TeamNotEmpty,
"This team still owns vaults, and archiving it would take them away from everybody "
+ "holding a key — including you.");
}
teams.RemoveAt(index);
members.Remove(teamId);
invitations.Remove(teamId);
return Task.FromResult(true);
}
/// <inheritdoc />
/// <remarks>
/// Both rows move, because a fake that only promoted the recipient would let a test pass while
/// the team was owned twice — which is the exact failure the real service uses a transaction to
/// make impossible.
/// </remarks>
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken)
{
var list = members.GetValueOrDefault(teamId, []);
var incoming = list.FindIndex(member => member.UserId == request.UserId);
if (incoming < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
"That account is not an active member of this team.");
}
var outgoing = list.FindIndex(member => member.Role == TeamMemberRole.Owner);
list[incoming] = list[incoming] with { Role = TeamMemberRole.Owner };
if (outgoing >= 0)
{
list[outgoing] = list[outgoing] with { Role = TeamMemberRole.Admin };
}
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index >= 0)
{
teams[index] = teams[index] with { Role = TeamMemberRole.Admin };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
@@ -132,6 +231,8 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
ProblemCodes.InvalidTeam,
"No such account on this server.");
// LastActiveAt is left null: this account has been added, not seen. The owner's row carries a
// real one, so both branches of the interface's "last active / never" split are exercised.
var member = new TeamMemberSummary(
entry.UserId,
entry.Email,
@@ -139,7 +240,8 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
request.Role,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch);
DateTimeOffset.UnixEpoch,
LastActiveAt: null);
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
@@ -148,6 +250,69 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return Task.FromResult(member);
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
invitations.TryGetValue(teamId, out var list) ? [.. list] : []);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
if (list.Exists(invitation =>
invitation.State == TeamInvitationState.Pending
&& string.Equals(invitation.Email, request.Email, StringComparison.OrdinalIgnoreCase)))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeamInvitation,
"There is already an invitation to that address for this team.");
}
var invited = new TeamInvitationSummary(
request.InvitationId,
request.Email,
request.Role,
TeamInvitationState.Pending,
UserId,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddDays(14),
AcceptedAt: null);
invitations[teamId] = [.. list, invited];
return Task.FromResult(invited);
}
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
var index = list.FindIndex(invitation =>
invitation.InvitationId == invitationId
&& invitation.State == TeamInvitationState.Pending);
if (index < 0)
{
return Task.FromResult(false);
}
// Kept and marked rather than removed, as the server keeps it: the screen has to be able to
// say an invitation was withdrawn rather than letting it vanish and read as never sent.
list[index] = list[index] with { State = TeamInvitationState.Revoked };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
@@ -305,20 +305,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// application is not running.
/// </para>
/// <para>
/// Both branches are written out rather than compared against the constant itself. Asserting a
/// constant against itself would pass however it were edited, and the whole point of this test is
/// that a release build must not ship a developer's loopback address — or, since the split, that a
/// debug build must not point a clone at production.
/// The address is written out rather than compared against the constant itself. Asserting a constant
/// against itself would pass however it were edited, and the whole point of this test is that no
/// build ships a developer's loopback address.
/// </para>
/// </remarks>
[Fact]
public void TheDefaultServerUrl_IsTheHostedDeployment_ExceptInADebugBuild()
public void TheDefaultServerUrl_IsTheHostedDeployment_InEveryBuild()
{
#if DEBUG
shell.ServerUrl.ShouldBe("http://localhost:5233");
#else
shell.ServerUrl.ShouldBe("https://ssh.dodotech.cloud");
#endif
}
[Theory]
@@ -2882,6 +2877,123 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding");
}
// ---- Remembering a typed password ----
[Fact]
public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain()
{
var vault = await ReadyToConnectAsync();
vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision");
vault.ConnectPassword = "s3cret";
vault.RemembersConnectPassword = true;
await ConnectAndRememberAsync(vault);
// An ordinary keychain credential, named after the host, and carrying no username of its own — the
// connection that just succeeded used the host's, and pinning a copy of it here would stop following
// the host.
var stored = vault.Credentials.ShouldHaveSingleItem();
stored.Label.ShouldBe("prod-db");
stored.Credential.Password.ShouldBe("s3cret");
stored.Credential.Username.ShouldBeNull();
var host = vault.Hosts.ShouldHaveSingleItem();
host.Host.CredentialId.ShouldBe(stored.EntityId);
host.Authentication.ShouldBe("credential");
// The box has nothing left to hold and nothing left to ask, and the tick does not carry over to
// whatever host is selected next.
vault.ConnectPassword.ShouldBeEmpty();
vault.RemembersConnectPassword.ShouldBeFalse();
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
}
[Fact]
public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection()
{
// The whole point of storing it in the vault rather than on this machine: it is a property of the
// host that reaches the other machines, not a box this one happens to remember filling in.
var vault = await ReadyToConnectAsync();
// One renderer for both connections. The page's token is spent on the first attach, so a second
// FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason
// nothing else in this suite connects twice.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ConnectPassword = "s3cret";
vault.RemembersConnectPassword = true;
await vault.ConnectCommand.ExecuteAsync(null);
await vault.SyncCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");
vault.SelectedHost = vault.Hosts[0];
vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now");
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.Count.ShouldBe(2);
ssh.Requests[1].Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
}
[Fact]
public async Task ARefusedConnection_RemembersNothing()
{
// The failure this feature could most easily cause: a typo bound to the host, which then stops asking
// and cannot be connected to until somebody works out that the keychain is where the wrong password
// now lives. Only a handshake the remote accepted is worth keeping.
var vault = await ReadyToConnectAsync();
ssh.Failure = new InvalidOperationException("authentication failed");
vault.ConnectPassword = "wrong";
vault.RemembersConnectPassword = true;
await vault.ConnectCommand.ExecuteAsync(null);
vault.Credentials.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
}
[Fact]
public async Task ConnectingWithoutTheTick_StoresNothing()
{
// The other half of the decision, and the reason the typed box still exists: a one-off password on a
// machine somebody will never open again must not end up synchronised to every device they own.
var vault = await ReadyToConnectAsync();
vault.ConnectPassword = "s3cret";
await ConnectWithRendererAsync(vault);
vault.Credentials.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed");
}
[Fact]
public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword()
{
// A tick left over from a host that did ask must not manufacture a credential out of a stored one's
// password — which is what reading the dialled secret without checking the binding would do.
var vault = await ReadyToConnectAsync();
await AddCredentialAsync(vault, "prod deploy", password: "s3cret");
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
vault.SelectedHost = vault.Hosts[0];
vault.RemembersConnectPassword = true;
await ConnectWithRendererAsync(vault);
vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain");
}
/// <remarks>
/// The reason the picker is one control rather than two. <c>HostSecret.TryValidate</c> refuses a host naming
/// both a key and a credential, so two pickers would have been able to express the state and would have had
@@ -5309,6 +5421,24 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("Connected", Case.Insensitive);
}
/// <summary>
/// The same, for a connection that is expected to store its password.
/// </summary>
/// <remarks>
/// Without the status assertion, and that is the whole reason it is separate. Remembering writes two
/// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own
/// count — leaving "Connected" true of what happened and false of what the line says. What the connection
/// actually did is asserted on the vault, which is where it is durable.
/// </remarks>
private async Task ConnectAndRememberAsync(VaultViewModel vault)
{
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
}
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
private async Task<VaultViewModel> ReadyToConnectAsync()
{
@@ -6,6 +6,7 @@ using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
@@ -273,6 +274,259 @@ public sealed class TeamSharingTests : IAsyncLifetime
.ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
}
/// <remarks>
/// The screen's answer to "who can actually open this", which until now it could not give at all —
/// the endpoint existed and nothing called it. Asserted after a share rather than before, because
/// an empty list proves nothing about whether the call was made.
/// </remarks>
[Fact]
public async Task SelectingATeamVault_ListsWhoHoldsAKeyToIt()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
await teams.ShareVaultCommand.ExecuteAsync(null);
// Selecting the vault again is what drives the read; the share above happened after the
// previous selection had already loaded an empty list.
teams.SelectedVault = null;
teams.SelectedVault = teams.Vaults[0];
var holder = teams.Grants.ShouldHaveSingleItem();
holder.UserId.ShouldBe(colleague);
holder.IsLive.ShouldBeTrue(teams.Status);
holder.State.ShouldBe("holds a key");
}
/// <remarks>
/// A role change is authorization only. The status line has to say so, because the obvious reading
/// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
/// key they were already wrapped. Withdrawing that is a separate act.
/// </remarks>
[Fact]
public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
teams.Status.ShouldContain("does not withdraw a vault key");
}
/// <remarks>
/// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
/// itself rather than letting the server do it: a button that produced a server error would be
/// reporting a rule the screen already knew.
/// </remarks>
[Fact]
public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
teams.Status.ShouldContain("HAND OVER");
}
/// <remarks>
/// <para>
/// Both halves, because a transfer that only promoted the recipient would leave the team owned
/// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
/// a single transaction to make impossible, so the client test asserts the same pair.
/// </para>
/// <para>
/// It also goes through the armed confirmation rather than calling the command directly, since
/// arming and confirming are where the target id is carried — and carrying it on the selection
/// instead is how a confirmation ends up applied to whatever was clicked last.
/// </para>
/// </remarks>
[Fact]
public async Task HandingOverATeam_MakesThemTheOwnerAndTheCallerAnAdmin()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.TransferOwnershipCommand.Execute(null);
teams.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
teams.ShowsTeamActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
teams.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
teams.IsConfirming.ShouldBeFalse();
}
/// <remarks>
/// Archiving is refused while the team owns a vault, and the refusal has to reach the screen. The
/// failure this guards is the quiet one: a client that swallowed the 409 and reloaded would show a
/// team that is still there with no explanation of why nothing happened.
/// </remarks>
[Fact]
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndSaysWhy()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldContain(team => team.Slug == "platform");
teams.Status.ShouldContain("holding a key");
}
/// <remarks>
/// An empty team can go, and this is the only operation on the screen that removes something from
/// everybody's list at once.
/// </remarks>
[Fact]
public async Task ArchivingAnEmptyTeam_RemovesIt()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldNotContain(team => team.Slug == "platform");
teams.Status.ShouldContain("Archived");
}
/// <remarks>
/// Renaming leaves the slug alone, and the status line says so unprompted — somebody who assumed
/// otherwise would find out from a URL much later, which is the worst moment to find out.
/// </remarks>
[Fact]
public async Task RenamingATeam_LeavesItsSlugAlone()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.RenameTeamCommand.Execute(null);
teams.EditTeamName = "Platform Engineering";
await teams.SaveTeamCommand.ExecuteAsync(null);
var team = teams.Teams.ShouldHaveSingleItem();
team.Name.ShouldBe("Platform Engineering");
team.Slug.ShouldBe("platform");
teams.Status.ShouldContain("slug is still 'platform'");
}
/// <remarks>
/// <para>
/// The address the directory does not know used to be a dead end — the screen said they had to sign
/// in first and stopped. It invites them instead, from the same button, because which of the two
/// applies is a fact about the server's account table rather than about what the user is doing.
/// </para>
/// <para>
/// The status assertion is the point of the test. Nothing is sent, and an interface that said
/// "invited" without saying that would leave somebody waiting for an email that is never coming.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
var invitation = teams.Invitations.ShouldHaveSingleItem();
invitation.Email.ShouldBe("newcomer@example.com");
invitation.IsPending.ShouldBeTrue();
invitation.State.ShouldContain("Nothing was sent");
teams.Status.ShouldContain("cannot send mail");
}
/// <remarks>
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
/// disappeared would read as never having been sent, which is the same thing the screen looks like
/// before anybody does anything.
/// </remarks>
[Fact]
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedInvitation = teams.Invitations.ShouldHaveSingleItem();
await teams.RevokeInvitationCommand.ExecuteAsync(null);
teams.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
teams.Status.ShouldContain("Withdrew the invitation");
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);
@@ -468,6 +468,108 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
UpdatedAtUtc = Now,
};
// ---- Team invitations ----
/// <remarks>
/// One live invitation per address per team. Without the index two admins acting a minute apart
/// would each leave a row, and the claim at sign-in would apply both — quietly overwriting whichever
/// role was decided second with whichever was written first.
/// </remarks>
[Fact]
public async Task Invitation_IsUniquePerTeamAndAddress()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
/// <remarks>
/// The citext proof, and it is load-bearing rather than tidy: the address in an invitation is typed
/// by a person and the one on the token is chosen by the identity provider, so a column that
/// compared them case-sensitively would let <c>Alice@</c> and <c>alice@</c> be two invitations and
/// would make the claim at sign-in miss the one that was actually sent.
/// </remarks>
[Fact]
public async Task Invitation_IsCaseInsensitivelyUniquePerTeam()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"Invite{Guid.CreateVersion7():N}@Example.com";
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToUpperInvariant(), user.Id));
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToLowerInvariant(), user.Id));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
/// <remarks>
/// The other half of the filter. Withdrawing an invitation and issuing a fresh one — at a different
/// role, say — has to be possible, so the uniqueness is among live rows rather than all of them, and
/// the withdrawn row stays for the history.
/// </remarks>
[Fact]
public async Task Invitation_MayBeReissuedAfterItIsRevoked()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
var first = NewInvitation(team.Id, email, user.Id);
context.TeamInvitations.Add(first);
await context.SaveChangesAsync();
first.RevokedAtUtc = Now;
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await Should.NotThrowAsync(() => context.SaveChangesAsync());
}
/// <remarks>
/// An accepted invitation frees the slot too, which is what lets somebody removed from a team be
/// invited back. The claim marks the old row accepted rather than deleting it, so without this the
/// second invitation would collide with a row that has already done its job.
/// </remarks>
[Fact]
public async Task Invitation_MayBeReissuedAfterItIsAccepted()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
var first = NewInvitation(team.Id, email, user.Id);
context.TeamInvitations.Add(first);
await context.SaveChangesAsync();
first.AcceptedAtUtc = Now;
first.AcceptedByUserId = user.Id;
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await Should.NotThrowAsync(() => context.SaveChangesAsync());
}
private static async Task<UserAccount> SeedUserAsync(DodoDbContext context)
{
var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
@@ -568,4 +670,15 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
ActorUserId = Guid.CreateVersion7(),
OccurredAtUtc = Now,
};
private static TeamInvitation NewInvitation(Guid teamId, string email, Guid invitedBy) => new()
{
Id = Guid.CreateVersion7(),
TeamId = teamId,
Email = email,
Role = TeamRole.Member,
InvitedByUserId = invitedBy,
CreatedAtUtc = Now,
ExpiresAtUtc = Now.AddDays(14),
};
}