Public Access
Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh machine takes a server URL, signs in through the browser, enrolls, and from then on opens with the passphrase alone. DodoSSH.Client.Session is the composition layer: where a profile lives, how it unlocks, and how a machine gets one. ClientPaths picks a non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%, because a SQLite cache that roams between two machines is a corrupt one, and each machine's outbox is its own. SessionOpener needs no transport at all and could not reach one if it wanted to; that is the offline unlock, asserted rather than asserted about. A wrong passphrase, a stale KDF and a grant revoked by a rekey are three different answers, because the remedies are three different things and telling someone to retype a passphrase that was never the problem is worse than saying nothing. The shell's states are the onboarding story. The recovery code gets its own state that cannot be clicked past: it exists for one moment, losing it with the passphrase loses the vault, and there is no server-side reset by design. It is dropped from memory on confirmation rather than merely hidden. Sign-in is a delegate over IVaultServer, so the whole state machine runs in a test against an in-memory server — no browser, no identity provider, no toolkit. The view models are plain observable objects, which is what makes that possible. What it does not cover is whether the XAML binds to the right names; that needs a rendered tree and Avalonia.Headless, and is its own piece of work. Three things found by doing it rather than by reading it: - Pooled SQLite connections keep the database file open after the last context is disposed. On Windows that means locked, so the application could never replace its own cache — and a test could not clean up after itself, which is how it surfaced. Dispose now clears the pool. - EF's SQLite provider puts the database in WAL mode, so the cache is three files. A comment in ClientCacheFactory claimed the opposite; reading PRAGMA journal_mode off a real launch settled it. WAL is the right mode here — a sync pass writes while the interface reads — so the comment was wrong on the merits as well as on the fact. - Enrolling a device key with nowhere to keep the private half would put a wrap on the server nobody can open and make the device list claim this machine can unlock without a passphrase. Device binding is now optional and the shell declines it until the OS keystore is wired. Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db and migrated it on first launch, and msedgewebview2 held an established connection to the data plane while the unlock overlay covered it — which is the point of covering the WebView rather than collapsing it, since a NativeWebView that is never laid out is never realised. 630 tests, up from 593. The recovery-code gate and the offline unlock were each verified by breaking them and watching the right test fail. Still to do for M1's actual definition of done: the manual run against the real API and a real Keycloak. Credentials are not a synced entity type yet, so a connection still asks for a password, and the interface says so rather than implying otherwise.
This commit is contained in:
@@ -4,72 +4,291 @@
|
||||
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="DodoSSH"
|
||||
Width="1100"
|
||||
Height="720"
|
||||
MinWidth="640"
|
||||
MinHeight="400"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="820"
|
||||
MinHeight="520"
|
||||
Background="#10131a">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.hint">
|
||||
<Setter Property="Foreground" Value="#7b8394" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.heading">
|
||||
<Setter Property="Foreground" Value="#e6e9f0" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="#171b24" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="24" />
|
||||
<Setter Property="MaxWidth" Value="520" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<!--
|
||||
The terminal's WebView stays in the visual tree at all times and is covered by the setup and unlock
|
||||
screens rather than being collapsed. A NativeWebView hosts a real child window, and hiding it means
|
||||
never realising it — which would leave the terminal blank on the first connection after unlocking.
|
||||
-->
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" ColumnDefinitions="340,*">
|
||||
|
||||
<!-- Account bar -->
|
||||
<Border Grid.Row="0" Grid.ColumnSpan="2" Padding="12,8" Background="#171b24"
|
||||
IsVisible="{Binding IsUnlocked}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Vault.VaultName}" Foreground="#e6e9f0" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding AccountName}" Classes="hint" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Vault.Status}" Classes="hint" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="520" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="offline" Foreground="#c8a55a" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !IsOnline}" />
|
||||
<Button Content="Sign in" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}" />
|
||||
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
|
||||
<Button Content="Lock" Command="{Binding LockCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Host list -->
|
||||
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto"
|
||||
Background="#131722" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<ListBox Grid.Row="0" Margin="6"
|
||||
ItemsSource="{Binding Vault.Hosts}"
|
||||
SelectedItem="{Binding Vault.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>
|
||||
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
|
||||
FontFamily="ui-monospace,Consolas,monospace" />
|
||||
</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 Vault.IsEditing}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBox Text="{Binding Vault.EditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding Vault.EditorHostname}" PlaceholderText="hostname or address" />
|
||||
<NumericUpDown Value="{Binding Vault.EditorPort}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" />
|
||||
<TextBox Text="{Binding Vault.EditorUsername}" PlaceholderText="username" />
|
||||
<TextBox Text="{Binding Vault.EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="60" TextWrapping="Wrap" />
|
||||
<CheckBox IsChecked="{Binding Vault.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 Vault.SaveHostCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding Vault.CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
|
||||
IsVisible="{Binding !Vault.IsEditing}">
|
||||
<Button Content="Add" Command="{Binding Vault.NewHostCommand}" />
|
||||
<Button Content="Edit" Command="{Binding Vault.EditSelectedHostCommand}" />
|
||||
<Button Content="Delete" Command="{Binding Vault.DeleteHostCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Terminal column -->
|
||||
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!--
|
||||
Typed per connection. SyncEntityType.Credential exists in the contract but is not synced
|
||||
yet, so the vault genuinely does not hold this — saying so beats a password box that looks
|
||||
like it should have been remembered.
|
||||
-->
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored yet)"
|
||||
PasswordChar="•" Width="220" VerticalAlignment="Center" />
|
||||
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="hint" FontSize="11" VerticalAlignment="Center"
|
||||
Text="Credentials are not in the vault yet." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<!--
|
||||
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="10,8" Background="#2b2410" 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="#e8dcb0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
Foreground="#f4ecd0" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Trust and connect" Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
|
||||
Foreground="#d59aa1" 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.
|
||||
-->
|
||||
<Border Padding="10,8" Background="#1b2432" IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="#bcd2ea" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="#141b26" CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="#dfe6f0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding Detail}" Classes="hint" FontSize="11"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="Dismiss all" Command="{Binding Vault.AcknowledgeAllConflictsCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser process
|
||||
tree, so twenty tabs would cost twenty of them.
|
||||
-->
|
||||
<NativeWebView Grid.Row="2" x:Name="Terminal" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Setup and unlock, over the top. -->
|
||||
<Border Background="#10131a" IsVisible="{Binding !IsUnlocked}">
|
||||
|
||||
<Panel>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsStarting}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="heading" Text="DodoSSH" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Connect to your server" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
|
||||
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
|
||||
<Button Content="Sign in with your browser" Command="{Binding SignInCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Content="Create my vault" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Shown once and impossible to skip. This is the only moment the code exists, and losing it
|
||||
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
|
||||
design.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
|
||||
<Border Background="#0c0f15" CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
FontSize="16" Foreground="#9ee6b4" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
||||
Content="I have written it down somewhere safe" />
|
||||
<Button Content="Continue" Command="{Binding ConfirmRecoveryCodeCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<Button Content="Unlock" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!-- Connection bar. Replaced by the host list once the vault is wired up. -->
|
||||
<Border Grid.Row="0" Padding="10,8" Background="#171b24">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Host}" PlaceholderText="host" Width="200" VerticalAlignment="Center" />
|
||||
<NumericUpDown Value="{Binding Port}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" Width="110" VerticalAlignment="Center" />
|
||||
<TextBox Text="{Binding Username}" PlaceholderText="username" Width="150" VerticalAlignment="Center" />
|
||||
<TextBox Text="{Binding Password}" PlaceholderText="password" PasswordChar="•"
|
||||
Width="170" VerticalAlignment="Center" />
|
||||
<Button Content="Connect"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsEnabled="{Binding !IsConnecting}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Status}" Foreground="#7b8394"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<StackPanel Grid.Row="1">
|
||||
|
||||
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="#e8dcb0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding PendingHostKey.Fingerprint}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
Foreground="#f4ecd0" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Trust and connect" Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
|
||||
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
|
||||
Foreground="#d59aa1" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
||||
process, so twenty tabs would cost twenty renderer processes.
|
||||
-->
|
||||
<NativeWebView Grid.Row="2" x:Name="Terminal" />
|
||||
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
</Window>
|
||||
|
||||
Reference in New Issue
Block a user