Show one kind of vault item at a time, and let the vault hold passwords

Outstanding items #8 and #9, in one commit rather than two. They are separable
as work and were built in that order, but not as a diff: the section enum has
three members, the one-editor guard has three arms, and the picker offers keys
and credentials from the same list. Reconstructing an #8-only state would mean
hand-writing an intermediate version of VaultViewModel that never existed and
that no test has ever run. One honest commit beats two invented ones.

--- #8, the type selector ---

The column showed two lists and two editors stacked in 340 pixels, and only
just: the key list needed a MaxHeight and had to hide itself whenever its editor
opened, both to stop the host list above it pushing the buttons off the bottom
edge. Credentials would not have fitted at all. It now shows one kind at a time,
chosen by a selector at the top, and both workarounds are gone because a section
owns the whole column.

Three departures from the plan, each with a reason found while building it.

The selector is plain Buttons and a parameterised command, not a TabControl, a
TabStrip or a ListBox. All three of those hold the selection themselves, so a
click moves the highlight before the view model can refuse it — and this column
does refuse, while an editor is open. A selector lit on a section the column is
not showing is worse than the refusal it would be hiding. Buttons carry no state
and cannot disagree with the vault.

The one-editor-at-a-time rule survives with its justification replaced. That
rule was a workaround for the sizing problem above, and sections dissolved it:
the editors are in different sections and only one section is ever laid out.
BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is now
BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut — the same test, inverted,
because its own comment said that if it ever started passing the rule had become
unnecessary. It has. The rule stays for a better reason: an open key editor
holds a pasted private key in a bound string, and letting the column move on
would leave key material in a form nobody can see, with nothing on screen to say
it is there. A sizing hack became a rule about not hiding a secret from the
person holding it. KeyEditorIsInTheWay and HostEditorIsInTheWay are one
AnEditorIsInTheWay, called by the section switch and by every editor-opening
command.

And releasing the keyboard from the terminal has never worked. MainWindow takes
Win32 focus off the WebView's child window and then calls Focus() on
VaultColumn.KeyboardTarget — and a ListBox is not focusable by default in
Avalonia, which leaves focus to its items. So the call returned false, the
window ended up with nothing focused, and the keystrokes went nowhere: exactly
the state that method's own comment says its second half exists to prevent.
Found by writing the test to assert focus was taken rather than that the right
control was named — the cheap assertion was already passing. Fixed with
Focusable="True" on every list.

--- #9, credentials ---

Credentials have synced since they were added and could not be created. They can
now, and the sync layer needed no change at all: fourth item type, same result,
which is the item-kind seam working as intended.

One picker for all three ways a host authenticates, which is what makes the
illegal combination unrepresentable rather than merely invalid. SshKeyChoice
became AuthenticationChoice carrying an AuthenticationKind, and BuildHost reads
both SshKeyId and CredentialId off that single selection, so a host naming a key
and a credential — which HostSecret.TryValidate refuses — cannot be expressed.
Two pickers would have expressed it and then rejected it at save time. The kind
travels with the id in three places and none is padding: Missing takes it, the
placeholder lookup matches on kind as well as id, and Bound(kind) returns null
unless the selection is that kind. Drop any one and a dangling credential comes
back as a dangling key, which saves as a key binding to an id no key has.

A credential's username had to reach the SSH request, not just its password.
TryBuildCredential returned only the secret and the connect path read the
username off the host, so a stored credential would have gone out under the
wrong account — wrong in a way a server only reports as "authentication failed".
It is now TryBuildAuthentication returning a (Username, Credential) pair.

The no-username refusal moved, and had to. It ran before anything looked at the
binding, which made a credential's username unreachable in the one case it is
most useful: a host somebody never filled a username in for. It is now the last
thing every branch agrees on, so such a host is perfectly usable through a
credential that carries one, and a host with neither still refuses and now says
where to put one.

--- What the measurements cost ---

Ten mutations, all caught. Two are worth naming.

Removing a section's IsVisible is caught by OnlyOneSectionIsOnScreenAtOnce and
by nothing else: two visible sections overlap in the row they share rather than
clip, so every fit test still passes while the column shows one list through
another.

Defaulting the credential selection to the first row is caught by
ReloadingKeepsACredentialSelectionButNeverInventsOne, and the property is a
safety one rather than tidiness — Delete acts on the selection, so a list that
picked a row on every background sync would aim a one-click password deletion at
something nobody chose. The key list has the same property, and its comment
cited a method that has not existed for some time; both now name the delete
command they actually protect.

One test of mine could not fail, and the mutation pass is what found it.
AHostBoundToACredential_SendsItsPasswordAndItsUsername gave the credential and
the host the same username, so it passed whichever one the code read. An
override is only tested when the two values differ.

Two shipped statements went false and were corrected rather than left: the class
remark saying passwords were "not yet" in the vault, and the terminal column's
"Keys are in the vault; passwords are not yet."

That column's hint is now a tooltip on the password box rather than a sentence
in the row, which was measured the hard way — by looking. At the window's 820px
minimum the column gets 480, and a 220px box plus Connect plus any sentence does
not fit; the row has shipped clipped for as long as it has had a hint in it.
That strip is the one part of the window nothing can measure, because MainWindow
cannot be laid out headlessly at all. Extracting it into its own control, as the
vault column was extracted for exactly this reason, is what would fix that, and
is not done here.

911 tests green, 30 of them new. Zero warnings, dotnet format clean.

Seen by a person, which is how the two defects above were found. Still open from
that pass: unlocking with the device key raises its consent dialog and then
never returns, while registering one works — the difference is which thread the
CNG call lands on, and diagnosing it properly is its own change.
This commit is contained in:
2026-07-30 17:08:59 +02:00
parent 573f5d5668
commit da7462e41f
6 changed files with 1835 additions and 341 deletions
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -90,28 +90,35 @@
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
<StackPanel Orientation="Horizontal" Spacing="8">
<!--
Typed per connection. Credentials do sync, but nothing in this interface can create one, so
the vault genuinely does not hold this — saying so beats a password box that looks like it
should have been remembered. Disabled rather than hidden when a key is being used, so it
stays visible that a password is what the other choice means.
Typed per connection, and now only for a host that asks to be. A host bound to a stored
credential wants nothing typed here; so does one bound to a key. "not stored" rather than "not
stored yet", because the vault can hold this now — a host on a typed password is a host nobody
has bound a credential to, which is a choice rather than a missing feature.
The rest of that explanation is a tooltip and not a sentence in this row, which was measured the
hard way: at the window's 820px minimum this column gets 480, and a 220px box plus Connect plus
any sentence does not fit. The row shipped clipped for as long as it has had a hint in it. This
is also the one strip of the window no test can see — MainWindow cannot be laid out headlessly at
all, because WebView2 refuses the headless dispatcher's MTA thread. Extracting this header into
its own control, as the vault column was extracted for exactly this reason, is what would make
it measurable.
-->
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored yet)"
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="220" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostUsesAKey}" />
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Passwords and bind this host to it in the host's own editor." />
<!--
Hidden rather than disabled for the key case, unlike most of this window. A disabled password
box invites the reading that a password is wanted and unavailable; the honest statement for a
key-authenticated host is that nothing needs typing, and an absent box says that better than a
greyed-out one.
Hidden rather than disabled, unlike most of this window. A disabled password box invites the
reading that a password is wanted and unavailable; the honest statement for a host that carries
its own secret is that nothing needs typing, and an absent box says that better than a
greyed-out one. Which secret it carries is in the sentence, because a stored password can be
wrong and re-typed here and a key cannot.
-->
<TextBlock Text="This host authenticates with its SSH key." Classes="hint" FontSize="11"
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostUsesAKey}" />
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="11" VerticalAlignment="Center"
Text="Keys are in the vault; passwords are not yet."
IsVisible="{Binding !Vault.SelectedHostUsesAKey}" />
</StackPanel>
</Border>
+304 -163
View File
@@ -5,188 +5,329 @@
x:DataType="vm:VaultViewModel">
<!--
The vault column: hosts above, SSH keys below.
The vault column: a selector at the top, and one kind of item below it.
One kind at a time rather than every list stacked. Stacking is what this column did with two of them and
it was already at its limit — the key list needed a MaxHeight and had to hide itself whenever its editor
opened, both to keep the host list above it from pushing the buttons off the bottom edge. Neither of those
workarounds is here any more, because a section owns the whole column. Credentials are the third kind and
would not have fitted at all.
Its own control rather than part of MainWindow, for two reasons that arrived together. It is the part of
this window with a height budget tight enough to break — one editor open at a time is a state rule
standing in for a sizing guarantee — and it is the only part that can be laid out by a test, because
MainWindow cannot be shown headlessly at all: the terminal's WebView2 adapter initialises on attach and
refuses a non-STA thread, which a headless dispatcher is. See DodoSSH.Client.App.Layout.Tests.
this window with a height budget tight enough to break, and it is the only part that can be laid out by a
test, because MainWindow cannot be shown headlessly at all: the terminal's WebView2 adapter initialises on
attach and refuses a non-STA thread, which a headless dispatcher is. See DodoSSH.Client.App.Layout.Tests,
which measures one section at a time because that is all a user can see at once.
Two lists in one column rather than a TabControl. A TabControl is the tidier layout and it was not
chosen because of what the terminal does with the keyboard: MainWindow releases focus to whichever list
is showing, and a tabbed version would put that target outside the visual tree when its tab is not
selected. This repository already has one measured finding of that shape — Focus() on a collapsed
control is a no-op and is not replayed when it is revealed — and whether an unselected TabItem behaves
the same way here is untested. Not worth finding out by shipping it, for a layout preference.
Buttons and a command rather than a TabControl, a TabStrip or a ListBox, and not for looks. All three hold
the selection themselves, so a click moves the highlight before the view model can refuse it — and this
column does refuse, while an editor is open. A selector lit on a section the column is not showing is
worse than the refusal it would be hiding. Buttons carry no state and cannot disagree with the vault.
The host list keeps the flexible row, so it is what grows with the window; the key section takes what it
needs and no more.
It also keeps the keyboard working. MainWindow releases focus to VaultColumn.KeyboardTarget, and Focus()
on a collapsed control is measurably a no-op that is not replayed when the control is revealed — so the
target has to be a control that is on screen right now. A tabbed version would put the unselected list
outside the visual tree entirely; here it is one section switch away and the column knows which.
-->
<Grid RowDefinitions="*,Auto,Auto,Auto,Auto,Auto,Auto" Background="#131722">
<!-- Named because it is where keyboard focus lands when the user leaves the terminal. -->
<ListBox Grid.Row="0" x:Name="HostList" Margin="6"
ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
FontFamily="ui-monospace,Consolas,monospace" />
<!--
Which of the two ways this host authenticates. In the list because the password box in the
terminal column is only relevant to one of them, and an empty box on a key-authenticated
host is otherwise indistinguishable from one somebody forgot to fill in.
-->
<TextBlock Text="{Binding Authentication}" Classes="hint" FontSize="11" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<UserControl.Styles>
<!--
The selector's two states. The brushes go on the presenter rather than the button because that is where
the Fluent theme puts its own: a Background set on the button is a TemplateBinding the theme's
:pointerover rule overrides, so the accent would survive until the pointer arrived and then vanish.
-->
<Style Selector="StackPanel#SectionSelector > Button /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="#9aa4b6" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="StackPanel#SectionSelector > Button.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="#e6e9f0" />
<Setter Property="BorderBrush" Value="#5b7cfa" />
</Style>
<!-- Hover on the inactive one, so the row reads as clickable before anything is clicked. -->
<Style Selector="StackPanel#SectionSelector > Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#1c2230" />
</Style>
</UserControl.Styles>
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
<Border Grid.Row="1" Padding="10" Background="#171b24" IsVisible="{Binding IsEditing}">
<StackPanel Spacing="6">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
<NumericUpDown Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" />
<TextBox Text="{Binding EditorUsername}" PlaceholderText="username" />
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="60" TextWrapping="Wrap" />
<!--
Which key this host authenticates with, or a password. Part of the host rather than of the
connection, so it follows the host to every machine; a host bound to a key that has since been
deleted keeps a placeholder entry here, so that editing the port cannot quietly turn it back
into a password host.
-->
<ComboBox ItemsSource="{Binding EditorKeyChoices}"
SelectedItem="{Binding EditorSelectedKey}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SshKeyChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Allow connecting through the server relay" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to resolve
the target, or it becomes an authenticated open proxy into the operator's network.
-->
<TextBlock Classes="hint" FontSize="11"
Text="Enabling the relay stores this host's address on the server in plain text. Everything else about the host stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding SaveHostCommand}" />
<Button Content="Cancel" Command="{Binding CancelEditCommand}" />
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
not a field of the host.
<Grid RowDefinitions="Auto,*" Background="#131722">
Added to this row rather than as a row of its own on purpose: this column's editors already
only just fit at the window's minimum height, which is why only one may be open at a time.
-->
<Button Content="Forget host key" Command="{Binding ForgetHostKeyCommand}"
IsVisible="{Binding CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
<!--
Adding a kind is an enum member, one button here, and one section grid below. Deliberately not a
generated list: each section's contents are different markup, so a loop would buy nothing and cost the
compiled bindings.
-->
<Border Grid.Row="0" Background="#10141d" BorderBrush="#1c2230" BorderThickness="0,0,0,1">
<StackPanel x:Name="SectionSelector" Orientation="Horizontal">
<Button Content="Hosts" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
Classes.active="{Binding ShowsHosts}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Hosts}" />
<Button Content="SSH keys" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
Classes.active="{Binding ShowsKeys}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Keys}" />
<Button Content="Passwords" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
Classes.active="{Binding ShowsCredentials}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Credentials}" />
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
IsVisible="{Binding !IsEditing}">
<Button Content="Add" Command="{Binding NewHostCommand}" />
<Button Content="Edit" Command="{Binding EditSelectedHostCommand}" />
<Button Content="Delete" Command="{Binding DeleteHostCommand}" />
</StackPanel>
<Border Grid.Row="3" Padding="8,6" Background="#10141d">
<TextBlock Text="SSH keys" Foreground="#9aa4b6" FontSize="11" FontWeight="SemiBold" />
</Border>
<!--
Bounded rather than flexible, and hidden while its editor is open. The key editor is the tallest
thing in this column — a private key needs a real text area — and at the window's minimum height
there is not room for both. Browsing the list and editing one of its rows are not things anyone
needs to do at the same moment.
The two sections share this row and take turns being visible. Same row rather than two rows with one
collapsed, so neither can reserve height from the other by accident.
The list keeps the flexible row in both, so it is what grows with the window and what shrinks when an
editor opens; the editor takes what it needs. The list stays visible while its editor is open, which the
key list did not manage before — and that is what keeps KeyboardTarget honest, since the control it
returns has to be one a Focus() call can actually reach.
-->
<ListBox Grid.Row="4" x:Name="KeyList" Margin="6" MaxHeight="170"
ItemsSource="{Binding Keys}"
SelectedItem="{Binding SelectedKey}"
Background="Transparent"
IsVisible="{Binding !IsEditingKey}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SshKeyRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
<!-- Hosts -->
<Grid Grid.Row="1" RowDefinitions="*,Auto,Auto" IsVisible="{Binding ShowsHosts}">
<!--
Named because it is where keyboard focus lands when the user leaves the terminal.
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty list has
none. Without it MainWindow's release-the-keyboard path was a measured no-op: it took Win32 focus off
the terminal's child window and then called Focus() on something that refuses it, leaving the window
with nothing focused and the keystrokes going nowhere. Exactly the state the second half of
ReleaseKeyboardTo says it exists to prevent.
-->
<ListBox Grid.Row="0" x:Name="HostList" Margin="6" Focusable="True"
ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
FontFamily="ui-monospace,Consolas,monospace" />
<!--
Which of the two ways this host authenticates. In the list because the password box in the
terminal column is only relevant to one of them, and an empty box on a key-authenticated
host is otherwise indistinguishable from one somebody forgot to fill in.
-->
<TextBlock Text="{Binding Authentication}" Classes="hint" FontSize="11" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
<Border Grid.Row="1" Padding="10" Background="#171b24" IsVisible="{Binding IsEditing}">
<StackPanel Spacing="6">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
<NumericUpDown Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" />
<TextBox Text="{Binding EditorUsername}" PlaceholderText="username" />
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="60" TextWrapping="Wrap" />
<!--
How this host authenticates: a typed password, one of the vault's keys, or one of its credentials.
Part of the host rather than of the connection, so it follows the host to every machine; a host
bound to something since deleted keeps a placeholder entry here, so that editing the port cannot
quietly turn it back into a typed-password host.
One control for all three, which is what makes "a key or a credential, never both" impossible to
express rather than merely invalid. The qualifier beside each label is not decoration: a key called
"deploy" and the deploy account's password are the ordinary case, and bare labels would offer two
identical-looking entries that authenticate completely differently.
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Allow connecting through the server relay" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to resolve
the target, or it becomes an authenticated open proxy into the operator's network.
-->
<TextBlock Classes="hint" FontSize="11"
Text="Enabling the relay stores this host's address on the server in plain text. Everything else about the host stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding SaveHostCommand}" />
<Button Content="Cancel" Command="{Binding CancelEditCommand}" />
<!--
What is known about the key, never the key. Binding the material here would put a private
key into a list item's visual tree, where a tooltip or a screen reader could read it out.
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
not a field of the host.
-->
<TextBlock Text="{Binding Description}" Classes="hint" FontSize="11" />
<Button Content="Forget host key" Command="{Binding ForgetHostKeyCommand}"
IsVisible="{Binding CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="5" Padding="10" Background="#171b24"
IsVisible="{Binding IsEditingKey}">
<StackPanel Spacing="6">
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
<!--
Not a password box. The armour has to be visible to be pasted and checked — a masked
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this actually
prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
-->
<TextBox Text="{Binding KeyEditorPrivateKey}"
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
FontFamily="ui-monospace,Consolas,monospace" FontSize="11" />
<TextBox Text="{Binding KeyEditorPassphrase}"
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
<TextBox Text="{Binding KeyEditorPublicKey}"
PlaceholderText="public half (optional)" FontSize="11" />
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding SaveKeyCommand}" />
<Button Content="Cancel" Command="{Binding CancelKeyEditCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Border>
<StackPanel Grid.Row="6" Orientation="Horizontal" Spacing="6" Margin="8,4,8,8"
IsVisible="{Binding !IsEditingKey}">
<Button Content="Add key" Command="{Binding NewKeyCommand}" />
<Button Content="Edit" Command="{Binding EditSelectedKeyCommand}" />
<Button Content="Delete" Command="{Binding DeleteKeyCommand}" />
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
IsVisible="{Binding !IsEditing}">
<Button Content="Add" Command="{Binding NewHostCommand}" />
<Button Content="Edit" Command="{Binding EditSelectedHostCommand}" />
<Button Content="Delete" Command="{Binding DeleteHostCommand}" />
</StackPanel>
</Grid>
<!-- SSH keys -->
<Grid Grid.Row="1" RowDefinitions="*,Auto,Auto" IsVisible="{Binding ShowsKeys}">
<!-- The other place the keyboard can land, and focusable for the same reason; see KeyboardTarget. -->
<ListBox Grid.Row="0" x:Name="KeyList" Margin="6" Focusable="True"
ItemsSource="{Binding Keys}"
SelectedItem="{Binding SelectedKey}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SshKeyRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<!--
What is known about the key, never the key. Binding the material here would put a private
key into a list item's visual tree, where a tooltip or a screen reader could read it out.
-->
<TextBlock Text="{Binding Description}" Classes="hint" FontSize="11" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="1" Padding="10" Background="#171b24"
IsVisible="{Binding IsEditingKey}">
<StackPanel Spacing="6">
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
<!--
Not a password box. The armour has to be visible to be pasted and checked — a masked
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this actually
prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
-->
<TextBox Text="{Binding KeyEditorPrivateKey}"
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
FontFamily="ui-monospace,Consolas,monospace" FontSize="11" />
<TextBox Text="{Binding KeyEditorPassphrase}"
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
<TextBox Text="{Binding KeyEditorPublicKey}"
PlaceholderText="public half (optional)" FontSize="11" />
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding SaveKeyCommand}" />
<Button Content="Cancel" Command="{Binding CancelKeyEditCommand}" />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8,4,8,8"
IsVisible="{Binding !IsEditingKey}">
<Button Content="Add key" Command="{Binding NewKeyCommand}" />
<Button Content="Edit" Command="{Binding EditSelectedKeyCommand}" />
<Button Content="Delete" Command="{Binding DeleteKeyCommand}" />
</StackPanel>
</Grid>
<!-- Stored passwords -->
<Grid Grid.Row="1" RowDefinitions="*,Auto,Auto" IsVisible="{Binding ShowsCredentials}">
<!-- The third place the keyboard can land; see KeyboardTarget. -->
<ListBox Grid.Row="0" x:Name="CredentialList" Margin="6" Focusable="True"
ItemsSource="{Binding Credentials}"
SelectedItem="{Binding SelectedCredential}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:CredentialRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<!--
The account, never the password. The same rule as the key list, and the same reason: a
password bound into a list item's visual tree is one a tooltip or a screen reader can read out.
-->
<TextBlock Text="{Binding Description}" Classes="hint" FontSize="11" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="1" Padding="10" Background="#171b24"
IsVisible="{Binding IsEditingCredential}">
<StackPanel Spacing="6">
<TextBox Text="{Binding CredentialEditorLabel}" PlaceholderText="name" />
<!--
Optional, and the reason a credential is worth being its own item rather than two more fields on a
host: one account on twenty machines is described once and rotated once. Left blank, each host
supplies its own username and only the password is shared.
-->
<TextBox Text="{Binding CredentialEditorUsername}"
PlaceholderText="username (blank: use each host's own)" />
<!--
Masked, unlike the private key box, and the difference is not inconsistency. A key's armour has to
be visible to be checked for truncation after a paste; a password is short, usually typed, and
shoulder-surfing is the likelier problem.
-->
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11"
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to this in the host's own editor, under how it authenticates." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding SaveCredentialCommand}" />
<Button Content="Cancel" Command="{Binding CancelCredentialEditCommand}" />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8,4,8,8"
IsVisible="{Binding !IsEditingCredential}">
<Button Content="Add password" Command="{Binding NewCredentialCommand}" />
<Button Content="Edit" Command="{Binding EditSelectedCredentialCommand}" />
<Button Content="Delete" Command="{Binding DeleteCredentialCommand}" />
</StackPanel>
</Grid>
</Grid>
@@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -18,10 +19,29 @@ internal sealed partial class VaultColumn : UserControl
/// Where the keyboard should land when the terminal hands it back.
/// </summary>
/// <remarks>
/// <para>
/// Exposed as a property rather than left for the window to find by name, because the name is now inside
/// this control's template and the window cannot see it. Which is the better arrangement anyway: when
/// this column shows one list at a time, "the list the keyboard belongs to" is a question only the column
/// this control's template and the window cannot see it. Which is the better arrangement anyway: the
/// column shows one list at a time, so "the list the keyboard belongs to" is a question only the column
/// can answer, and answering it here means the window never has to know how many lists there are.
/// </para>
/// <para>
/// It has to be the list that is on screen, not a fixed one. <c>Focus()</c> on a collapsed control is
/// measurably a no-op and is not replayed when the control is revealed, so returning the host list while
/// the keys section is showing would swallow the keyboard: the terminal would let go and nothing would
/// take it.
/// </para>
/// <para>
/// Read from the view model rather than from the controls' own <c>IsVisible</c>, because that is the
/// direction the truth flows — the section is the state and the visibility is a binding to it. Asking the
/// controls would answer the same question one indirection later, and would answer it wrongly for the
/// moment between a section change and the next layout pass.
/// </para>
/// </remarks>
internal IInputElement KeyboardTarget => HostList;
internal IInputElement KeyboardTarget => DataContext switch
{
VaultViewModel { ShowsKeys: true } => KeyList,
VaultViewModel { ShowsCredentials: true } => CredentialList,
_ => HostList,
};
}