Public Access
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user