Public Access
Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes Enter, which is the gesture everybody makes after typing a password and which did nothing until they found the button. Signing in survives a relaunch. The refresh token is kept in the local cache, sealed under the vault's own cache key, so a later launch resumes the session through the refresh grant with no browser and nobody present — and because it is sealed under that key, only an unlocked vault can resume it. A locked client therefore cannot reach the server at all, which is a consequence worth stating rather than working around; docs/crypto.md §3.2 records it. Every sync pass asks the shell for a connection rather than reading one captured at unlock, so a laptop that unlocked on a train is online within a minute of finding a network, with nothing pressed. Unlocking itself still never waits on a socket. Signing out empties this machine: the profile, the cached items, the outbox and this machine's device key, with the account's row withdrawn when the server can be reached. It asks first and says what it costs — the outbox count when the vault is open, an admission that it cannot be counted when it is not, and the shells that keep running either way. The vault is on the server and is untouched, which is what makes the same button the only honest answer to a forgotten passphrase, so it is on the unlock screen as well as in preferences. It cannot end the session at the identity provider, and says so. Two defects surfaced on the way. The synchronisation pass that runs when the vault opens never ran at all: the loop is started from inside the unlock command, so the busy flag it yields to was raised by that command — the first sync was a minute late on every launch. And signing in from preferences while unlocked threw an unlock screen over an open vault whose keys were still in memory. The unlock card and the new confirmation live in their own controls because MainWindow cannot be laid out headless, so markup left inside it is markup no test can measure; both are now measured at the window's minimum size in the shapes that grow. What is still unverified is the composed window itself.
This commit is contained in:
@@ -252,7 +252,13 @@
|
||||
<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" />
|
||||
<!--
|
||||
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
|
||||
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
|
||||
not replayed. See MainWindow.axaml.cs.
|
||||
-->
|
||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||
PlaceholderText="https://dodossh.example" />
|
||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
@@ -296,54 +302,22 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to
|
||||
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
|
||||
machine that never had it invites the reading that something is broken — and the passphrase
|
||||
box beside it is not a fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
<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." />
|
||||
<!--
|
||||
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
|
||||
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
|
||||
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
|
||||
<views:UnlockCard x:Name="UnlockPane" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
The confirmation, in place of the unlock card rather than under it: the card is already close
|
||||
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
|
||||
worse than one that shows one question at a time.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
@@ -74,6 +74,23 @@ internal sealed partial class MainWindow : Window
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard belongs once the vault is no longer open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two ways out of an unlocked vault, and they land on different screens: locking shows the passphrase
|
||||
/// box, and signing out empties this machine and goes back to asking for a server. Both collapse the
|
||||
/// controls the keyboard was on, and <c>Focus()</c> on a collapsed control is a no-op that is not
|
||||
/// replayed when it is revealed — so a fixed target would leave whoever signed out with a window that
|
||||
/// swallows every keystroke until they click something.
|
||||
/// </remarks>
|
||||
private IInputElement ClosedVaultKeyboardHome => shell?.State switch
|
||||
{
|
||||
ShellState.Locked => UnlockPane.PassphraseBox,
|
||||
ShellState.NeedsServer => ServerUrlBox,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The shortcuts the window owns.
|
||||
/// </summary>
|
||||
@@ -209,7 +226,7 @@ internal sealed partial class MainWindow : Window
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(UnlockPassphrase);
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
@@ -76,18 +77,49 @@
|
||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open, so nothing depends on this being pressed." />
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}"
|
||||
ToolTip.Tip="Opens your browser. Syncing needs a connection; everything else works without one." />
|
||||
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
|
||||
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11" Margin="0,8,0,0"
|
||||
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
||||
button that actually does it and two sign-out buttons on one screen is one too many.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="danger" Content="SIGN OUT"
|
||||
Command="{Binding SignOutCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding !IsConfirmingSignOut}" />
|
||||
</Grid>
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14" Margin="0,14,0,0"
|
||||
IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SignOutCard"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The sign-out confirmation.
|
||||
|
||||
One control used in two places — the preferences screen, where somebody is leaving a machine on
|
||||
purpose, and the unlock screen, where somebody has forgotten their passphrase and this is the only way
|
||||
forward. The two moments are different and the warning is not, which is why this is a shared control
|
||||
rather than two blocks that would drift apart.
|
||||
|
||||
It is a bare StackPanel and not a card: the two hosts frame it differently, because a card that centres
|
||||
itself is right over a lock screen and wrong halfway down a scrolling column of preferences.
|
||||
|
||||
Everything it says is something the state machine can actually answer. The count comes from the outbox,
|
||||
the shell count from the workspace, and the sentence about the identity provider is there because
|
||||
nothing here can end that session — see MainWindowViewModel.ConfirmSignOutAsync.
|
||||
-->
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<TextBlock Classes="heading" FontSize="15" Text="Sign out of this machine?" />
|
||||
|
||||
<TextBlock Text="{Binding SignOutWarning}" Foreground="{StaticResource WarnText}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." />
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Signing out does not close them, exactly as locking does not. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
||||
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Your session at the identity provider is not ended by this — DodoSSH has no way to end it — so on a machine that is not yours, sign out there too." />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
||||
internal sealed partial class SignOutCard : UserControl
|
||||
{
|
||||
public SignOutCard() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.UnlockCard"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The unlock screen's contents.
|
||||
|
||||
Extracted from MainWindow.axaml so that it can be laid out and looked at: MainWindow cannot be shown in
|
||||
the headless session at all — WebView2's adapter refuses its thread, which LayoutHarnessTests pins — so
|
||||
markup that stays inside that file is markup no test can measure. This card is the one on the screen
|
||||
with the least room to spare and the most conditional content: a disclosure about shells left running,
|
||||
and a way out for a forgotten passphrase, both of which appear underneath a form that already fills most
|
||||
of the height the window guarantees.
|
||||
|
||||
A bare StackPanel rather than a card, because the card is the frame MainWindow puts around it.
|
||||
-->
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly, and reached from the window through
|
||||
PassphraseBox. The terminal's native child window keeps Win32 focus when it is collapsed, so without
|
||||
that this box would show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
|
||||
Enter unlocks. A KeyBinding on the box rather than a handler on the window, because this is a property
|
||||
of the control the passphrase is typed into and not of the shell's state: the keyboard is put here on
|
||||
every lock, so the one gesture everybody makes after typing a password reaches the command this box
|
||||
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to release the
|
||||
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never
|
||||
had it invites the reading that something is broken — and the passphrase box beside it is not a
|
||||
fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed while
|
||||
locked, so a shell left running is invisible as well as unstopped — and a screen saying "Unlock your
|
||||
vault" over a machine that still holds authenticated SSH channels is exactly the kind of half-truth
|
||||
this project writes down instead of implying. Visible only when there is something to disclose, so an
|
||||
ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The way out of a forgotten passphrase, and the only one there is. Nothing can recover a passphrase —
|
||||
there is no server-side reset by design — so the honest offer is to reset this machine and sign in
|
||||
again, which costs whatever this machine had not yet pushed and nothing else. Stated here rather than
|
||||
left to be discovered, because somebody stuck on this screen has no other route and quitting the
|
||||
application does not help.
|
||||
-->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." />
|
||||
<Button Classes="ghost" Content="RESET THIS MACHINE"
|
||||
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The unlock screen's contents.</summary>
|
||||
internal sealed partial class UnlockCard : UserControl
|
||||
{
|
||||
public UnlockCard() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard goes when the vault is locked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed the way <see cref="HostSidebar.KeyboardTarget"/> is, and for the same reason: the window
|
||||
/// owns the focus policy — it has to take the keyboard off the terminal's native child window first —
|
||||
/// and the control it hands it to belongs to whichever screen is showing.
|
||||
/// </remarks>
|
||||
internal TextBox PassphraseBox => UnlockPassphrase;
|
||||
}
|
||||
Reference in New Issue
Block a user