Public Access
Desktop: put the keyboard in the closed vault's card on launch, and let Enter sign in and enrol
This commit is contained in:
1 parent
a4b81da597
commit
372a7cbdc7
3 files changed
+175
-3
No files matched your search
@@ -383,7 +383,12 @@
|
|||||||
not replayed. See MainWindow.axaml.cs.
|
not replayed. See MainWindow.axaml.cs.
|
||||||
-->
|
-->
|
||||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||||
PlaceholderText="https://dodossh.example" />
|
PlaceholderText="https://dodossh.example">
|
||||||
|
<!-- Enter signs in, as it unlocks on the unlock card. -->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||||
@@ -395,8 +400,14 @@
|
|||||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||||
<TextBlock Classes="hint"
|
<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 keychain." />
|
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 keychain." />
|
||||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
<TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
|
||||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
PasswordChar="•" />
|
||||||
|
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
|
||||||
|
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ internal sealed partial class MainWindow : Window
|
|||||||
|
|
||||||
private bool wasUnlocked;
|
private bool wasUnlocked;
|
||||||
|
|
||||||
|
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
||||||
|
private ShellState? closedState;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -184,6 +187,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
ShellState.Locked => UnlockPane.PassphraseBox,
|
ShellState.Locked => UnlockPane.PassphraseBox,
|
||||||
ShellState.NeedsServer => ServerUrlBox,
|
ShellState.NeedsServer => ServerUrlBox,
|
||||||
|
ShellState.NeedsEnrollment => NewPassphraseBox,
|
||||||
_ => this,
|
_ => this,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,6 +311,14 @@ internal sealed partial class MainWindow : Window
|
|||||||
Terminal.Source = viewModel.TerminalPageUrl;
|
Terminal.Source = viewModel.TerminalPageUrl;
|
||||||
|
|
||||||
wasUnlocked = viewModel.IsUnlocked;
|
wasUnlocked = viewModel.IsUnlocked;
|
||||||
|
closedState = null;
|
||||||
|
|
||||||
|
// The shell may have reached its card before this window was given it, and then no change would
|
||||||
|
// ever say so.
|
||||||
|
if (!viewModel.IsUnlocked)
|
||||||
|
{
|
||||||
|
FocusClosedVaultCard(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
@@ -380,10 +392,32 @@ internal sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||||
}
|
}
|
||||||
|
else if (!unlocked && viewModel.State != closedState)
|
||||||
|
{
|
||||||
|
FocusClosedVaultCard(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
closedState = unlocked ? null : viewModel.State;
|
||||||
wasUnlocked = unlocked;
|
wasUnlocked = unlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
|
||||||
|
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
|
||||||
|
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
|
||||||
|
/// raised for every state change, and the state does not change while they type. Posted, because the card
|
||||||
|
/// is revealed by a binding the same change has not reached yet.
|
||||||
|
/// </remarks>
|
||||||
|
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
closedState = viewModel.State;
|
||||||
|
|
||||||
|
var home = ClosedVaultKeyboardHome;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using DodoSSH.Client.App.Views;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Session.Tests;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
using DodoSSH.Client.Transfer;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keyboard on the cards a closed vault shows: where it is when the card appears, and what Enter does.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Launch used to leave the keyboard nowhere. Focus only moved on the way out of an unlocked vault, so the
|
||||||
|
/// passphrase somebody started typing the moment the window appeared went nowhere until they clicked the box,
|
||||||
|
/// and Enter in the server address did nothing at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The whole window, which <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> says cannot be shown
|
||||||
|
/// here: the terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the
|
||||||
|
/// tree before the window is shown, which is everything that test is about and nothing these are.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ClosedVaultFocusTests
|
||||||
|
{
|
||||||
|
private const string Passphrase = "a sufficiently long passphrase";
|
||||||
|
|
||||||
|
private static readonly Argon2Profile CheapProfile =
|
||||||
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AFreshMachine_PutsTheKeyboardInTheServerAddressAndEnterSignsIn()
|
||||||
|
{
|
||||||
|
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||||
|
await caches.MigrateAsync(Token);
|
||||||
|
|
||||||
|
await using var workspace = Workspace();
|
||||||
|
await using var shell = Shell(caches, workspace);
|
||||||
|
|
||||||
|
await shell.StartAsync(Token);
|
||||||
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() => WithWindow(shell, window =>
|
||||||
|
{
|
||||||
|
var box = window.FindControl<TextBox>("ServerUrlBox").ShouldNotBeNull();
|
||||||
|
|
||||||
|
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(box);
|
||||||
|
|
||||||
|
box.KeyBindings.ShouldContain(binding =>
|
||||||
|
binding.Gesture == new KeyGesture(Key.Enter) && binding.Command == shell.SignInCommand);
|
||||||
|
}),
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEnrolledMachine_PutsTheKeyboardInThePassphraseBox()
|
||||||
|
{
|
||||||
|
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||||
|
await caches.MigrateAsync(Token);
|
||||||
|
|
||||||
|
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, CheapProfile)
|
||||||
|
.EnrollAsync("https://dodossh.example", Passphrase, "laptop", "Personal", Token);
|
||||||
|
|
||||||
|
await using var workspace = Workspace();
|
||||||
|
await using var shell = Shell(caches, workspace);
|
||||||
|
|
||||||
|
await shell.StartAsync(Token);
|
||||||
|
shell.State.ShouldBe(ShellState.Locked);
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() => WithWindow(shell, window =>
|
||||||
|
window.FocusManager!.GetFocusedElement()
|
||||||
|
.ShouldBeSameAs(window.FindControl<UnlockCard>("UnlockPane").ShouldNotBeNull().PassphraseBox)),
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerminalWorkspace Workspace() =>
|
||||||
|
new(
|
||||||
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||||
|
Substitute.For<ISshConnectionFactory>(),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
private static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
|
||||||
|
new(
|
||||||
|
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
|
||||||
|
caches,
|
||||||
|
workspace,
|
||||||
|
new VaultKnownHostStore(),
|
||||||
|
new UnavailableDeviceKeyStore(),
|
||||||
|
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||||
|
TimeProvider.System,
|
||||||
|
Substitute.For<ISftpSessionFactory>(),
|
||||||
|
CheapProfile);
|
||||||
|
|
||||||
|
private static void WithWindow(MainWindowViewModel shell, Action<MainWindow> assert)
|
||||||
|
{
|
||||||
|
var window = new MainWindow { DataContext = shell };
|
||||||
|
|
||||||
|
if (window.FindControl<NativeWebView>("Terminal") is { Parent: Panel panel } web)
|
||||||
|
{
|
||||||
|
panel.Children.Remove(web);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
assert(window);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user