Public Access
Merge main into the phone connections branch
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:
+172
-20
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user