Merge branch 'main'
ci / build and test (push) Successful in 2m3s
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 45s
ci / api image (push) Successful in 33s

Two of main's changes land in files this branch rewrote, and both needed carrying
across by hand rather than by the merge.

The phone's nav staying up on Connections with nothing running is a fourth input
to RefreshChrome, which this branch had already given two more — whether hosts are
ticked and whether the host editor is filling the screen. They compose: the rail
and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped
terminal surface keeps its way off the screen and the editor still takes the whole
display.

The key question under the host's move panel is the harder one, because this
branch deleted the panel it was added to. The connect card is gone and the phone's
only route to a move is the action bar, so leaving the merge to take this side
would have removed a capability main had just shipped — silently, since nothing
would fail to build. It is asked in the action bar's own picker instead, in two
shapes fewer than the desktop's: one host, because which key to carry is a fact
about one machine and a selection of six has six answers, and a move rather than a
copy, because taking the key out from under an original that is staying put would
leave that original unable to connect. BindingOfTheMovingHost splits into
MovableBindingOf so both heads answer it the same way from different panels.

Main also fixed a real trap in the same commit — a host that only inherited its
key from its group arrived in the destination naming nothing at all, because the
group stays behind — and the batch move had the same bug for the same reason. It
goes through Detached now, which is where that fix lives.

The carried host is written as the carry left it rather than being detached again,
which is the one thing worth measuring: the key takes a new id over there, so a
run that rebuilt the payload from the row would send the machine across naming a
tombstone. Both directions are pinned, along with the rule about which shapes the
question is asked in at all.
This commit is contained in:
2026-08-06 09:30:00 +02:00
20 changed files with 2530 additions and 113 deletions
+22 -2
View File
@@ -424,8 +424,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port. vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
What a move cannot do is reach a machine that has already synced the host, which is the same limit What a move cannot do is reach a machine that has already synced the host, which is the same limit
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
to" picker on the Keychain screen and cannot be moved yet. Keychain screen and cannot be moved yet.
**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
every group lending it as a default, across every vault you can write to. Without that the move would be a
deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
afterwards. The panel says what points at the key before you press it, not after.
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group **A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
+10 -3
View File
@@ -568,9 +568,16 @@ go at 360dp:
believing they typed an `l`. believing they typed an `l`.
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip **The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three the session pills, and a `+` raising a sheet with the three connections there are.
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
*A shell rather than the surface, and the two parted company once that surface gained a connect page.*
With nothing running, Connections is a box, a CONNECT button and the machines connected to before — a
page in everything but which enum it is in — so `RefreshChrome` keeps the bar (and, wide, the rail) under
it and the Connections entry lights for the first time. It is the one screen reachable by closing your
last tab, and collapsing the nav there left the system back gesture as the only route to Hosts or
Settings. The header is not part of that: the surface draws its own bar, and a vault header above it is
the second row of chrome this head exists to avoid. That sheet is the head's first control that could be drawn over the renderer, so
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
WebView actually composites above Avalonia content is still the unverified question recorded below, and WebView actually composites above Avalonia content is still the unverified question recorded below, and
collapsing is correct under either answer. collapsing is correct under either answer.
+60 -3
View File
@@ -446,6 +446,31 @@ target lands. Anything still in the source vault is a partial move, which is sur
not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves
hosts behind and never a shelf with nothing on it. hosts behind and never a shelf with nothing on it.
### 3.3b Moving a key, and moving a host with its key · **needs a second vault**
In your personal vault: add an SSH key, then two hosts that both authenticate with it. On the Keychain
screen select the key and press **MOVE**, and read the panel before choosing the shared vault.
**Pass:** the panel says what uses the key — "Used by 2 hosts…" — before anything happens. Afterwards the key
carries the destination's badge under an id it did not have a moment ago, *and both hosts still say `key`
under their names* and still connect. The sentence names the vault and the two hosts that followed it.
Then the other direction: with a key back in your personal vault and a host bound to it, choose **Move to
another vault…** on the host and pick the shared vault. The tick box under the picker offers to bring the
key, unticked, with the count of what else uses it underneath. Leave it and press MOVE; then move the host
back, tick it, and press MOVE again.
**Pass:** unticked, the host lands in the shared vault and the status line says the key it authenticates with
is in another vault and will not resolve for anybody else there. Ticked, the key lands in the shared vault
too and the host still says `key`. Choosing a *different* vault in the picker re-asks the question, and the
box disappears when the destination is the vault the key is already in.
**Failure means:** a host that says `password` after either move is the re-aim not having happened — the item
takes a new id in the destination, so every host bound to it and every group lending it has to be rewritten
as it lands, and a host bound to something its vault no longer holds refuses to connect rather than falling
back to a typed password. A tick box that is ticked when the panel opens is worse than a bug: it moves a
private key into a shared vault on the strength of a decision nobody made.
### 3.4 A group deleted on another machine · **needs two machines** ### 3.4 A group deleted on another machine · **needs two machines**
Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync
@@ -1114,6 +1139,29 @@ with both hosts and both ticks still there.
"delete prod-db?" is not a confirmation anybody reads. A question that cleared the ticks on KEEP is a "delete prod-db?" is not a confirmation anybody reads. A question that cleared the ticks on KEEP is a
selection thrown away for declining to destroy it. selection thrown away for declining to destroy it.
### 8.14a The key question is asked for one host and for a move · **needs two writable vaults**
With a host bound to a key that is in the vault it is leaving, tick that host alone and choose **Move to
vault**.
**Pass:** under the picker, an unticked box offering to bring the key, with the count of what else uses it
underneath. Choosing a different destination re-asks it, and it disappears when the destination is the vault
the key is already in — the same behaviour as the desktop's, which 3.3 measures.
Now choose **Copy to vault** on the same host.
**Pass:** no box at all. A copy that took the key away would leave the original — still sitting in the vault
it started in — unable to connect, which is the one thing "copy" promises not to do.
Tick a second host and open **Move to vault** again.
**Pass:** still no box. Which key to carry is a fact about one machine, and a selection of six has six
answers.
**Failure means:** a box under a copy is `ChosenBindingToBring` no longer reading
`ChosenHostsAreBeingCopied`, and it moves a private key out from under a host that is still using it. A box
that is ticked when the panel opens is worse than a bug for the reason 3.3 gives.
### 8.15 A tap connects, and a long press chooses · **the one worth the most care** ### 8.15 A tap connects, and a long press chooses · **the one worth the most care**
On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once. On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once.
@@ -1320,12 +1368,13 @@ headless surface has a native view to answer with. `docs/android-port.md` still
Open a shell from HOSTS. Open a shell from HOSTS.
**Pass:** the vault header, the session strip and the four-entry bottom bar are all gone. What is left is **Pass:** the vault header, the session strip and the three-entry bottom bar are all gone. What is left is
one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
back: all three come back, the tab is still in the strip and its dot is still green. back: all three come back, the tab is still in the strip and its dot is still green.
**Failure means:** one of the three rows is not bound on `IsShowingPages`, or the terminal is being reached **Failure means:** the strip is not bound on `IsShowingPages`, `RefreshChrome` is not reading `HasTabs`, or
by a route that leaves `Surface` on `Page`. the terminal is being reached by a route that leaves `Surface` on `Page`. A bar still there **with a shell
showing** is the one that matters: that is the third of the screen this arrangement exists to give back.
### 11.2 The connect menu is not drawn over the renderer · **the important one** ### 11.2 The connect menu is not drawn over the renderer · **the important one**
@@ -1391,6 +1440,14 @@ Close every tab, then press Connections in the bottom bar.
keychain has ever connected to anything — a RECENT list underneath. The whole thing scrolls with the keychain has ever connected to anything — a RECENT list underneath. The whole thing scrolls with the
keyboard up, and the password box shows dots with **no suggestion strip** above the keyboard. keyboard up, and the password box shows dots with **no suggestion strip** above the keyboard.
**And the bottom bar is still there, with Connections lit.** Press Hosts: it goes straight there, without
the back gesture. Then open a shell — the bar goes, which is 11.1. Wide, the same holds for the rail.
**Failure means:** for the bar, `RefreshChrome` is back to collapsing the nav for the whole terminal
surface rather than for a shell — which strands anybody who reaches this screen by closing their last tab.
For an unlit Connections entry, `IsCurrent` lost its binding: on this screen it is the only true thing the
bar could say about where you are.
Type a machine you can reach and press CONNECT: a tab appears in the bar and the shell opens. Type Type a machine you can reach and press CONNECT: a tab appears in the bar and the shell opens. Type
something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning
colour, with no tab and nothing dialled. colour, with no tab and nothing dialled.
@@ -158,6 +158,28 @@
<TextBlock Classes="body" Text="{Binding ChosenHostVaultPanelNote}" /> <TextBlock Classes="body" Text="{Binding ChosenHostVaultPanelNote}" />
<!--
◆ THE KEY. A binding resolves across vaults, so the host goes on working here whichever way this
is answered — but the vault it has just joined holds one key, and a host whose own key stayed
behind is one its new colleagues cannot connect with.
Drawn only for a move of exactly one host, which is the only shape the question has an answer in:
which key to carry is a fact about one machine, and a copy that took the key away would leave the
original unable to connect. See VaultViewModel.BringsTheChosenBindingAlong.
Unticked, because moving a key into a team's vault hands it to everybody who holds that vault's
key: a disclosure is chosen, never defaulted into. The line under it is the count of what else
uses that key, which is the difference between an obvious yes and an obvious no.
-->
<CheckBox IsChecked="{Binding BringsTheChosenBindingAlong}" MinHeight="44"
IsVisible="{Binding HasAChosenBindingToBring}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding ChosenBindingToBringQuestion}" />
</CheckBox>
<TextBlock Classes="body" TextWrapping="Wrap"
IsVisible="{Binding HasAChosenBindingToBring}"
Text="{Binding ChosenBindingToBringNote}" />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" <Button Grid.Column="0" Classes="primary" Height="44"
Content="{Binding ChosenHostVaultPanelVerb}" Content="{Binding ChosenHostVaultPanelVerb}"
@@ -149,8 +149,19 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE" <!--
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
this head, for the reason a phone has: there is no room to draw a button that answers with a
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
moving it back.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
IsVisible="{Binding CanMoveSelectedItem}"
Command="{Binding MoveSelectedItemCommand}" />
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
Command="{Binding DeleteSelectedItemCommand}" /> Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -180,6 +191,47 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- ============ ◆ moving it to another vault ============ -->
<!--
The desktop's panel, in the place the deletion question uses and never at the same time as it: MOVE
disarms a pending deletion on the way in, and the buttons that ask either question are hidden while
one is up.
Both sentences are here rather than only in the status line afterwards, which on a phone is one line at
the bottom of a screen somebody has already navigated away from. The second one is the count of what
points at this key — every one of them is re-aimed at the vault it moves to, and that is the part
nobody can see from a keychain row.
-->
<Border Grid.Row="3" IsVisible="{Binding IsMovingItem}" Margin="12,4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding MovingItemLabel}" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveItemVaultChoices}"
SelectedItem="{Binding SelectedMoveItemVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body" TextWrapping="Wrap"
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
<TextBlock Classes="body" TextWrapping="Wrap"
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveItemCommand}" />
</Grid>
</StackPanel>
</Border>
<!-- ============ the items ============ --> <!-- ============ the items ============ -->
<Panel Grid.Row="4"> <Panel Grid.Row="4">
@@ -134,15 +134,17 @@
<!-- <!--
The terminal is a surface rather than a page, so it goes through its own command — the same The terminal is a surface rather than a page, so it goes through its own command — the same
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
destination as current is the surface filling the screen. this surface is Connections with nothing running. That screen is a page in everything but which
enum it is in, and a rail sitting beside it with no entry lit would be pointing nowhere.
No count, unlike the two above it. The shells strip sits above the bottom of every page on this No count, unlike the two above it. The shells strip sits above the bottom of every page on this
surface and is that count, drawn as the sessions themselves — a number beside this word would be surface and is that count, drawn as the sessions themselves — a number beside this word would be
the same fact said twice, three inches apart. the same fact said twice, three inches apart.
--> -->
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}"> <Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
@@ -25,8 +25,9 @@
── a terminal gets the screen ───────────────────────────────────────────────────────────────────────── ── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
surface is either a page or a terminal, and these are the chrome a page has. computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not. 360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
@@ -316,10 +317,15 @@
<!-- ============ navigation ============ --> <!-- ============ navigation ============ -->
<!-- <!--
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's three Gone while a shell is showing, which is the whole of that surface's arrangement: the bar's three
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
own bar. See TerminalScreen.axaml. own bar. See TerminalScreen.axaml.
◆ A shell rather than the surface, which are not the same question — Connections with nothing running
is that surface drawing a page, and the bar stays under it. The screen it stands down for is the one
that pays for the room; a box asking which machine to connect to does not, and taking the nav away
from somebody who has just closed their last tab left them with only the back gesture.
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag ◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar. the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
@@ -348,12 +354,15 @@
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's. tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed ◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
would be a rule about a state this control cannot be in. What marks it as current is the surface separates them: false for every page, true for the one screen where this is where you are. With a
filling the screen. shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
bar can now sit under and not point at is the entry looking broken instead.
--> -->
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗" <views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" /> Command="{Binding ShowTerminalCommand}" />
<!-- <!--
@@ -146,10 +146,19 @@ internal sealed partial class PhoneShell : UserControl
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide)); AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
/// <summary>Whether the rail down the left edge is drawn.</summary> /// <summary>Whether the rail down the left edge is drawn.</summary>
/// <remarks>
/// The wide surface's answer to <see cref="ShowsBottomBarProperty"/> and asks the same question about
/// which screen is up, so the two move together — including over Connections with nothing running. See
/// <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsRailProperty = public static readonly StyledProperty<bool> ShowsRailProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail)); AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary> /// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
/// <remarks>
/// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal
/// surface drawing a page. See <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsBottomBarProperty = public static readonly StyledProperty<bool> ShowsBottomBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar)); AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
@@ -212,12 +221,21 @@ internal sealed partial class PhoneShell : UserControl
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's /// ◆ <b>Five flags computed here rather than five conditions in the markup, because Avalonia's
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this /// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would /// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
/// have to be an "or", which a wrapper cannot express at all. /// have to be an "or", which a wrapper cannot express at all.
/// </para> /// </para>
/// <para> /// <para>
/// ◆ <b>The nav stands down for a shell rather than for the terminal surface, and those parted company
/// when that surface gained a page.</b> Connections with nothing running is a box, a CONNECT button and
/// the machines connected to before — see TerminalScreen.axaml — and none of that is worth the screen a
/// shell is worth it for. It is also the one screen somebody can arrive at by closing their last tab,
/// which made the collapsed bar a way to end up with no route to Hosts or Settings but the system back
/// gesture. The header is deliberately not part of this: the surface draws its own bar with the back
/// arrow and the +, and the vault header above that is the second row this head exists to avoid.
/// </para>
/// <para>
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the /// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this /// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail /// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
@@ -245,6 +263,14 @@ internal sealed partial class PhoneShell : UserControl
var wide = body.Bounds.Width >= WideAt; var wide = body.Bounds.Width >= WideAt;
var pages = shell?.IsShowingPages == true; var pages = shell?.IsShowingPages == true;
// ◆ Connections with nothing running, which is the terminal surface drawing a page: a box, a CONNECT
// button and the machines connected to before. The nav stands down for a shell — the whole of the
// arrangement below — and there is no shell here to stand down for, so it stays. Taking it away on
// this one screen was worst where it was least affordable: somebody who has just closed their last
// tab, or who pressed Connections to see what was open and found nothing, was left on a screen whose
// only way to Hosts or Settings was the system back gesture.
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header, // The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which // not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
// is the one control it needs and the one the system gesture already maps to. // is the one control it needs and the one the system gesture already maps to.
@@ -266,8 +292,8 @@ internal sealed partial class PhoneShell : UserControl
} }
IsWide = wide; IsWide = wide;
ShowsRail = wide && pages && !editing; ShowsRail = wide && (pages || connectPage) && !editing;
ShowsBottomBar = !wide && pages && !editing; ShowsBottomBar = !wide && (pages || connectPage) && !editing;
ShowsShellStrip = pages && !editing; ShowsShellStrip = pages && !editing;
ShowsHostSelectionBar = pages && choosing && !editing; ShowsHostSelectionBar = pages && choosing && !editing;
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true); ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
@@ -64,6 +64,26 @@
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" /> <TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
◆ Which vault a *new* snippet is filed into. Hidden for an existing one — its vault is not a
field of this form, and changing it is MOVE below — and hidden entirely where there is only one
vault to choose between, which is where most people stay.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command" <TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
Height="120" AcceptsReturn="True" TextWrapping="Wrap" Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Padding="14,10" /> VerticalContentAlignment="Top" Padding="14,10" />
@@ -125,6 +145,15 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" /> <TextBlock Text="{Binding Badge}" />
</Border> </Border>
<!--
◆ Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert into their own terminals,
and this card is the only place that fact appears before somebody taps EDIT.
-->
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
</StackPanel> </StackPanel>
<!-- The command, on the surface every block of monospace in this design is drawn on. --> <!-- The command, on the surface every block of monospace in this design is drawn on. -->
@@ -151,6 +180,7 @@
IsEnabled="{Binding !IsEditing}"> IsEnabled="{Binding !IsEditing}">
<StackPanel Spacing="9"> <StackPanel Spacing="9">
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}" <Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
IsEnabled="{Binding CanInsert}" /> IsEnabled="{Binding CanInsert}" />
@@ -162,9 +192,54 @@
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT" <Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" /> Command="{Binding EditCommand}" />
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE" <!--
Command="{Binding DeleteCommand}" /> ◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
draws too: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do. It shows only where there is somewhere to
move to; see SnippetsViewModel.CanMove.
-->
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
IsVisible="{Binding CanMove}" Command="{Binding MoveCommand}" />
</Grid> </Grid>
<!--
◆ A row of its own, under the pair rather than beside them, as the hosts screen puts it. A phone
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary
one is where a thumb lands.
-->
<Button Classes="danger" Height="44" Content="DELETE" Command="{Binding DeleteCommand}" />
</StackPanel>
<!--
◆ MOVING THE SNIPPET TO ANOTHER VAULT, in the place the insert controls were. A picker and two
buttons rather than a question with a yes: what is being asked is which vault, and a move is undone
by moving it back.
The sentence is not decoration. Unlike a host, a snippet crosses whole — there is no group and no
tag to leave behind — so what there is to say is who can read it afterwards; and on a phone, where
the status line is one line at the bottom of a screen somebody has already navigated away from,
before the tap is the only place that reliably gets read.
-->
<StackPanel Spacing="10" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveCommand}" />
</Grid>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -19,9 +19,13 @@
── the screen a shell gets ─────────────────────────────────────────────────────────────────────────── ── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them three-entry bottom bar are all collapsed by PhoneShell while a shell is showing, and what replaces them
is the row below: back, the sessions, and the way to open another one. is the row below: back, the sessions, and the way to open another one.
With nothing running it is only the header and the strip. This surface is then the connect page below —
a box and the machines connected to before — which is not what the screen was being cleared for, and is
the one screen reachable by closing your last tab, so the nav stays. See PhoneShell.RefreshChrome.
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
@@ -389,9 +393,10 @@
of them. of them.
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way but this surface raises it. The scrim reaching only the screen area is the full display while a shell
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
display. that lands on the add sheet's own answer: the three destinations under the scrim stay tappable, and for
a menu of places to connect from, navigating away is a perfectly good way to decide not to.
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu: Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
each of the three destinations is a picker, and the shell they open lands back on this surface as a each of the three destinations is a picker, and the shell they open lands back on this surface as a
@@ -689,6 +689,26 @@
</ComboBox> </ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." /> Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
<!--
◆ THE KEY, WHICH IS THE HALF THE SENTENCE ABOVE CANNOT PROMISE. A binding resolves across
vaults, so the host goes on working here either way — but the colleagues it has just joined
hold one vault's key, and a host whose key stayed behind is one they cannot connect with.
Unticked, and it has to be: moving a key into a team's vault hands it to everybody who holds
that key. The note under it is the count, because a key twenty machines authenticate with is a
different decision from one nothing else uses, and neither is visible from here otherwise.
-->
<CheckBox IsChecked="{Binding BringsTheBindingAlong}"
IsVisible="{Binding HasABindingToBring}"
ToolTip.Tip="Moves the key or password itself into the same vault, and re-aims every host and group that used it at where it has gone.">
<TextBlock Text="{Binding BindingToBringQuestion}" Classes="hint" FontSize="12"
TextWrapping="Wrap" />
</CheckBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasABindingToBring}"
Text="{Binding BindingToBringNote}" />
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}" <Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
@@ -297,9 +297,50 @@
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0" <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding ShowsItemActions}"> IsVisible="{Binding ShowsItemActions}">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" /> <Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
<!--
Only where there is somewhere to move to, unlike EDIT beside it, which is the same rule the
host's MOVE follows on the phone: a button that answers with "this is the only vault you can
write to" is a button that should not have been drawn. Keys and passwords only — a tag and a
bucket are read from the active vault alone, so "another vault" is not a question they have.
-->
<Button Classes="ghost" Content="MOVE" Command="{Binding MoveSelectedItemCommand}"
IsVisible="{Binding CanMoveSelectedItem}"
ToolTip.Tip="Re-encrypts this under another vault's key, and re-aims every host and group that used it at where it has gone." />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" /> <Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel> </StackPanel>
<!--
◆ MOVING THE ITEM TO ANOTHER VAULT, in the place those buttons were. The host's panel, over
here — see HostDrawer.axaml — and what it is for is the thing a shared vault could not do until
now: a key typed into a personal vault before the team existed was stuck there, and the only
way across was to paste the private half into a second item and delete the first.
The two sentences under the picker are the whole of the decision. The first says what a move
is; the second says what points at this key, because everything that does is re-aimed at it in
its new vault and somebody moving a key twenty machines use should see the twenty first.
-->
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMovingItem}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveItemVaultChoices}"
SelectedItem="{Binding SelectedMoveItemVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveItemCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveItemCommand}" />
</StackPanel>
</StackPanel>
<!-- <!--
The public half only, and there is no button for the other one. Installing a key means pasting The public half only, and there is no button for the other one. Installing a key means pasting
this line into a host's authorized_keys; a private key on the clipboard is a private key in this line into a host's authorized_keys; a private key on the clipboard is a private key in
@@ -63,6 +63,14 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" /> <TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border> </Border>
<!--
Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert; the row is where somebody
decides whether the thing they are about to edit is theirs alone.
-->
<Border Classes="chip" Padding="4,0" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
</Border>
</StackPanel> </StackPanel>
<!-- <!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
@@ -89,6 +97,16 @@
IsEnabled="{Binding HasSelection}" /> IsEnabled="{Binding HasSelection}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}" <Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
IsEnabled="{Binding HasSelection}" /> IsEnabled="{Binding HasSelection}" />
<!--
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
inside it: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do — and a picker inside the form would let
somebody correcting a typo hand a command to a team by leaving it where they found it. The
picker itself opens beside the snippet, in the pane on the right.
-->
<Button Classes="ghost" Content="MOVE TO VAULT…" Command="{Binding MoveCommand}"
IsEnabled="{Binding CanMove}"
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -102,6 +120,27 @@
<!-- ============ The editor ============ --> <!-- ============ The editor ============ -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}"> <StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" /> <TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault is
not a field of this form, and changing it is MOVE — and not drawn at all where there is only
one vault to choose between, because a control offering one option is a question nobody asked.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<!-- <!--
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
@@ -133,6 +172,16 @@
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13" <TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" /> FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<!--
Named here as well as on the row, because this pane is where somebody decides to put a
command into a production terminal, and who else holds a key to it is part of that decision.
The row's badge is off the screen by the time this is being read.
-->
<Border Classes="chip" Padding="4,0" HorizontalAlignment="Left"
IsVisible="{Binding HasSelectionVaultBadge}">
<TextBlock Text="{Binding SelectionVaultBadge}" FontSize="9.5" />
</Border>
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" /> <TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}" <Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8"> BorderThickness="1" CornerRadius="4" Padding="8">
@@ -150,6 +199,7 @@
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
about to receive a command, at the moment that is worst to be wrong about. about to receive a command, at the moment that is worst to be wrong about.
--> -->
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0" <Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left" HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}" Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
@@ -163,6 +213,38 @@
<TextBlock Classes="hint" FontSize="11" 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." /> 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>
<!--
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker and
two buttons, not a question with a yes: what is being asked is which vault, and a move is
undone by moving it back rather than by being careful — so this is not drawn in the danger
colours the deletion question uses.
It takes the insert controls' place while it is up, for the reason the host pane hides
CONNECT: the button that opened this is still on screen otherwise, offering to open it again.
The sentence is the part worth keeping. Unlike a host, a snippet crosses whole — it has no
group and no tags to leave behind — so what there is to say is who can read it afterwards,
and for a command that may carry a hostname or a path that is the whole of the decision.
-->
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -35,6 +35,15 @@ internal sealed record InsertTarget(uint? SessionId, string Label)
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was /// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>. /// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
/// </para> /// </para>
/// <para>
/// <b>A snippet can be shared, which is why this screen has two vault controls rather than none.</b> The
/// editor asks which vault a <em>new</em> snippet goes into, and the move panel re-seals an existing one into
/// another — two controls because they are two different acts. A save writes a payload; a move re-encrypts it
/// under a second key and tombstones the first, so putting that in the editor would let somebody fixing a
/// typo hand a command to a team by leaving a picker where they found it. Both live here rather than on
/// <see cref="VaultViewModel"/> because this screen owns its editor, unlike the host pane; the writing they
/// ask for is still the vault's.
/// </para>
/// </remarks> /// </remarks>
internal sealed partial class SnippetsViewModel : ObservableObject internal sealed partial class SnippetsViewModel : ObservableObject
{ {
@@ -103,7 +112,100 @@ internal sealed partial class SnippetsViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string status = string.Empty; private string status = string.Empty;
internal bool HasSnippets => vault.Snippets.Count > 0; /// <summary>
/// The vault the open editor will write to.
/// </summary>
/// <remarks>
/// Latched when the editor opens — the chosen vault for a new snippet, the row's own vault for an
/// existing one — rather than read back off the selection at save time. The list this screen shows spans
/// every readable vault now, so a save that reached for the active vault instead would fork a colleague's
/// snippet into a private copy; and a selection that moved under a half-typed form would send the text to
/// whichever row happened to be highlighted. The same reason <c>VaultViewModel.editingHostVaultId</c>
/// exists.
/// </remarks>
private Guid editorVaultId;
/// <summary>Which vault the open move panel would send the snippet to.</summary>
private SnippetRowViewModel? moving;
/// <summary>
/// The vaults a new snippet may be filed into.
/// </summary>
/// <remarks>
/// Filled from <c>VaultViewModel.TargetVaults</c>, which is already the readable-and-writable set: a
/// vault this session cannot read has no key to encrypt with, and one it can read but not write is a
/// team vault this account is a viewer of. The options are the shared objects rather than copies, so
/// this picker and the keychain screen's show the same names without either being able to move the
/// other — what they do not share is the selection.
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? editorSelectedVault;
/// <summary>
/// Whether the editor should be asking which vault this snippet goes into.
/// </summary>
/// <remarks>
/// Only while creating, and only where there is more than one vault to choose between. An existing
/// snippet's vault is not a field of this form — moving it is a re-seal and a tombstone rather than a
/// save, offered by <see cref="Move"/> — and a control offering one option is a question nobody was
/// asked.
/// </remarks>
internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;
/// <summary>Whether the panel asking which vault to move the selected snippet to is up.</summary>
/// <remarks>
/// The armed-state idiom this application uses instead of a modal, carrying a choice rather than a yes:
/// the question is not "are you sure" but "which vault".
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
private bool isMoving;
/// <summary>Where the selected snippet could be moved: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveVault;
/// <summary>
/// Whether the selected snippet has anywhere to move to.
/// </summary>
/// <remarks>
/// Asked so the phone can leave the button out rather than draw one that answers with a refusal, as the
/// hosts screen does. It counts vaults rather than merely asking whether there are two, because the
/// answer is per snippet: one already in the only other writable vault has nowhere to go.
/// </remarks>
internal bool CanMove =>
Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
/// <remarks>
/// Off while the move panel is up, which takes their place — the same rule the host pane's
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
/// otherwise still be there offering to open it again.
/// </remarks>
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
/// <remarks>
/// For the detail pane, which is where somebody decides whether to insert a command into a production
/// terminal. Who else can read it is part of that, and the badge on the row is gone by the time the pane
/// is being read.
/// </remarks>
internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;
/// <summary>Whether there is a vault to name beside the selected snippet.</summary>
internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;
/// <summary>Whether this keychain holds any snippet the screen would draw.</summary>
/// <remarks>
/// Counts what a hidden vault leaves behind rather than the whole list, so that switching a team's vault
/// off and emptying the screen produces the "nothing saved yet" copy rather than a filter box over
/// nothing.
/// </remarks>
internal bool HasSnippets => vault.Snippets.Any(row => vault.IsVaultShown(row.VaultId));
internal bool HasVisible => Visible.Count > 0; internal bool HasVisible => Visible.Count > 0;
@@ -136,19 +238,31 @@ internal sealed partial class SnippetsViewModel : ObservableObject
+ "typing it again."; + "typing it again.";
/// <summary>Starts a new snippet.</summary> /// <summary>Starts a new snippet.</summary>
/// <remarks>
/// The vault picker lands on wherever the keychain screen is filing new items — the personal vault
/// unless that has been changed — because a snippet put in a team's vault is a command everybody in
/// that team can read, and that has to be chosen rather than defaulted into.
/// </remarks>
[RelayCommand] [RelayCommand]
private void New() private void New()
{ {
CloseMovePanel();
EditingId = null; EditingId = null;
EditorLabel = string.Empty; EditorLabel = string.Empty;
EditorCommand = string.Empty; EditorCommand = string.Empty;
EditorNotes = string.Empty; EditorNotes = string.Empty;
EditorRunsOnInsert = false; EditorRunsOnInsert = false;
BuildEditorVaultChoices(vault.TargetVaultId);
IsEditing = true; IsEditing = true;
Status = "Adding a snippet."; Status = "Adding a snippet.";
} }
/// <summary>Opens the selected snippet for editing.</summary> /// <summary>Opens the selected snippet for editing.</summary>
/// <remarks>
/// The editor writes back to the vault this row came out of, which is what the latch is for. The picker
/// is not drawn for an existing snippet: its vault is not a field of this form.
/// </remarks>
[RelayCommand] [RelayCommand]
private void Edit() private void Edit()
{ {
@@ -163,11 +277,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
return; return;
} }
CloseMovePanel();
EditingId = row.EntityId; EditingId = row.EntityId;
EditorLabel = row.Snippet.Label; EditorLabel = row.Snippet.Label;
EditorCommand = row.Snippet.Command; EditorCommand = row.Snippet.Command;
EditorNotes = row.Snippet.Notes ?? string.Empty; EditorNotes = row.Snippet.Notes ?? string.Empty;
EditorRunsOnInsert = row.Snippet.RunsOnInsert; EditorRunsOnInsert = row.Snippet.RunsOnInsert;
BuildEditorVaultChoices(row.VaultId);
IsEditing = true; IsEditing = true;
Status = $"Editing {row.Label}."; Status = $"Editing {row.Label}.";
} }
@@ -197,7 +314,8 @@ internal sealed partial class SnippetsViewModel : ObservableObject
RunsOnInsert = EditorRunsOnInsert, RunsOnInsert = EditorRunsOnInsert,
}; };
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true); var saved = await vault.SaveSnippetAsync(editorVaultId, EditingId, snippet, cancellationToken)
.ConfigureAwait(true);
if (!saved) if (!saved)
{ {
@@ -224,6 +342,105 @@ internal sealed partial class SnippetsViewModel : ObservableObject
Status = vault.Status; Status = vault.Status;
} }
/// <summary>
/// Opens the panel that asks which vault the selected snippet should move to.
/// </summary>
/// <remarks>
/// <para>
/// How a snippet gets shared: a command one person keeps becomes one the team holds a key to. A panel
/// rather than a picker in the editor, because a move is a re-seal under the destination's key and a
/// tombstone in the source — see <c>VaultItemRepository.MoveAsync</c> — and that must not happen as a
/// side effect of saving a corrected typo.
/// </para>
/// <para>
/// Refused for a snippet a newer client wrote, as editing one is, and refused with the editor open: two
/// forms about the same snippet, one of which moves it, is not something anybody should have to read
/// carefully.
/// </para>
/// </remarks>
[RelayCommand]
private void Move()
{
if (Selected is not { } row || IsEditing)
{
return;
}
if (row.IsReadOnly)
{
Status = "This snippet was written by a newer version of DodoSSH. Moving it would re-encode it "
+ "here and lose what this build cannot read. Update first.";
return;
}
var choices = vault.MoveTargetsBesides(row.VaultId);
if (choices.Count == 0)
{
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
// somebody in a team whose only other vault is read-only sees.
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
return;
}
MoveVaultChoices.Clear();
foreach (var choice in choices)
{
MoveVaultChoices.Add(choice);
}
SelectedMoveVault = MoveVaultChoices[0];
moving = row;
IsMoving = true;
Status = string.Empty;
}
/// <summary>Abandons the move panel.</summary>
[RelayCommand]
private void CancelMove()
{
CloseMovePanel();
Status = string.Empty;
}
/// <summary>
/// Moves the selected snippet into the chosen vault.
/// </summary>
/// <remarks>
/// <para>
/// <b>The snippet crosses whole.</b> Nothing on it points at an item of the vault it is leaving — a
/// snippet is a label, a command and a note — so unlike a host there is no group and no tag to strip,
/// and nothing to report as left behind.
/// </para>
/// <para>
/// The row is re-selected by its new id afterwards. A move carries the item into the destination under a
/// fresh id, so a screen that went on looking for the old one would leave the pane empty and read as the
/// snippet having been deleted.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveAsync(CancellationToken cancellationToken)
{
if (moving is not { } row || SelectedMoveVault is not { } target)
{
return;
}
CloseMovePanel();
var moved = await vault.MoveSnippetAsync(row, target, cancellationToken).ConfigureAwait(true);
// The reload inside the move refilled the list, which rebuilt this one and dropped a selection
// keyed on an id that no longer exists.
if (moved is { } entityId)
{
Selected = Visible.FirstOrDefault(candidate => candidate.EntityId == entityId);
}
Status = vault.Status;
}
/// <summary> /// <summary>
/// Types the selected snippet into the selected terminal, without pressing Enter. /// Types the selected snippet into the selected terminal, without pressing Enter.
/// </summary> /// </summary>
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
partial void OnFilterChanged(string value) => Rebuild(); partial void OnFilterChanged(string value) => Rebuild();
/// <remarks>
/// The move panel folds away with the selection it was opened about. Without that, a filter that stopped
/// matching the snippet would leave a picker on screen aimed at a row nobody can see.
/// </remarks>
partial void OnSelectedChanged(SnippetRowViewModel? value) partial void OnSelectedChanged(SnippetRowViewModel? value)
{ {
if (IsMoving && value?.EntityId != moving?.EntityId)
{
CloseMovePanel();
}
OnPropertyChanged(nameof(HasSelection)); OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert)); OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns)); OnPropertyChanged(nameof(SelectionRuns));
OnPropertyChanged(nameof(CanMove));
OnPropertyChanged(nameof(ShowsSelectionActions));
OnPropertyChanged(nameof(SelectionVaultBadge));
OnPropertyChanged(nameof(HasSelectionVaultBadge));
} }
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating)); partial void OnEditingIdChanged(Guid? value)
{
OnPropertyChanged(nameof(IsCreating));
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
}
/// <summary>
/// Moves a half-typed snippet into the vault just chosen for it.
/// </summary>
/// <remarks>
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
/// the control. An existing snippet <em>can</em> change vaults — see <see cref="Move"/> — but not this
/// way and not as part of a save: reassigning it here on an edit would write the snippet into a second
/// vault and leave the original behind, which is a fork rather than a move.
/// </remarks>
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{
if (value is null || EditingId is not null)
{
return;
}
editorVaultId = value.VaultId;
}
/// <summary>Refills the editor's vault picker, landing on the vault the editor will write to.</summary>
private void BuildEditorVaultChoices(Guid vaultId)
{
editorVaultId = vaultId;
EditorVaultChoices.Clear();
foreach (var choice in vault.TargetVaults)
{
EditorVaultChoices.Add(choice);
}
// Null where the snippet's vault is one this session cannot write — a team vault this account is a
// viewer of. The picker is hidden for an existing snippet anyway, and an empty box is a better
// answer than an option that would move the snippet if it were touched.
EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
}
private void CloseMovePanel()
{
IsMoving = false;
moving = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
}
/// <summary>Whether the editor would create a snippet rather than replace one.</summary> /// <summary>Whether the editor would create a snippet rather than replace one.</summary>
internal bool IsCreating => EditingId is null; internal bool IsCreating => EditingId is null;
@@ -315,14 +596,27 @@ internal sealed partial class SnippetsViewModel : ObservableObject
OnPropertyChanged(nameof(HasSnippets)); OnPropertyChanged(nameof(HasSnippets));
OnPropertyChanged(nameof(HasVisible)); OnPropertyChanged(nameof(HasVisible));
OnPropertyChanged(nameof(EmptyMessage)); OnPropertyChanged(nameof(EmptyMessage));
OnPropertyChanged(nameof(CanMove));
} }
/// <remarks> /// <remarks>
/// <para>
/// The command is searched as well as the name and the notes, because half of what somebody remembers /// The command is searched as well as the name and the notes, because half of what somebody remembers
/// about a saved command is a word that was in it. /// about a saved command is a word that was in it.
/// </para>
/// <para>
/// A hidden vault's snippets come off here rather than out of <c>VaultViewModel.Snippets</c>, which is
/// the rule that list follows for keys and passwords too: the projection is filtered and the list stays
/// whole. Hiding a vault is a preference about what is drawn, not about what the keychain contains.
/// </para>
/// </remarks> /// </remarks>
private bool Matches(SnippetRowViewModel row) private bool Matches(SnippetRowViewModel row)
{ {
if (!vault.IsVaultShown(row.VaultId))
{
return false;
}
var needle = Filter.Trim(); var needle = Filter.Trim();
if (needle.Length == 0) if (needle.Length == 0)
File diff suppressed because it is too large Load Diff
@@ -48,6 +48,16 @@ public sealed class CredentialRepository(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken); credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <inheritdoc cref="SshKeyRepository.MoveAsync" path="/remarks" />
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
CredentialSecret credential,
CancellationToken cancellationToken) =>
credentials.MoveAsync(fromVaultId, toVaultId, entityId, credential, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" /> /// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
credentials.DeleteAsync(vaultId, entityId, cancellationToken); credentials.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken); snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <remarks>
/// What sharing a snippet is, underneath. A snippet has no group, no tags and no key binding — see
/// <see cref="SnippetSecret"/> — so unlike a host it crosses whole: nothing about it points at an item
/// of the vault it is leaving, and there is consequently nothing to strip on the way across.
/// </remarks>
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
SnippetSecret snippet,
CancellationToken cancellationToken) =>
snippets.MoveAsync(fromVaultId, toVaultId, entityId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" /> /// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
snippets.DeleteAsync(vaultId, entityId, cancellationToken); snippets.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -47,6 +47,29 @@ public sealed class SshKeyRepository(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
keys.UpdateAsync(vaultId, entityId, key, cancellationToken); keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <remarks>
/// <para>
/// The same two writes a host's move is, and the reason a key needs one at all is what a vault is for:
/// a key created in a personal vault before a team existed is the key the team's hosts authenticate
/// with, and until this existed the only way to get it across was to paste the private half into a
/// second item and delete the first — which is a private key on a clipboard, and two items nobody can
/// tell apart afterwards.
/// </para>
/// <para>
/// <b>It lands with a new id</b>, as everything moved does, so every host and group default naming the
/// old one is left pointing at a tombstone. Re-aiming them is the caller's, because only the caller
/// knows which of them it is allowed to rewrite — see <c>VaultViewModel.ReAimAtAsync</c>.
/// </para>
/// </remarks>
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
SshKeySecret key,
CancellationToken cancellationToken) =>
keys.MoveAsync(fromVaultId, toVaultId, entityId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" /> /// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
keys.DeleteAsync(vaultId, entityId, cancellationToken); keys.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -883,6 +883,38 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets); await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
} }
/// <remarks>
/// <para>
/// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is
/// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the
/// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveCommand</c>, which would refuse: this fixture's
/// account holds one vault, and the command declines rather than open a picker with nothing in it. The
/// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>. The same arrangement, and the same
/// reason, as <see cref="TheHostDrawerFitsWithTheMovePanelOpen"/>.
/// </para>
/// </remarks>
[Fact]
public async Task TheSnippetsScreenFitsWithTheMovePanelOpen()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
snippets.MoveVaultChoices.Add(
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
snippets.SelectedMoveVault = snippets.MoveVaultChoices[0];
snippets.IsMoving = true;
snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it");
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel open"), snippets);
}
[Fact] [Fact]
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing() public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
{ {
@@ -1919,6 +1951,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private async Task SeedSnippetsAsync() private async Task SeedSnippetsAsync()
{ {
await vault.SaveSnippetAsync( await vault.SaveSnippetAsync(
vault.TargetVaultId,
null, null,
new SnippetSecret new SnippetSecret
{ {
@@ -1929,6 +1962,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Token); Token);
await vault.SaveSnippetAsync( await vault.SaveSnippetAsync(
vault.TargetVaultId,
null, null,
new SnippetSecret new SnippetSecret
{ {
@@ -785,6 +785,351 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("only vault you can write to"); vault.Status.ShouldContain("only vault you can write to");
} }
/// <remarks>
/// <para>
/// The gap the host's move kept running into. A key typed into a personal vault before the team existed
/// is the key the team's machines authenticate with, and until this existed there was no way to get it
/// across: the keychain could create and delete, so "moving" a key meant pasting the private half into a
/// second item and deleting the first.
/// </para>
/// <para>
/// <b>The re-aim is the half worth the test.</b> An item re-sealed into another vault lands with a new
/// id, so without it every host bound to the key would be left naming a tombstone — and a host bound to
/// something its vault no longer holds refuses to connect rather than falling back to a typed password.
/// A move that did only the first half would look like a success and break two machines.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAKeyToAnotherVault_ReSealsItThereAndReAimsTheHostsThatUsedIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
key.VaultId.ShouldNotBe(sharedVaultId, "it was typed into the personal vault");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == key.EntityId);
vault.CanMoveSelectedItem.ShouldBeTrue("there is a second vault this session can write to");
vault.MoveSelectedItemCommand.Execute(null);
vault.IsMovingItem.ShouldBeTrue(vault.Status);
vault.ShowsItemActions.ShouldBeFalse("the panel takes the place of EDIT and DELETE");
vault.MoveItemVaultChoices.ShouldNotContain(choice => choice.VaultId == key.VaultId);
// The count, before the move rather than after it. Two machines stop connecting if this is wrong.
vault.MovingItemUsage.ShouldContain("2 hosts");
vault.SelectedMoveItemVault =
vault.MoveItemVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// As the host's move does: the pass that follows every write is made to fail, so the sentence the
// move itself wrote is still on the status line to be read.
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveItemCommand.ExecuteAsync(null);
var moved = vault.Keys.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.EntityId.ShouldNotBe(key.EntityId, "an id belongs to one vault");
moved.Key.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"), "the material crossed intact");
vault.Hosts.Count.ShouldBe(2);
vault.Hosts.ShouldAllBe(host => host.Host.SshKeyId == moved.EntityId);
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("2 hosts");
}
/// <remarks>
/// The question this whole panel exists to ask. A binding resolves across vaults, so the moved host goes
/// on working for the person who moved it either way — and for the colleagues it has just joined, a host
/// whose key stayed behind is one they cannot connect with. Ticked, the key goes too and the host lands
/// naming it by the id it landed with.
/// </remarks>
[Fact]
public async Task MovingAHostWithItsKeyBrought_TakesTheKeyAcrossAndKeepsTheBinding()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue(vault.Status);
vault.BindingToBringQuestion.ShouldContain("deploy");
vault.BindingToBringNote.ShouldContain("Nothing else", Case.Insensitive);
vault.BringsTheBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
vault.BringsTheBindingAlong = true;
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var movedKey = vault.Keys.ShouldHaveSingleItem();
var movedHost = vault.Hosts.ShouldHaveSingleItem();
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
vault.Status.ShouldContain("came with it");
vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
}
/// <remarks>
/// <para>
/// ◆ <b>The same question from the phone's action bar, which is that head's only route to it since the
/// connect card went.</b> It is asked in two shapes fewer than the desktop's: one host, because which key
/// to carry is a fact about one machine and a selection of six has six answers; and a move rather than a
/// copy, because taking the key out from under an original that is staying put would leave that original
/// unable to connect.
/// </para>
/// <para>
/// The three shapes are asserted in one test on purpose. What is being pinned is not that the box appears
/// but that it appears in exactly one of them — a rule that only reads as a rule when the other two are
/// beside it.
/// </para>
/// </remarks>
[Fact]
public async Task TheActionBarAsksAboutTheKey_ForOneHostAndForAMoveOnly()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
var one = vault.Hosts.Single(
row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
vault.ChooseHostCommand.Execute(one);
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasAChosenBindingToBring.ShouldBeTrue(vault.Status);
vault.ChosenBindingToBringQuestion.ShouldContain("deploy");
vault.ChosenBindingToBringNote.ShouldContain("one other host", Case.Insensitive);
vault.BringsTheChosenBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
// A copy, which must never take the key: the original stays where it is and would be left bound to
// something its own vault no longer holds.
vault.CopyChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasAChosenBindingToBring.ShouldBeFalse("a copy that moved the key would break the original");
// And two hosts, where the question has two answers and no tick can carry them.
vault.ToggleHostChoiceCommand.Execute(
vault.Hosts.Single(row => string.Equals(row.Label, "prod-web", StringComparison.Ordinal)));
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasAChosenBindingToBring.ShouldBeFalse("which key to carry is a fact about one machine");
}
/// <remarks>
/// Ticked, from the phone. The desktop's own path is measured above; what this adds is that the batch
/// command carries the key before it writes the host, so the host lands naming the id the key arrived
/// with rather than a tombstone.
/// </remarks>
[Fact]
public async Task MovingTheOneChosenHostWithItsKey_TakesTheKeyAcrossAndKeepsTheBinding()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.ChooseHostCommand.Execute(vault.Hosts.ShouldHaveSingleItem());
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.BringsTheChosenBindingAlong = true;
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
var movedKey = vault.Keys.ShouldHaveSingleItem();
var movedHost = vault.Hosts.ShouldHaveSingleItem();
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
vault.Status.ShouldContain("came with it");
vault.BringsTheChosenBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
vault.IsChoosingHosts.ShouldBeFalse();
}
/// <remarks>
/// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
/// and the sentence afterwards says what that means for everybody else in the destination. It is also
/// what happens to anybody who presses MOVE without reading, which is why it is the unticked state.
/// </remarks>
[Fact]
public async Task MovingAHostWithoutItsKey_LeavesTheKeyBehindAndSaysWhatThatCosts()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
vault.Keys.ShouldHaveSingleItem().VaultId.ShouldBe(key.VaultId, "the key was not asked for");
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.SshKeyId.ShouldBe(key.EntityId, "the binding is kept — it resolves across vaults");
vault.Status.ShouldContain("another vault");
}
/// <remarks>
/// <para>
/// The case where a host stops connecting without naming anything. A group lends its default key to
/// everything filed under it, and a group belongs to the vault it is in — so the group stays behind, and
/// a host that only inherited its key used to arrive naming nothing at all.
/// </para>
/// <para>
/// The binding is written onto the host on the way across instead, which is the same key it
/// authenticated with before the move. The move is also asked about it: the tick box reads the resolved
/// binding, so an inherited key can be brought too.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAHostThatInheritsItsGroupsKey_WritesThatBindingOntoIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices
.Single(choice => choice.EntityId == key.EntityId);
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
var before = vault.Hosts.ShouldHaveSingleItem();
before.Host.SshKeyId.ShouldBeNull("the host names nothing; the group lends it");
before.Authentication.ShouldBe("key");
vault.SelectedHost = before;
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue("an inherited key is still a key that can come along");
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
moved.Host.SshKeyId.ShouldBe(key.EntityId, "what it inherited is written onto it");
moved.Authentication.ShouldBe("key", "it authenticates with what it did before the move");
}
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created, /// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
@@ -1599,6 +1944,41 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// Through the form rather than straight at the command, because the name is what the form is for — /// Through the form rather than straight at the command, because the name is what the form is for —
/// and because the form is now the only way in: there is no separate "make a team" step behind it. /// and because the form is now the only way in: there is no separate "make a team" step behind it.
/// </remarks> /// </remarks>
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
private static string PrivateKey(string body) =>
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
/// <summary>Puts one key in whatever vault the keychain is filing into, and hands back its row.</summary>
private static async Task<SshKeyRowViewModel> AddKeyAsync(VaultViewModel vault, string label)
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = label;
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.IsEditingKey.ShouldBeFalse(vault.Status);
return vault.Keys.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
}
/// <summary>Creates a host that authenticates with one key, by choosing it in the editor.</summary>
private static async Task AddHostBoundToKeyAsync(VaultViewModel vault, string label, Guid keyId)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.EntityId == keyId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
}
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name) private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{ {
await vaults.LoadAsync(Token); await vaults.LoadAsync(Token);
@@ -1613,6 +1993,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status); vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
} }
/// <remarks>
/// <para>
/// Sharing a snippet, which is a move like a host's and simpler in exactly one way: a snippet crosses
/// whole. It has no group, no tags and no key binding — nothing on it points at an item of the vault it
/// came from — so the assertion the host's move makes about what was left behind has no analogue, and
/// the one worth making instead is that <em>nothing</em> was lost, the flag that decides whether it
/// presses Enter for you least of all.
/// </para>
/// <para>
/// The new id is asserted for the reason the host's test gives: one entity id in two vaults would make
/// the destination's row and the source's tombstone the same row.
/// </para>
/// </remarks>
[Fact]
public async Task MovingASnippetToAnotherVault_ReSealsItThereAndCarriesItWhole()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: true);
var before = Snippet(snippets, "restart the api");
before.VaultId.ShouldNotBe(sharedVaultId);
snippets.Selected = before;
snippets.CanMove.ShouldBeTrue("there is a second vault this session can write to");
snippets.MoveCommand.Execute(null);
snippets.IsMoving.ShouldBeTrue(snippets.Status);
snippets.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);
snippets.SelectedMoveVault =
snippets.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// The pass that follows every write is made to fail, so the move's own sentence is still on the
// status line to be read. See the host's move test, which does this for the same reason.
server.SyncFailure = new IOException("The server is not answering.");
await snippets.ConfirmMoveCommand.ExecuteAsync(null);
var after = Snippet(snippets, "restart the api");
after.VaultId.ShouldBe(sharedVaultId, vault.Status);
after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
after.Snippet.Command.ShouldBe("sudo systemctl restart dodossh-api");
after.Snippet.RunsOnInsert.ShouldBeTrue("the flag that decides whether it presses Enter came too");
snippets.Selected?.EntityId.ShouldBe(after.EntityId, "the pane follows the snippet it moved");
snippets.Status.ShouldContain("Platform secrets");
}
/// <remarks>
/// Refused by the command rather than by an empty picker, and the phone reads the same question to
/// decide whether to draw the button at all.
/// </remarks>
[Fact]
public async Task MovingASnippetWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
{
await UnlockedAsync();
var vault = shell.Vault!;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
snippets.Selected = Snippet(snippets, "uptime");
snippets.CanMove.ShouldBeFalse("the personal vault is the only one there is");
snippets.MoveCommand.Execute(null);
snippets.IsMoving.ShouldBeFalse();
snippets.MoveVaultChoices.ShouldBeEmpty();
snippets.Status.ShouldContain("only vault you can write to");
}
/// <remarks>
/// The picker the snippet editor grew, and the thing it is for: choosing at the moment a snippet is
/// written, on the form it is being typed into. A command is worth sharing precisely when somebody else
/// would otherwise be retyping it, so filing it into the team's vault at that moment is the ordinary
/// case rather than an afterthought.
/// </remarks>
[Fact]
public async Task TheSnippetEditorFilesANewSnippetIntoTheVaultChosenOnIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
snippets.NewCommand.Execute(null);
snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
snippets.EditorSelectedVault =
snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
snippets.EditorLabel = "rotate the certs";
snippets.EditorCommand = "sudo certbot renew";
await snippets.SaveCommand.ExecuteAsync(null);
Snippet(snippets, "rotate the certs").VaultId.ShouldBe(sharedVaultId, snippets.Status);
}
/// <remarks>
/// <para>
/// The bug the per-editor latch exists to prevent, and the reason the screen could not simply keep
/// writing to the active vault once its list spanned several. An update sent to the active vault would
/// create a second snippet there and leave the team's original untouched: a fork that shows up only
/// when a colleague asks why the correction never arrived.
/// </para>
/// <para>
/// The count is the assertion. One snippet with that label, in the vault it started in.
/// </para>
/// </remarks>
[Fact]
public async Task EditingASharedSnippet_WritesBackToItsOwnVaultRatherThanForkingACopy()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
snippets.NewCommand.Execute(null);
snippets.EditorSelectedVault =
snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
snippets.EditorLabel = "drain the node";
snippets.EditorCommand = "kubectl drain node-1";
await snippets.SaveCommand.ExecuteAsync(null);
snippets.Selected = Snippet(snippets, "drain the node");
snippets.EditCommand.Execute(null);
snippets.ShowsEditorVaultChoice.ShouldBeFalse("an existing snippet's vault is not a field of the form");
snippets.EditorCommand = "kubectl drain node-1 --ignore-daemonsets";
await snippets.SaveCommand.ExecuteAsync(null);
var edited = Snippet(snippets, "drain the node");
edited.VaultId.ShouldBe(sharedVaultId, "the edit went back to the vault it came from");
edited.Snippet.Command.ShouldBe("kubectl drain node-1 --ignore-daemonsets");
}
/// <summary>The snippet with a given name, re-found because every row is replaced on every reload.</summary>
private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) =>
snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <summary>The snippets screen over a vault, with no terminal to insert into.</summary>
/// <remarks>
/// Insert is not what this suite is about — see <c>ShellFlowTests</c> for that — so the target is empty
/// and the delivery is a stub that would report success if anything asked it to.
/// </remarks>
private static SnippetsViewModel SnippetsOver(VaultViewModel vault) =>
new(vault, () => InsertTarget.None, (_, _, _, _) => Task.FromResult(true));
private static async Task AddSnippetAsync(
SnippetsViewModel snippets,
string label,
string command,
bool runs)
{
snippets.NewCommand.Execute(null);
snippets.EditorLabel = label;
snippets.EditorCommand = command;
snippets.EditorRunsOnInsert = runs;
await snippets.SaveCommand.ExecuteAsync(null);
}
/// <remarks> /// <remarks>
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity /// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and /// key that was really enrolled: the fake server publishes it into its key log during enrollment, and