Public Access
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host against its groups; nothing asked it to. This is the wiring, and it is mostly one change repeated: read the resolved host, not the stored one. TryBuildAuthentication and TryBuildConnectionRequest now take the resolved value beside the stored one, which is where group context was being lost. It is the only authentication resolution in the product — both heads and both transports come through it — so a host inheriting its binding would otherwise have been offered a password prompt on every screen at once. The credential-username fallback becomes three levels, and Complete still refuses an empty username, but now only after the chain has been walked; refusing before it would refuse exactly the hosts inheritance exists to serve. HostRowViewModel carries its ResolvedHost, resolved once when the list is built. Address, Authentication and Dialled read it, so a row cannot disagree with itself about what it dials — and MainWindowViewModel.Rank searches Address, so a host inheriting 2222 that displayed 22 would have been unfindable by the port it actually answers on. HostsBoundTo counts over the resolved binding, which is the difference between a warning and a silence: a key bound once on a group and inherited by twenty hosts named nobody, would have been deleted, and would then have refused all twenty at connect time. HostFields.From is answered by a refusal rather than by threading a group list through the sync engine. A relay host may not inherit its port. The reason is stronger than the convenience: a plaintext column is a derived duplicate the client supplies when it pushes *this* host, so an inherited port would make it depend on another item — editing a group would change what the relay dials for every host beneath it, except that nothing re-pushes those hosts, so the server would keep dialling the old port until each was next touched for some unrelated reason. A stale wire on the relay path connects the user to the wrong service. The editor distinguishes unset from explicit in both directions. An empty port box means "take the group's" and shows what that will be as a placeholder, following the group picker as it moves — a pre-filled 2222 would have been indistinguishable from one the user typed, and saving would have pinned it. The authentication picker gains a fourth entry, offered only to a host in a group, because for an ungrouped host it would behave exactly like the first. Which found a real defect while the tests were being written. Filing an ungrouped host into a group silently pinned it to a typed password: the picker had no "Inherit" entry when it opened, so it sat on "Password (ask each time)", and saving wrote that as a decision — the host would have been pinned to a prompt nobody asked for and the group's key would never have reached it. Two guards now: the picker is rebuilt when the group changes, and BuildHost writes AsksForPassword only for a host that had the alternative on offer. The group editor is here too, and the plan never assigned it a step. Without it no group can carry a default, so every line above would have been unreachable. It grows a parent picker that leaves out the group itself and everything beneath it — a courtesy rather than the guarantee, since a cycle assembled from two offline re-parents was never offered that list — and three defaults beside the name, each of which may be left empty because "lend nothing" is an answer. Tags are stored and not editable. TagIds merges, encodes and resolves; no screen can set one yet, and the editor carries the set through a save untouched so a client that can set them does not lose them to somebody editing a port. Eight new tests, and they dial. That is the point of them: a resolved value that never reaches SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting to the wrong port, or being asked for a password it does not need, with nothing on screen admitting it. Verified by the whole suite: 1390 tests over nineteen projects, none failing. Both heads build. Nothing on the phone has changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
298 lines
17 KiB
XML
298 lines
17 KiB
XML
<UserControl xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
|
x:Class="DodoSSH.Client.App.Views.HostsScreen"
|
|
x:DataType="vm:MainWindowViewModel">
|
|
|
|
<!--
|
|
The hosts screen: the list of machines, and what this application has to say about the one that is
|
|
selected.
|
|
|
|
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
|
|
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
|
|
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
|
|
promised: an overview.
|
|
|
|
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
|
|
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
|
|
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
|
|
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
|
|
has to reproduce by hand.
|
|
|
|
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
|
|
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
|
|
two cannot be put on one element.
|
|
-->
|
|
|
|
<Grid ColumnDefinitions="268,*">
|
|
|
|
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
|
|
|
|
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
|
|
|
|
<!--
|
|
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
|
|
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
|
|
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
|
|
one of them is fine.
|
|
-->
|
|
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
|
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
|
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
|
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
|
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
|
|
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
|
FontSize="11" VerticalAlignment="Center"
|
|
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
|
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
|
|
IsEnabled="{Binding !Vault.IsBusy}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<StackPanel Grid.Row="1">
|
|
|
|
<!--
|
|
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
|
|
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
|
|
through the one warning that matters.
|
|
-->
|
|
<Border Padding="12,10" Background="{StaticResource WarnWash}"
|
|
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
|
IsVisible="{Binding Vault.HasPendingHostKey}">
|
|
<StackPanel Spacing="6">
|
|
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
|
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
|
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
|
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<Button Classes="accent" Content="TRUST AND CONNECT"
|
|
Command="{Binding Vault.TrustHostKeyCommand}" />
|
|
<Button Classes="ghost" Content="CANCEL"
|
|
Command="{Binding Vault.RejectHostKeyCommand}" />
|
|
</StackPanel>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Padding="12,10" Background="{StaticResource DangerWash}"
|
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
|
IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
|
<StackPanel Spacing="6">
|
|
<TextBlock Text="The host key changed and the connection was refused."
|
|
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
|
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
|
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
|
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
|
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
|
and shown; without this panel it would be last-writer-wins with a longer explanation.
|
|
|
|
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
|
|
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
|
|
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
|
|
for as long as it did because no test could lay this markup out; that is the other half of why this
|
|
file exists.
|
|
-->
|
|
<Border Padding="12,10" Background="{StaticResource Panel}"
|
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
|
IsVisible="{Binding Vault.HasConflicts}">
|
|
<StackPanel Spacing="6">
|
|
<TextBlock Text="Some changes could not be merged automatically."
|
|
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
|
|
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
|
|
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
|
<ItemsControl.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
|
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
|
|
CornerRadius="4">
|
|
<StackPanel Spacing="4">
|
|
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
|
|
TextWrapping="Wrap" />
|
|
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
|
|
Foreground="{StaticResource TextDim}"
|
|
IsVisible="{Binding HasDetail}" />
|
|
</StackPanel>
|
|
</Border>
|
|
</DataTemplate>
|
|
</ItemsControl.ItemTemplate>
|
|
</ItemsControl>
|
|
</ScrollViewer>
|
|
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
|
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
</StackPanel>
|
|
|
|
<!--
|
|
The overview proper: what is known about the host the list has selected.
|
|
|
|
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
|
|
terminal until this screen stopped hosting one, and filling it with something that needed new state
|
|
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
|
|
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
|
|
list of forty.
|
|
-->
|
|
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
|
|
<Panel Margin="24">
|
|
|
|
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
|
|
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
|
|
VerticalAlignment="Center" />
|
|
<Border Classes="chip" VerticalAlignment="Center"
|
|
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
|
|
<TextBlock Text="CONNECTED" />
|
|
</Border>
|
|
</StackPanel>
|
|
|
|
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
|
|
Foreground="{StaticResource TextDim}" />
|
|
|
|
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
|
|
|
|
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
|
|
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
|
|
</StackPanel>
|
|
|
|
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
|
|
MaxWidth="440" TextWrapping="Wrap"
|
|
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
|
|
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
|
|
|
|
</Panel>
|
|
</ScrollViewer>
|
|
|
|
<!--
|
|
Groups: making them, renaming them, and taking them away.
|
|
|
|
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
|
|
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
|
|
in the host's own editor, on the left, for the same reason its key and its password are.
|
|
|
|
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
|
|
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
|
|
happen.
|
|
-->
|
|
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
|
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
|
<StackPanel Spacing="8">
|
|
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
|
|
VerticalAlignment="Center" />
|
|
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
|
|
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
|
|
</StackPanel>
|
|
|
|
<!--
|
|
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
|
|
third of this column to say what a row of chips says in one line.
|
|
-->
|
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
|
IsVisible="{Binding Vault.HasGroups}">
|
|
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
|
|
Background="Transparent" MaxHeight="72">
|
|
<ListBox.ItemsPanel>
|
|
<ItemsPanelTemplate>
|
|
<StackPanel Orientation="Horizontal" />
|
|
</ItemsPanelTemplate>
|
|
</ListBox.ItemsPanel>
|
|
<ListBox.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
|
|
<StackPanel Margin="2,4" Spacing="1">
|
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
|
|
FontSize="11.5" />
|
|
<Border Classes="chip warn" Padding="4,0"
|
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
|
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
|
</Border>
|
|
</StackPanel>
|
|
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
|
|
Foreground="{StaticResource TextFaint}" />
|
|
</StackPanel>
|
|
</DataTemplate>
|
|
</ListBox.ItemTemplate>
|
|
</ListBox>
|
|
</ScrollViewer>
|
|
|
|
<!--
|
|
A group is no longer only a name, so this is no longer only a box. The four fields under the
|
|
name are what the hosts inside inherit when they say nothing themselves; every one of them may
|
|
be left empty, and empty means "lend nothing" rather than "unset". The parent picker leaves out
|
|
this group and everything beneath it, so a cycle cannot be made here — which is a courtesy
|
|
rather than the guarantee, because one assembled offline on two machines was never offered this
|
|
list. See HostInheritance.
|
|
-->
|
|
<StackPanel Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
|
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
|
|
FontSize="11" MinHeight="26" Padding="8,3" />
|
|
<ComboBox ItemsSource="{Binding Vault.GroupEditorParentChoices}"
|
|
SelectedItem="{Binding Vault.GroupEditorSelectedParent}"
|
|
MinWidth="150" FontSize="11" MinHeight="26">
|
|
<ComboBox.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:GroupChoice">
|
|
<TextBlock Text="{Binding Label}" />
|
|
</DataTemplate>
|
|
</ComboBox.ItemTemplate>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
|
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
<NumericUpDown Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1" Maximum="65535"
|
|
FormatString="0" ShowButtonSpinner="False" PlaceholderText="default port"
|
|
Width="120" FontSize="11" MinHeight="26" />
|
|
<TextBox Text="{Binding Vault.GroupEditorDefaultUsername}"
|
|
PlaceholderText="default username" Width="150"
|
|
FontSize="11" MinHeight="26" Padding="8,3" />
|
|
<ComboBox ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
|
|
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}"
|
|
MinWidth="180" FontSize="11" MinHeight="26">
|
|
<ComboBox.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
<TextBlock Text="{Binding Label}" />
|
|
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="10"
|
|
VerticalAlignment="Center"
|
|
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
|
</StackPanel>
|
|
</DataTemplate>
|
|
</ComboBox.ItemTemplate>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
|
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
|
|
Command="{Binding Vault.SaveGroupCommand}" />
|
|
<Button Classes="ghost" Content="EDIT SELECTED" Command="{Binding Vault.EditGroupCommand}" />
|
|
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
|
|
</StackPanel>
|
|
</StackPanel>
|
|
|
|
<!--
|
|
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
|
|
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
|
|
two panels share one pending deletion, and the sidebar checks the same way.
|
|
-->
|
|
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
|
|
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
|
|
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
|
</Border>
|
|
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
</Grid>
|
|
</Grid>
|
|
|
|
</UserControl>
|