Public Access
Merge branch 'claude/vault-creation-sharing-62c0b6'
This commit is contained in:
@@ -73,6 +73,37 @@ Press Ctrl+K from the FILES screen and connect to a host whose key is not yet ap
|
||||
**Failure means:** the prompt is behind the screen that asked for it, and the connection is blocked on a
|
||||
question that cannot be reached.
|
||||
|
||||
### 1.6 The vault menu draws above the terminal's rectangle · **the one with a precedent**
|
||||
|
||||
With a terminal open and showing, press the `⌄` beside the Vaults tab.
|
||||
|
||||
**Pass:** the window leaves the terminal for the Vaults tab as the menu opens, and the menu is drawn whole
|
||||
over the screen underneath it — no part of it clipped along the WebView's edge.
|
||||
|
||||
**Failure means:** the popup is being composited under the renderer's native child window, and the guard
|
||||
this design relies on has stopped working. It is not supposed to be possible: `OnVaultMenuPressed` selects
|
||||
the Vaults tab *before* opening the flyout, and a page surface is one where the renderer is not drawn — the
|
||||
same move QuickConnect makes. `OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt` asserts
|
||||
the ordering headlessly, which is as far as a headless test can go: it has no native window, so it cannot
|
||||
see what is painted over what. This check is the other half.
|
||||
|
||||
It is on this list rather than assumed because the note beside the `+` button in `TerminalTabs.axaml`
|
||||
refuses a flyout on exactly this reasoning, and `docs/platform-flags.md` records what this project has
|
||||
already paid for treating a rendering claim as settled without looking.
|
||||
|
||||
### 1.7 Switching a vault off does not switch it out
|
||||
|
||||
With a team vault holding at least one host: press `⌄` beside Vaults, switch the team vault off, and check
|
||||
the hosts screen, the keychain and the pins.
|
||||
|
||||
**Pass:** that vault's hosts, keys and pins are gone from all three; the vault is still in the "file this
|
||||
into" picker on a host editor; the sync indicator still settles rather than stalling; and a host in another
|
||||
vault that authenticates with a key filed in the switched-off one still connects.
|
||||
|
||||
**Failure means:** the filter has reached past the projections it is allowed to touch. See
|
||||
`VaultViewModel.IsVaultShown` for the list, and `VaultVisibilityTests` for the same assertions made against
|
||||
view models — this check is the version with a real connection on the end of it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
|
||||
@@ -446,6 +446,36 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The two halves of the Vaults tab: the tab itself, and the caret that opens its menu. Two buttons
|
||||
because they do two things, drawn as one pill because they are one destination — so the pair meets in
|
||||
the middle with no gap, no doubled border down the join, and the outer corners rounded as any tab's
|
||||
are.
|
||||
|
||||
Both the Button and its ContentPresenter carry a CornerRadius above, so both have to be squared here:
|
||||
setting only one leaves a rounded outline inside a square hit area, which shows as a hairline of the
|
||||
strip's background cutting through the join.
|
||||
|
||||
After Button.tab.fixed rather than beside it, because .caret takes that rule's padding back to zero
|
||||
and Avalonia has no specificity — the later declaration is the one that wins. See the ordering note
|
||||
above.
|
||||
-->
|
||||
<Style Selector="Button.tab.split">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.split /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
||||
<Setter Property="BorderThickness" Value="1,1,0,1" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.caret">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0,8,8,0" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.caret /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="0,8,8,0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A pair of buttons standing in for a two-way choice, inside a pane rather than down a rail. Not the
|
||||
.cat style, which stretches to fill a 176-pixel rail row and would be wrong at this width — and which
|
||||
@@ -843,8 +873,12 @@
|
||||
<!--
|
||||
A context menu, in this window's palette rather than the theme's. The Fluent default is a lighter grey
|
||||
than anything else here, which on a near-black chrome reads as a dialog from another application.
|
||||
|
||||
FlyoutPresenter is in the same rule rather than one of its own: the vault menu on the tab strip is a
|
||||
plain Flyout, and a popup that came out of this window in a different grey from the context menu two
|
||||
screens away would read as two applications rather than one.
|
||||
-->
|
||||
<Style Selector="MenuFlyoutPresenter, ContextMenu">
|
||||
<Style Selector="MenuFlyoutPresenter, ContextMenu, FlyoutPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Chrome}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
|
||||
@@ -62,22 +62,53 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- The create form, in place rather than in a modal: this window has no idiom for one. -->
|
||||
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingTeam}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
||||
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
||||
<!-- The create forms, in place rather than in a modal: this window has no idiom for one. -->
|
||||
<StackPanel Grid.Row="2">
|
||||
|
||||
<Border Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingTeam}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
||||
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The name-a-vault form, and it is in this column rather than beside the VAULTS list it belongs to
|
||||
for one reason: that list lives inside a ScrollViewer bound to HasSelection, so with no teams at
|
||||
all it is not on screen — and "no teams at all" is exactly the state somebody arrives in from
|
||||
the tab strip's New vault entry. Here it is reachable whatever else is true.
|
||||
|
||||
One field. A team is made behind it and named after the vault, and its slug is derived — see
|
||||
TeamsViewModel.CreateVaultAsync. Asking for a slug as the form above does would be asking for a
|
||||
URL handle from somebody who has not been told they are making a team.
|
||||
-->
|
||||
<Border Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW VAULT" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="{Binding NewVaultDestination}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Its key is made on this machine and nobody else has it yet. Add people to the team, then press SHARE KEY." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -276,8 +307,13 @@
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
|
||||
<!--
|
||||
Opens the form under the team list rather than creating one outright. It used to create a
|
||||
vault named after the team, which meant a team with three of them held three vaults with
|
||||
the same name and no way to tell them apart.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
|
||||
Command="{Binding CreateVaultCommand}"
|
||||
Command="{Binding NewVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -75,14 +75,101 @@
|
||||
"a page, and not one of these two", and the other two are the existing IsTransfersShowing and
|
||||
IsBucketsShowing that both heads already use. Nothing here can light two at once.
|
||||
-->
|
||||
<Button Classes="flat tab fixed" Classes.active="{Binding IsVaultsTab}"
|
||||
Command="{Binding ShowVaultsCommand}"
|
||||
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Vaults" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!--
|
||||
Two buttons drawn as one pill: the tab, and a caret that opens the vault menu. Split rather than
|
||||
one button with a menu, because the tab's job is to go somewhere and that must stay a single
|
||||
click — a tab you cannot press without being asked a question is not a tab.
|
||||
|
||||
── WHY A FLYOUT IS SAFE HERE, WHEN THE + BUTTON BELOW STILL REFUSES ONE ────────────────────────
|
||||
That refusal stands and its reasoning is unchanged: this strip sits directly above the WebView's
|
||||
rectangle, and whether a popup dropping into it composites above a native child window is not
|
||||
something this project treats as settled without a screenshot.
|
||||
|
||||
What makes the question not arise here is the order in the handler. OnVaultMenuPressed selects
|
||||
the Vaults tab *first*, which sets the shell's surface to a page and collapses the renderer — so
|
||||
by the time the flyout opens there is no native child window under it. Exactly the move
|
||||
QuickConnect already makes. It is also the behaviour a user expects: the caret belongs to the
|
||||
Vaults tab, so pressing it going to Vaults is not a surprise.
|
||||
|
||||
The handler is explicit rather than Button.Flyout's own open, so that ordering is a thing the
|
||||
code states and the headless suite can assert, rather than a thing the framework happens to do.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="0">
|
||||
|
||||
<Button Classes="flat tab fixed split" Classes.active="{Binding IsVaultsTab}"
|
||||
Command="{Binding ShowVaultsCommand}"
|
||||
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Vaults" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="VaultMenu" Classes="flat tab fixed caret" Width="22"
|
||||
Classes.active="{Binding IsVaultsTab}"
|
||||
Click="OnVaultMenuPressed"
|
||||
ToolTip.Tip="Choose which vaults this window shows, or make a new one">
|
||||
<TextBlock Text="⌄" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
|
||||
<FlyoutBase.AttachedFlyout>
|
||||
<Flyout Placement="BottomEdgeAlignedLeft">
|
||||
<StackPanel Width="230" Spacing="8">
|
||||
|
||||
<!--
|
||||
Chips rather than checkable menu items. Nothing in this application uses a checkable
|
||||
MenuItem, and binding one needs an ItemContainerTheme to reach ToggleType and IsChecked
|
||||
plus a composed collection to put a fixed entry after a bound one — where the chip
|
||||
toggle beside every host's tags already says on-and-off in this window's own language.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="SHOW ITEMS FROM"
|
||||
IsVisible="{Binding HasVaultSwitches}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding VaultToggles}"
|
||||
IsVisible="{Binding HasVaultSwitches}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultToggleViewModel">
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding IsShown}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Margin="0,0,0,4"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="{Binding Display}" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
A hint rather than a disabled switch, because the personal vault's chip is drawn lit and
|
||||
pressing it says the same thing in the status bar. One sentence under the list is where
|
||||
somebody looks when a chip does not move.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasVaultSwitches}"
|
||||
Text="Switching a vault off only stops it being listed here. It still syncs, and hosts that authenticate with its keys still connect." />
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}"
|
||||
IsVisible="{Binding HasVaultSwitches}" />
|
||||
|
||||
<!--
|
||||
A handler rather than a Command binding, because this one navigates and the menu has to
|
||||
shut on the way. A Flyout stays open when something inside it is pressed — which is
|
||||
right for the chips above, where switching two vaults off is one visit — and wrong for
|
||||
the one entry that leaves.
|
||||
-->
|
||||
<Button Classes="ghost" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Content="New vault…"
|
||||
Click="OnNewVaultPressed"
|
||||
ToolTip.Tip="Names a vault and makes a team to own it, so you can invite people to it and give them roles" />
|
||||
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</FlyoutBase.AttachedFlyout>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<Button Classes="flat tab fixed" Classes.active="{Binding IsTransfersShowing}"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
@@ -53,4 +55,54 @@ internal sealed partial class TerminalTabs : UserControl
|
||||
// it. Its failures are the workspace's to report, not this strip's.
|
||||
shell.CloseTabCommand.Execute(tab);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the vault menu, on the Vaults tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The tab is selected before the menu opens, and that order is the whole reason this is a handler
|
||||
/// rather than <c>Button.Flyout</c>.</b> Selecting it puts the shell on a page, which collapses the
|
||||
/// renderer — so the popup never has to drop over the WebView's native child window, and the question
|
||||
/// this strip's comment refuses to answer without a screenshot does not come up. See the comment on the
|
||||
/// caret in the markup, and <c>docs/platform-flags.md</c> for what treating such a question as settled
|
||||
/// has already cost this project.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is also what a user expects. The caret belongs to the Vaults tab, so pressing it arriving at
|
||||
/// Vaults is the same gesture as pressing the tab, with a menu on the end.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultMenuPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel shell || sender is not Control caret)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
shell.ShowVaultsCommand.Execute(null);
|
||||
|
||||
FlyoutBase.ShowAttachedFlyout(caret);
|
||||
}
|
||||
|
||||
/// <summary>Leaves for the teams screen with the new-vault form open, shutting the menu behind it.</summary>
|
||||
/// <remarks>
|
||||
/// The menu is closed first, because the command navigates and a flyout left open would be hanging over
|
||||
/// a screen it has nothing to do with. A <c>Flyout</c> does not close when something inside it is
|
||||
/// pressed — which is what the switches above it want, and not what this wants.
|
||||
/// </remarks>
|
||||
private void OnNewVaultPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("VaultMenu") is { } caret)
|
||||
{
|
||||
FlyoutBase.GetAttachedFlyout(caret)?.Hide();
|
||||
}
|
||||
|
||||
shell.ShowNewVaultCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using DodoSSH.Client.Storage;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Which vaults this machine has been asked to leave off the screens that list their contents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A reading preference, and only that.</b> Somebody in four teams does not want four teams' hosts in
|
||||
/// front of them all day, and the answer is a switch per vault rather than four sign-ins. What this must
|
||||
/// never become is an access control: a hidden vault still syncs, its key stays in the keyring, and a
|
||||
/// shown host that authenticates with a key filed in it still connects. Hiding changes what is drawn and
|
||||
/// nothing else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately unknown to <see cref="VaultSession"/>.</b> Nothing in that type takes one of these, and
|
||||
/// nothing in it should: <see cref="VaultSession.ReadableVaults"/> is what the sync loop walks and what the
|
||||
/// keyring is filled from, so a filter reaching it would be a preference that quietly stopped a team's
|
||||
/// vault from syncing — and the user would find out weeks later, from a host that was never there. The
|
||||
/// dependency runs one way, from here to the session's store, and this file is the only place the two meet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per machine, which is why it lives in the local cache rather than in the vault: the vault you set aside
|
||||
/// on a work laptop is not the one you set aside on a phone. It is in the <em>encrypted</em> cache rather
|
||||
/// than in <see cref="ClientSettings"/> because it is a list of vault ids, and that file is plaintext.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not thread-safe, and not meant to be. Every caller is a view model on the UI thread, which is the same
|
||||
/// reason the observable collections beside them are not either.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultVisibility
|
||||
{
|
||||
private readonly VaultStore store;
|
||||
private readonly HashSet<Guid> hidden;
|
||||
|
||||
private VaultVisibility(VaultStore store, IEnumerable<Guid> hidden)
|
||||
{
|
||||
this.store = store;
|
||||
this.hidden = [.. hidden];
|
||||
}
|
||||
|
||||
/// <summary>Reads this machine's preferences for an open session.</summary>
|
||||
/// <remarks>
|
||||
/// Read once, at unlock, rather than per list rebuild. The set is small and changes only when somebody
|
||||
/// presses a switch, and the lists that consult it are rebuilt on every background sync.
|
||||
/// </remarks>
|
||||
public static async Task<VaultVisibility> LoadAsync(
|
||||
VaultSession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
var vaults = session.Vault;
|
||||
|
||||
var stored = await vaults.ListHiddenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new VaultVisibility(vaults, stored);
|
||||
}
|
||||
|
||||
/// <summary>Whether this vault's items are kept off the screens.</summary>
|
||||
public bool IsHidden(Guid vaultId) => hidden.Contains(vaultId);
|
||||
|
||||
/// <summary>Whether this vault's items are drawn.</summary>
|
||||
/// <remarks>
|
||||
/// A vault nobody has said anything about is shown. That is what makes this feature cost nothing to
|
||||
/// ignore, and it is also what a fresh cache, a new grant and a re-granted vault all land on.
|
||||
/// </remarks>
|
||||
public bool IsShown(Guid vaultId) => !hidden.Contains(vaultId);
|
||||
|
||||
/// <summary>Records whether one vault's items are drawn.</summary>
|
||||
/// <remarks>
|
||||
/// The disk write happens first and the set is updated only if it returned. A preference that took
|
||||
/// effect on screen but never reached the cache would come back on the next launch, and a switch that
|
||||
/// silently forgets is worse than one that refuses.
|
||||
/// </remarks>
|
||||
public async Task SetHiddenAsync(Guid vaultId, bool value, CancellationToken cancellationToken)
|
||||
{
|
||||
await store.SetHiddenAsync(vaultId, value, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (value)
|
||||
{
|
||||
hidden.Add(vaultId);
|
||||
}
|
||||
else
|
||||
{
|
||||
hidden.Remove(vaultId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,23 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selected;
|
||||
|
||||
internal bool HasPins => vault.KnownHostPins.Count > 0;
|
||||
/// <summary>
|
||||
/// The pins from vaults this machine is showing, before the filter box narrows them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every count and every sentence on this screen is taken from here rather than from
|
||||
/// <c>vault.KnownHostPins</c>, so none of them can describe a pin the list is not drawing — a summary
|
||||
/// saying "3 that no host dials" over two rows would send somebody looking for a third.
|
||||
/// <para>
|
||||
/// The vault's own list stays whole and this is a projection of it, which is the rule stated on
|
||||
/// <c>VaultViewModel.IsVaultShown</c>: the trust the SSH handshake consults is read straight out of
|
||||
/// <c>VaultKnownHostStore</c> and has never come through either list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IEnumerable<KnownHostRowViewModel> Shown =>
|
||||
vault.KnownHostPins.Where(pin => vault.IsVaultShown(pin.VaultId));
|
||||
|
||||
internal bool HasPins => Shown.Any();
|
||||
|
||||
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||
|
||||
@@ -153,14 +169,14 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = vault.KnownHostPins.Count;
|
||||
var total = Shown.Count();
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
|
||||
var unused = Shown.Count(pin => !pin.IsDialledByAHost);
|
||||
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
|
||||
|
||||
return unused == 0
|
||||
@@ -169,10 +185,21 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
internal string EmptyMessage => HasPins
|
||||
? "No approved host key matches that."
|
||||
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
|
||||
+ "check — approving it puts it here.";
|
||||
/// <remarks>
|
||||
/// The hidden-vault case is its own sentence rather than falling into "nothing approved yet", which
|
||||
/// would be a screen telling somebody they have never approved a host key while the keys they approved
|
||||
/// sit in a vault they switched off in a menu.
|
||||
/// </remarks>
|
||||
internal string EmptyMessage => (HasPins, vault.KnownHostPins.Count) switch
|
||||
{
|
||||
(true, _) => "No approved host key matches that.",
|
||||
(false, > 0) =>
|
||||
"Every approved host key here is in a vault you have switched off. Press the ⌄ beside Vaults in "
|
||||
+ "the tab strip to switch one back on.",
|
||||
_ =>
|
||||
"Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you "
|
||||
+ "to check — approving it puts it here.",
|
||||
};
|
||||
|
||||
/// <summary>Withdraws trust in the selected pin.</summary>
|
||||
/// <remarks>
|
||||
@@ -212,7 +239,7 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
|
||||
VisiblePins.Clear();
|
||||
|
||||
foreach (var pin in vault.KnownHostPins.Where(Matches))
|
||||
foreach (var pin in Shown.Where(Matches))
|
||||
{
|
||||
VisiblePins.Add(pin);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,36 @@ using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One vault, as a switch in the tab strip's vault menu.</summary>
|
||||
/// <remarks>
|
||||
/// A record rebuilt per change rather than an observable row, which is the idiom the rest of these lists
|
||||
/// use: the menu is short, it is rebuilt whenever anything about the vault list moves, and a row with a
|
||||
/// settable property would be a second copy of a fact the cache already holds.
|
||||
/// </remarks>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
||||
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
|
||||
/// <param name="IsShown">Whether its items are currently drawn.</param>
|
||||
internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPersonal, bool IsShown)
|
||||
{
|
||||
/// <summary>What the switch says.</summary>
|
||||
/// <remarks>
|
||||
/// A team vault is marked as one, exactly as it is in the "file this into" picker, and for a weaker
|
||||
/// version of the same reason: two vaults may hold a host with the same label, and which vault a switch
|
||||
/// is about is the only thing that tells the two switches apart.
|
||||
/// </remarks>
|
||||
internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
|
||||
|
||||
/// <summary>Whether this vault can be switched off.</summary>
|
||||
/// <remarks>
|
||||
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
|
||||
/// the one the group and tag editors write to, and the fallback the save-target picker lands on — so
|
||||
/// switching it off would empty half the application rather than filter it. It is still drawn, ticked,
|
||||
/// because a vault missing from a list of vaults reads as something having gone wrong.
|
||||
/// </remarks>
|
||||
internal bool CanHide => !IsPersonal;
|
||||
}
|
||||
|
||||
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
|
||||
internal enum ShellState
|
||||
{
|
||||
@@ -348,7 +378,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// session after unlock, and both go away again on lock. Capturing either would give this screen a
|
||||
// reference that outlives what it points at — which for a session means holding vault keys past the
|
||||
// moment locking is supposed to have zeroed them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
|
||||
// The third argument is how a vault made over there reaches the lists and the menu over here: both
|
||||
// are built from the session's vault list, and neither would otherwise learn that it had grown until
|
||||
// something else happened to rebuild them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session, OnVaultsChangedAsync);
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
@@ -1026,7 +1059,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||
/// the machine is still there and the keychain no longer knows about it.
|
||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
|
||||
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
|
||||
/// out — arriving at the hosts screen with nothing selected and no explanation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
@@ -1038,7 +1074,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
if (row.HostId is { } hostId
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known)
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||
&& vault.IsVaultShown(known.VaultId))
|
||||
{
|
||||
vault.SelectedHost = known;
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
@@ -1101,6 +1138,148 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void ShowVaults() => ShowScreen(vaultsScreen);
|
||||
|
||||
// ---- Which vaults this window is showing ----
|
||||
|
||||
/// <summary>
|
||||
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
|
||||
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
|
||||
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
|
||||
/// than the keyring can.
|
||||
/// </remarks>
|
||||
private VaultVisibility? visibility;
|
||||
|
||||
/// <summary>
|
||||
/// One switch per readable vault, for the menu on the Vaults tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
|
||||
/// per window and per machine, and what they change is what is drawn — see <see cref="VaultVisibility"/>
|
||||
/// for the things they deliberately do not change.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultToggleViewModel> VaultToggles { get; } = [];
|
||||
|
||||
/// <summary>Whether the menu has anything to offer.</summary>
|
||||
/// <remarks>
|
||||
/// One vault is the ordinary case — somebody who has never joined a team — and a menu holding a single
|
||||
/// switch that cannot be moved is a menu that answers nothing. The New vault entry is still worth
|
||||
/// having, so this hides the list rather than the flyout.
|
||||
/// </remarks>
|
||||
internal bool HasVaultSwitches => VaultToggles.Count > 1;
|
||||
|
||||
/// <summary>Refills the switches from the vaults this session can read.</summary>
|
||||
/// <remarks>
|
||||
/// The readable ones, not every known one: a vault whose grant awaits re-wrap has nothing that would
|
||||
/// decrypt, so a switch for it would do nothing and say so to nobody. Personal first, then by name,
|
||||
/// which is the order every other vault list in the application uses.
|
||||
/// </remarks>
|
||||
private void RebuildVaultToggles()
|
||||
{
|
||||
VaultToggles.Clear();
|
||||
|
||||
if (Vault is { } open && visibility is { } preferences)
|
||||
{
|
||||
foreach (var readable in open.Session.ReadableVaults
|
||||
.OrderByDescending(row => row.IsPersonal)
|
||||
.ThenBy(row => row.Name, StringComparer.CurrentCulture))
|
||||
{
|
||||
VaultToggles.Add(new VaultToggleViewModel(
|
||||
readable.VaultId,
|
||||
readable.Name,
|
||||
readable.IsPersonal,
|
||||
preferences.IsShown(readable.VaultId)));
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasVaultSwitches));
|
||||
}
|
||||
|
||||
/// <summary>Shows or stops showing one vault's items.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The personal vault is drawn in the menu, ticked, and cannot be switched off — see
|
||||
/// <see cref="VaultToggleViewModel.CanHide"/>. Leaving it out of the list would read as a bug, and
|
||||
/// letting it be switched off would empty the snippet, log and bucket screens at once, since all three
|
||||
/// are read from the active vault alone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refuses to switch off the last one that is showing. In practice the rule above already makes that
|
||||
/// unreachable; it is here for the session whose personal grant is unreadable, where the alternative is
|
||||
/// an application that looks broken and gives no clue which menu broke it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ToggleVaultAsync(VaultToggleViewModel? row)
|
||||
{
|
||||
if (row is null || Vault is not { } open || visibility is not { } preferences)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!row.CanHide)
|
||||
{
|
||||
StatusMessage =
|
||||
"Your personal vault is always shown. Everything filed nowhere else lives in it.";
|
||||
return;
|
||||
}
|
||||
|
||||
var hiding = row.IsShown;
|
||||
|
||||
if (hiding && VaultToggles.Count(toggle => toggle.IsShown) <= 1)
|
||||
{
|
||||
StatusMessage = "At least one vault has to be showing.";
|
||||
return;
|
||||
}
|
||||
|
||||
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it
|
||||
// second means the menu and the screen behind it never disagree, even for a frame.
|
||||
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
RebuildVaultToggles();
|
||||
|
||||
StatusMessage = hiding
|
||||
? $"'{row.Name}' is no longer shown. It still syncs, and hosts that authenticate with its keys "
|
||||
+ "still connect."
|
||||
: $"'{row.Name}' is showing again.";
|
||||
}
|
||||
|
||||
/// <summary>Redraws everything built from the session's vault list.</summary>
|
||||
/// <remarks>
|
||||
/// Handed to the teams screen, which is where a vault gets made. The switches come from that list and
|
||||
/// so does every host, key and pin on the vault screens, so both are a vault out of date the moment one
|
||||
/// is created — and neither is on screen at that point, which is exactly why nothing would have noticed.
|
||||
/// </remarks>
|
||||
private async Task OnVaultsChangedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Vault is { } open)
|
||||
{
|
||||
await open.RefreshVaultsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
RebuildVaultToggles();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Goes to the teams screen with the new-vault form open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A vault gets a team, so the place to make one is the screen that shows teams — where the people, the
|
||||
/// roles and the key holders already are, which is the next thing anybody making a shared vault wants.
|
||||
/// The form asks for a name and nothing else; see <c>TeamsViewModel.CreateVaultAsync</c> for what is
|
||||
/// made behind it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ShowNewVault()
|
||||
{
|
||||
ShowScreen(ShellScreen.Team);
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
}
|
||||
|
||||
// ---- The phone's connect menu ----
|
||||
|
||||
/// <summary>
|
||||
@@ -1735,6 +1914,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
|
||||
// to "everything shown", because a vault somebody set aside last week should still be set aside.
|
||||
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Vault = new VaultViewModel(
|
||||
session,
|
||||
workspace,
|
||||
@@ -1742,7 +1925,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
() => connection,
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog);
|
||||
connectionLog,
|
||||
visibility);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -1758,6 +1942,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After the load, because the switches are built from the vaults the session admitted and the
|
||||
// keyring is filled during it — before, and a machine with a team vault would come up with one
|
||||
// switch until something else rebuilt them.
|
||||
RebuildVaultToggles();
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||
@@ -2037,6 +2226,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// With the session, because it was read out of that session's cache. Keeping it would be a set of
|
||||
// switches describing vaults nothing can open, offered on a lock screen.
|
||||
visibility = null;
|
||||
RebuildVaultToggles();
|
||||
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
// A confirmation armed on the preferences screen must not survive onto the unlock screen, where
|
||||
@@ -2155,6 +2349,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// With the session, as on lock — and here the cache it came from is about to be deleted
|
||||
// outright, so the switches would be describing vaults this machine no longer has a row for.
|
||||
visibility = null;
|
||||
RebuildVaultToggles();
|
||||
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
rememberedToken = null;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -196,10 +198,30 @@ internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsR
|
||||
/// would be a second copy of something the server is authoritative for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="vaultsChanged">
|
||||
/// Told when this screen has created a vault, or null where nobody is listening.
|
||||
/// <para>
|
||||
/// A delegate rather than an event, and optional, for the reason the two dependencies above are functions:
|
||||
/// this screen is built once and outlives every lock, so a subscription would be one more thing to detach
|
||||
/// at exactly the right moment. The one listener is the shell, which has a tab-strip menu and a set of host
|
||||
/// lists that are both a vault out of date the instant this screen makes one.
|
||||
/// </para>
|
||||
/// </param>
|
||||
internal sealed partial class TeamsViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
Func<VaultSession?> session) : ObservableObject
|
||||
Func<VaultSession?> session,
|
||||
Func<CancellationToken, Task>? vaultsChanged = null) : ObservableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a slug may be, mirroring the server's own cap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrored rather than shared because it belongs to <c>TeamService.RequireSlug</c>, which is server
|
||||
/// code this assembly does not reference. Being wrong here costs a refusal the user cannot act on, so
|
||||
/// it is a constant with a comment rather than a number in the middle of a method.
|
||||
/// </remarks>
|
||||
private const int MaximumSlugLength = 128;
|
||||
|
||||
/// <summary>Teams this account belongs to.</summary>
|
||||
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
||||
|
||||
@@ -249,6 +271,36 @@ internal sealed partial class TeamsViewModel(
|
||||
[ObservableProperty]
|
||||
private string newTeamSlug = string.Empty;
|
||||
|
||||
// ---- Creating a vault ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCreatingVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newVaultName = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The team the vault being named will belong to, or null for one made along with it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Captured when the form is armed rather than read from <see cref="SelectedTeam"/> when CREATE is
|
||||
/// pressed, for the reason <see cref="TeamActionRequest"/> carries its own ids: a click in the team
|
||||
/// list between the two would otherwise redirect a vault into a team the user was not looking at when
|
||||
/// they typed its name.
|
||||
/// </remarks>
|
||||
private Guid? newVaultTeamId;
|
||||
|
||||
/// <summary>
|
||||
/// The team a half-finished create already made, held so the retry does not make a second one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creating a vault of its own is two calls, and the first can succeed while the second fails. The id
|
||||
/// is generated once and kept here, which is the whole of the idempotency story: <c>TeamService</c>
|
||||
/// treats an identical repeat of a create it has already accepted as the same team rather than a new
|
||||
/// one, so pressing CREATE again resends the first call harmlessly and then retries the second.
|
||||
/// </remarks>
|
||||
private Guid? pendingVaultTeamId;
|
||||
|
||||
// ---- Renaming a team ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -327,6 +379,17 @@ internal sealed partial class TeamsViewModel(
|
||||
/// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
|
||||
internal bool HasInvitations => Invitations.Count > 0;
|
||||
|
||||
/// <summary>Where the vault being named will end up, in one line under the box.</summary>
|
||||
/// <remarks>
|
||||
/// Worth a sentence because the form is in the left column and one of the two ways to open it is a
|
||||
/// button in the right pane — so "which team is this going into" is a question the user can reasonably
|
||||
/// have, and the answer was fixed when they pressed the button rather than by whatever is selected now.
|
||||
/// </remarks>
|
||||
internal string NewVaultDestination => newVaultTeamId is { } teamId
|
||||
&& Teams.FirstOrDefault(row => row.TeamId == teamId) is { } team
|
||||
? $"in the team '{team.Name}'"
|
||||
: "in a new team of its own, which you will own. Invite people to it once it is made.";
|
||||
|
||||
internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;
|
||||
|
||||
internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member;
|
||||
@@ -357,7 +420,16 @@ internal sealed partial class TeamsViewModel(
|
||||
/// called the gated version would find the gate held by itself and skip the reload silently — leaving
|
||||
/// a team that was created moments ago missing from the list it was just added to.
|
||||
/// </remarks>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
/// <param name="select">
|
||||
/// The team to land on, or null to keep the one already selected.
|
||||
/// <para>
|
||||
/// Here rather than assigned after the call, because the assignment fires
|
||||
/// <see cref="OnSelectedTeamChanged"/> and that starts a read nothing can await — so a caller wanting
|
||||
/// the new team's vaults on screen would be racing a fire-and-forget. Passed in, the reselect happens
|
||||
/// under the same guard as every other one and the detail read below is the awaited one.
|
||||
/// </para>
|
||||
/// </param>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken, Guid? select = null)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
@@ -370,7 +442,7 @@ internal sealed partial class TeamsViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedId = SelectedTeam?.TeamId;
|
||||
var selectedId = select ?? SelectedTeam?.TeamId;
|
||||
|
||||
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -457,9 +529,9 @@ internal sealed partial class TeamsViewModel(
|
||||
|
||||
IsCreatingTeam = false;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
|
||||
// Selected through the reload rather than assigned after it, so the new team's members and
|
||||
// vaults are on screen by the time this returns — see the select parameter.
|
||||
await ReloadAsync(cancellationToken, select: created.TeamId).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
||||
+ "whoever needs it.";
|
||||
@@ -872,28 +944,224 @@ internal sealed partial class TeamsViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by the selected team.</summary>
|
||||
/// <summary>Opens the name-a-vault form, aimed at the selected team.</summary>
|
||||
[RelayCommand]
|
||||
private void NewVault() => ArmNewVault(SelectedTeam?.TeamId);
|
||||
|
||||
/// <summary>Opens the name-a-vault form, aimed at a team that does not exist yet.</summary>
|
||||
/// <remarks>
|
||||
/// What the tab strip's vault menu reaches. From there a vault is the thing being made and a team is
|
||||
/// what carries it, which is the way round most people mean it: somebody who wants to share four
|
||||
/// servers with two colleagues is not asking to found an organisation first.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void NewVaultInItsOwnTeam() => ArmNewVault(null);
|
||||
|
||||
/// <summary>Abandons the name-a-vault form.</summary>
|
||||
/// <remarks>
|
||||
/// Clears the half-finished create with it. Cancelling is the one place somebody says they are done
|
||||
/// with this attempt, so a team left behind by a failed second call stops being something the next
|
||||
/// CREATE will add a vault to — it stays in the list, where they can archive it or use it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void CancelNewVault()
|
||||
{
|
||||
IsCreatingVault = false;
|
||||
pendingVaultTeamId = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a vault, and the team to own it where there is not one already.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A vault always belongs to a team, and this is what keeps that from being the user's problem.</b>
|
||||
/// Naming a vault is enough: the team is derived from the name, created with this account as its owner,
|
||||
/// and the vault goes into it. What that buys is the rest of this screen — members, roles, invitations
|
||||
/// and key holders all hang off the team, so they are all there the moment the vault is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two calls, and the first can succeed alone.</b> When it does, the team is kept rather than tidied
|
||||
/// away — see <see cref="pendingVaultTeamId"/> for how the retry avoids a second one. Archiving it here
|
||||
/// would be a client deleting something on the user's behalf because a later step failed, which is the
|
||||
/// kind of cleanup that eventually archives a team somebody has just been added to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedTeam is not { } team)
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Status = "Offline. Creating a vault needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
if (session() is not { } open)
|
||||
{
|
||||
var vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
|
||||
Status = "Unlock your keychain first: a vault's key is generated on this machine.";
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewVaultName.Trim();
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
Status = "A vault needs a name.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(() => AddVaultAsync(server, open, name, cancellationToken)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The two calls behind <see cref="CreateVaultAsync"/>, once its arguments are known good.</summary>
|
||||
private async Task AddVaultAsync(
|
||||
IVaultServer server,
|
||||
VaultSession open,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teamId = newVaultTeamId
|
||||
?? await EnsureTeamForVaultAsync(server, name, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
StoredVault vault;
|
||||
|
||||
try
|
||||
{
|
||||
vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, teamId, name, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (pendingVaultTeamId is not null
|
||||
&& exception is not OperationCanceledException)
|
||||
{
|
||||
// The whole state, not "creating the vault failed". The team is real, it is about to appear in
|
||||
// the list on the left, and pressing CREATE again finishes the job rather than making a second
|
||||
// one — none of which the user can work out from the failure alone.
|
||||
await ReloadAsync(cancellationToken, select: pendingVaultTeamId).ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = $"The team '{name}' was created, but its vault was not: {exception.Message} Press "
|
||||
+ "CREATE again to add the vault to it — the team is already in the list on the left.";
|
||||
return;
|
||||
}
|
||||
|
||||
Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
|
||||
+ "hosts and credentials can be filed into it from the Vault screen.";
|
||||
}).ConfigureAwait(true);
|
||||
IsCreatingVault = false;
|
||||
pendingVaultTeamId = null;
|
||||
NewVaultName = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken, select: teamId).ConfigureAwait(true);
|
||||
|
||||
// After the reload, so this screen is already right when the rest of the shell redraws against the
|
||||
// same session. Nothing here depends on it having happened.
|
||||
if (vaultsChanged is { } notify)
|
||||
{
|
||||
await notify(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
Status = $"Created the vault '{vault.Name}'. You are the only one who can open it until you share "
|
||||
+ "its key — add people below, then press SHARE KEY.";
|
||||
}
|
||||
|
||||
/// <summary>Makes the team a new vault will belong to, or returns the one a retry already made.</summary>
|
||||
/// <remarks>
|
||||
/// The slug is derived rather than asked for. It is a URL-safe handle the server needs and not a thing
|
||||
/// somebody naming a vault has an opinion about, so making them invent one would be a second field for
|
||||
/// a fact the first one already contains.
|
||||
/// </remarks>
|
||||
private async Task<Guid> EnsureTeamForVaultAsync(
|
||||
IVaultServer server,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teamId = pendingVaultTeamId ?? Guid.CreateVersion7();
|
||||
|
||||
// Before the call, not after: if this throws, the id has to survive so the retry resends the same
|
||||
// request rather than creating a second team.
|
||||
pendingVaultTeamId = teamId;
|
||||
|
||||
var slug = Slugify(name, teamId);
|
||||
|
||||
try
|
||||
{
|
||||
await server.Teams
|
||||
.CreateTeamAsync(new CreateTeamRequest(teamId, name, slug, null), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
when (string.Equals(exception.Code, ProblemCodes.TeamSlugTaken, StringComparison.Ordinal))
|
||||
{
|
||||
// Once, and not in a loop. A second collision on a suffixed slug means something other than
|
||||
// "somebody already has this name", and a client that kept trying would be hammering a server
|
||||
// that is refusing for a reason retrying cannot fix.
|
||||
await server.Teams
|
||||
.CreateTeamAsync(
|
||||
new CreateTeamRequest(teamId, name, Disambiguate(slug, teamId), null),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return teamId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a vault name into a slug the server will accept.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <c>TeamService.RequireSlug</c>: lowercase, anything outside a-z0-9 becomes a hyphen, runs of
|
||||
/// hyphens collapse, and the ends are trimmed. A name with nothing sluggable in it — one written
|
||||
/// entirely in a non-Latin script, or in emoji — leaves nothing behind, so it falls back to the team's
|
||||
/// own id rather than to a refusal the user cannot see the cause of in what they typed.
|
||||
/// </remarks>
|
||||
private static string Slugify(string name, Guid teamId)
|
||||
{
|
||||
var slug = new StringBuilder(name.Length);
|
||||
|
||||
foreach (var character in name.ToLowerInvariant())
|
||||
{
|
||||
if (character is >= 'a' and <= 'z' or >= '0' and <= '9')
|
||||
{
|
||||
slug.Append(character);
|
||||
}
|
||||
else if (slug.Length > 0 && slug[^1] != '-')
|
||||
{
|
||||
slug.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
var trimmed = slug.ToString().Trim('-');
|
||||
|
||||
if (trimmed.Length > MaximumSlugLength)
|
||||
{
|
||||
trimmed = trimmed[..MaximumSlugLength].TrimEnd('-');
|
||||
}
|
||||
|
||||
return trimmed.Length > 0 ? trimmed : Disambiguate("vault", teamId);
|
||||
}
|
||||
|
||||
/// <summary>Adds enough of the team's id to a slug to get past one somebody else has taken.</summary>
|
||||
private static string Disambiguate(string slug, Guid teamId)
|
||||
{
|
||||
var suffix = "-" + teamId.ToString("N", CultureInfo.InvariantCulture)[..8];
|
||||
var room = MaximumSlugLength - suffix.Length;
|
||||
|
||||
return (slug.Length > room ? slug[..room].TrimEnd('-') : slug) + suffix;
|
||||
}
|
||||
|
||||
/// <summary>Opens the name-a-vault form, aimed wherever the caller says.</summary>
|
||||
private void ArmNewVault(Guid? teamId)
|
||||
{
|
||||
// Opening the form is a fresh attempt, so a team left behind by a create that got half way is not
|
||||
// carried into it — the name box has just been emptied, and a retry that reused the team would put
|
||||
// a vault called one thing inside a team called another. Finishing the half-done one is pressing
|
||||
// CREATE again on the form that is still open, which is what its message says.
|
||||
pendingVaultTeamId = null;
|
||||
|
||||
newVaultTeamId = teamId;
|
||||
NewVaultName = string.Empty;
|
||||
IsCreatingVault = true;
|
||||
Status = string.Empty;
|
||||
|
||||
OnPropertyChanged(nameof(NewVaultDestination));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1110,6 +1378,10 @@ internal sealed partial class TeamsViewModel(
|
||||
OnPropertyChanged(nameof(OwnsSelected));
|
||||
OnPropertyChanged(nameof(HasInvitations));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
|
||||
// The hint under the name box reads a team out of the list this method is called after refilling,
|
||||
// so it is stale until something says otherwise — and it has no backing field to notify for it.
|
||||
OnPropertyChanged(nameof(NewVaultDestination));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -1005,6 +1005,14 @@ internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken c
|
||||
/// clipboard rather than one that failed to copy, and the difference is worth saying out loud.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="visibility">
|
||||
/// Which vaults this machine has been asked to leave off the screens, or null where nothing is hidden.
|
||||
/// <para>
|
||||
/// Read by <see cref="IsVaultShown"/> and by nothing else in here, which is the whole of how this stays a
|
||||
/// display filter — see that method. Null rather than a required argument because "no preference" is the
|
||||
/// state every caller that does not care about this is in, including a locked launch and every test.
|
||||
/// </para>
|
||||
/// </param>
|
||||
internal sealed partial class VaultViewModel(
|
||||
VaultSession session,
|
||||
TerminalWorkspace workspace,
|
||||
@@ -1012,7 +1020,8 @@ internal sealed partial class VaultViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
ServerReconnectHandler? reconnect = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
ConnectionRecorder? connectionLog = null) : ObservableObject, IAsyncDisposable
|
||||
ConnectionRecorder? connectionLog = null,
|
||||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <remarks>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -1089,6 +1098,45 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal VaultSession Session => session;
|
||||
|
||||
/// <summary>Whether a vault's items are drawn on the screens that list them.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The one place the visibility preference is read, and it is read only by the projections a person
|
||||
/// looks at</b> — <see cref="Matches"/>, <see cref="RebuildVaultItems"/>, the group and tag card counts,
|
||||
/// and the pin list. Every <c>Reload*Async</c> above stays complete, and that is not tidiness:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <see cref="Keys"/> and <see cref="Credentials"/> are what <see cref="TryBuildAuthentication"/>
|
||||
/// resolves a host's binding out of, and a host in one vault may legitimately name a key filed in
|
||||
/// another. Filtering the lists rather than the table would make hiding a vault break connections to
|
||||
/// hosts that are still on screen.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="groupsById"/> decides what port a host dials. Hiding a vault must never change that.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// The dialled-endpoint set in <see cref="ReloadKnownHostsAsync"/> decides which pins are described as
|
||||
/// unused, which is a hint that invites deleting trust.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Nothing outside those projections asks. Sync walks <c>session.ReadableVaults</c>, the keyring is
|
||||
/// filled from the same list, and the trust the SSH handshake consults is read straight out of
|
||||
/// <c>VaultKnownHostStore</c> — none of which has ever come through this type.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsVaultShown(Guid vaultId) => visibility?.IsShown(vaultId) ?? true;
|
||||
|
||||
/// <summary>Whether anything at all is being kept off the screens.</summary>
|
||||
/// <remarks>
|
||||
/// What lets an empty grid say why it is empty rather than implying the vault is. Computed from the
|
||||
/// vaults this session can read rather than from the hidden set, because a hidden vault whose grant has
|
||||
/// since been withdrawn is not a reason to tell somebody to go and unhide something.
|
||||
/// </remarks>
|
||||
internal bool HasHiddenVaults =>
|
||||
visibility is not null && session.ReadableVaults.Any(vault => visibility.IsHidden(vault.VaultId));
|
||||
|
||||
/// <summary>The hosts to show, unpushed local state included.</summary>
|
||||
/// <remarks>
|
||||
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
|
||||
@@ -1117,21 +1165,31 @@ internal sealed partial class VaultViewModel(
|
||||
/// What the hosts grid says when it has nothing in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
|
||||
/// matches what you typed" are three different situations and only the first is an invitation to add
|
||||
/// something. Telling somebody with thirty machines to add their first one is answering a question they
|
||||
/// did not ask.
|
||||
/// Four answers rather than one, because "there are no hosts", "you have set a vault aside", "this group
|
||||
/// is empty" and "nothing matches what you typed" are four different situations and only the first is an
|
||||
/// invitation to add something. Telling somebody with thirty machines to add their first one is
|
||||
/// answering a question they did not ask.
|
||||
/// <para>
|
||||
/// The hidden-vault answer comes before the group and the search box, because it is the one an empty
|
||||
/// grid cannot otherwise explain: a filter the user typed is still in front of them, and an open group
|
||||
/// is still lit on a card, but a vault switched off in a menu two screens ago leaves nothing on screen
|
||||
/// to read.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
|
||||
{
|
||||
(0, _, _) =>
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||
+ "~/.ssh/config from Preferences.",
|
||||
(_, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto this "
|
||||
+ "group's card — or choose the group in a host's own editor.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
internal string NoVisibleHostsMessage =>
|
||||
(Hosts.Count, HasHiddenVaults, GroupFilter, HostFilter.Trim().Length) switch
|
||||
{
|
||||
(0, _, _, _) =>
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this "
|
||||
+ "computer's ~/.ssh/config from Preferences.",
|
||||
(_, true, null, 0) =>
|
||||
"Every host here is in a vault you have switched off. Press the ⌄ beside Vaults in the tab "
|
||||
+ "strip to switch one back on.",
|
||||
(_, _, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto "
|
||||
+ "this group's card — or choose the group in a host's own editor.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
||||
@@ -2630,11 +2688,36 @@ internal sealed partial class VaultViewModel(
|
||||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Redraws every list from the vault, without saying anything about it.</summary>
|
||||
/// <remarks>
|
||||
/// For the two things that change which vaults exist or which are drawn without going through this type
|
||||
/// at all: a vault created on the Teams screen, and a switch in the tab strip's vault menu. Both leave
|
||||
/// the lists on screen describing the world as it was a moment ago, and neither has a sentence worth
|
||||
/// printing — which is exactly what the quiet reload is for. Also refreshes the empty-state sentence,
|
||||
/// which is computed and has no change notification of its own.
|
||||
/// </remarks>
|
||||
internal async Task RefreshVaultsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
OnPropertyChanged(nameof(HasHiddenVaults));
|
||||
OnPropertyChanged(nameof(NoVisibleHostsMessage));
|
||||
}
|
||||
|
||||
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
|
||||
/// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
|
||||
/// vault rather than to nothing, so the next Save still has somewhere to go.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately not filtered by <see cref="IsVaultShown"/>.</b> Hiding is a preference about reading,
|
||||
/// and a destination you cannot choose is a vault you cannot put anything in — so switching a team's
|
||||
/// vault off to get its forty hosts out of the way would quietly stop you filing anything into it, which
|
||||
/// nobody asked for. The same goes for the transfers screen's host picker, which reads
|
||||
/// <see cref="Hosts"/> for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RebuildTargetVaults()
|
||||
{
|
||||
@@ -2968,8 +3051,11 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
foreach (var tag in tagItems)
|
||||
{
|
||||
// Over the shown vaults, for the reason the group counts are — see RebuildGroups.
|
||||
Tags.Add(new TagRowViewModel(
|
||||
tag, Hosts.Count(row => row.Host.TagIds.Contains(tag.EntityId))));
|
||||
tag,
|
||||
Hosts.Count(row =>
|
||||
row.Host.TagIds.Contains(tag.EntityId) && IsVaultShown(row.VaultId))));
|
||||
}
|
||||
|
||||
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
@@ -3031,7 +3117,10 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
foreach (var group in groupItems)
|
||||
{
|
||||
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId);
|
||||
// Counted over the shown vaults rather than over every host, so a card cannot claim members the
|
||||
// grid beside it is not drawing. Not counted over VisibleHosts, which would be both too early —
|
||||
// that list is rebuilt after this — and wrong: a card must not lose members to the search box.
|
||||
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId && IsVaultShown(row.VaultId));
|
||||
|
||||
Groups.Add(new HostGroupRowViewModel(group, count));
|
||||
}
|
||||
@@ -3414,6 +3503,13 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row)
|
||||
{
|
||||
// First, and ahead of both the cards and the box, because it is not a search: a hidden vault's host
|
||||
// is out however the grid is narrowed, and a count taken after this reflects what is on screen.
|
||||
if (!IsVaultShown(row.VaultId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The group cards, and they narrow before the box does — a host outside the chosen group is out
|
||||
// whatever was typed. The two are deliberately not one control: the box is what you type when you
|
||||
// know the name, and the cards are what you press when you do not.
|
||||
@@ -7139,6 +7235,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins.
|
||||
/// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved
|
||||
/// them would put a pin nobody created between two things somebody did.
|
||||
/// <para>
|
||||
/// This is where a hidden vault's keys and passwords come off the keychain — the table rather than
|
||||
/// <see cref="Keys"/> and <see cref="Credentials"/> themselves, which stay whole for the reason
|
||||
/// <see cref="IsVaultShown"/> gives. Tags and buckets are read from the active vault alone, which
|
||||
/// cannot be hidden, so neither needs a test of its own.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RebuildVaultItems()
|
||||
{
|
||||
@@ -7148,7 +7250,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Keys)
|
||||
{
|
||||
foreach (var key in Keys)
|
||||
foreach (var key in Keys.Where(row => IsVaultShown(row.VaultId)))
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.Key,
|
||||
@@ -7163,7 +7265,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Credentials)
|
||||
{
|
||||
foreach (var credential in Credentials)
|
||||
foreach (var credential in Credentials.Where(row => IsVaultShown(row.VaultId)))
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.Credential,
|
||||
|
||||
@@ -137,6 +137,25 @@ internal sealed class CachedVaultRow
|
||||
|
||||
public bool RekeyRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this machine has been asked to leave this vault's items off the screens that show them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A reading preference and nothing more. It is read by the view models that build the lists a
|
||||
/// person looks at, and by nothing else — never by sync, never by the keyring, and never by the
|
||||
/// trust the SSH handshake consults. A vault hidden here still syncs, still opens, and still
|
||||
/// answers when a shown host authenticates with a key that lives in it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Here rather than in <c>ClientSettings</c> because it is keyed by vault id, and that file is
|
||||
/// plaintext in the profile directory with a stated rule about what may go in it. This one is also
|
||||
/// per machine — a vault you have set aside on a laptop is not one you have set aside on a phone —
|
||||
/// which is why it never reaches the server and is not part of <see cref="StoredVault"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Hidden { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DodoSSH.Client.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClientCacheContext))]
|
||||
[Migration("20260803150728_AddVaultHidden")]
|
||||
partial class AddVaultHidden
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||
.HasName("pk_item");
|
||||
|
||||
b.HasIndex("VaultId", "ChangeSequence")
|
||||
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType")
|
||||
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||
|
||||
b.ToTable("item", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<bool>("Hidden")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("hidden");
|
||||
|
||||
b.Property<bool>("IsPersonal")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_personal");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("permissions");
|
||||
|
||||
b.Property<bool>("RekeyRequired")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("rekey_required");
|
||||
|
||||
b.Property<Guid?>("TeamId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<byte[]>("WrappedVaultKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_vault_key");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_vault");
|
||||
|
||||
b.ToTable("vault", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Acknowledged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("acknowledged");
|
||||
|
||||
b.Property<byte[]>("Detail")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("detail");
|
||||
|
||||
b.Property<long>("DetectedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("detected_at_utc");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_conflict");
|
||||
|
||||
b.HasIndex("VaultId", "Acknowledged")
|
||||
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||
|
||||
b.ToTable("conflict", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<byte?>("AncestorAadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_aad_version");
|
||||
|
||||
b.Property<Guid?>("AncestorDataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ancestor_data_key_id");
|
||||
|
||||
b.Property<uint?>("AncestorKeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_key_generation");
|
||||
|
||||
b.Property<byte[]>("AncestorPayload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_payload");
|
||||
|
||||
b.Property<byte[]>("AncestorProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_protected_fields");
|
||||
|
||||
b.Property<int?>("AncestorVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_version");
|
||||
|
||||
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_wrapped_data_key");
|
||||
|
||||
b.Property<int>("Attempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("attempts");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int?>("ExpectedVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("expected_version");
|
||||
|
||||
b.Property<bool>("IsParked")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_parked");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<Guid>("OperationId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("operation_id");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("QueuedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("queued_at_utc");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_outbox");
|
||||
|
||||
b.HasIndex("OperationId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_operation_id");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||
|
||||
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||
|
||||
b.ToTable("outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_remembered_sign_in");
|
||||
|
||||
b.ToTable("remembered_sign_in", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<string>("Cursor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cursor");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<long?>("LastPulledAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pulled_at_utc");
|
||||
|
||||
b.Property<long?>("LastPushedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pushed_at_utc");
|
||||
|
||||
b.Property<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_sync_state");
|
||||
|
||||
b.ToTable("sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("DeviceId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("device_id");
|
||||
|
||||
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("device_wrapped_private_key");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("issuer");
|
||||
|
||||
b.Property<string>("KdfAlgorithm")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("kdf_algorithm");
|
||||
|
||||
b.Property<int>("KdfMemoryKibibytes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_memory_kibibytes");
|
||||
|
||||
b.Property<int>("KdfParallelism")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_parallelism");
|
||||
|
||||
b.Property<int>("KdfPasses")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_passes");
|
||||
|
||||
b.Property<byte[]>("KdfSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("kdf_salt");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<byte[]>("WrappedPrivateKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_private_key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_unlock_material");
|
||||
|
||||
b.ToTable("unlock_material", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVaultHidden : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "hidden",
|
||||
table: "vault",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "hidden",
|
||||
table: "vault");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,10 @@ namespace DodoSSH.Client.Storage.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<bool>("Hidden")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("hidden");
|
||||
|
||||
b.Property<bool>("IsPersonal")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_personal");
|
||||
|
||||
@@ -119,6 +119,57 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Reads the vaults this machine has been asked to leave off the screens.</summary>
|
||||
/// <remarks>
|
||||
/// The ids alone: a caller wanting the names already has <see cref="ListAsync"/>, and a list that
|
||||
/// carried them would invite somebody to build a vault list out of this one, which is the list that
|
||||
/// must never decide what syncs.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<Guid>> ListHiddenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
return await context.Set<CachedVaultRow>()
|
||||
.AsNoTracking()
|
||||
.Where(row => row.Hidden)
|
||||
.Select(row => row.VaultId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Records whether one vault's items are shown.</summary>
|
||||
/// <remarks>
|
||||
/// A vault with no row here is not an error and not worth reporting: a grant withdrawn between the
|
||||
/// click and this write leaves nothing to record a preference about, and the vault is already gone
|
||||
/// from every list the preference would have applied to.
|
||||
/// </remarks>
|
||||
public async Task SetHiddenAsync(Guid vaultId, bool hidden, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<CachedVaultRow>()
|
||||
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null || row.Hidden == hidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
row.Hidden = hidden;
|
||||
row.UpdatedAtUtc = clock.GetUtcNow();
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="CachedVaultRow.Hidden"/> is deliberately not among these. This method exists to write
|
||||
/// what the server said, and the server has never been told which vaults this machine is currently
|
||||
/// showing — so a refresh that touched the flag would be a refresh that silently un-hid every vault,
|
||||
/// once a minute. Leaving it out is what makes the preference survive <see cref="ReplaceAllAsync"/>.
|
||||
/// </remarks>
|
||||
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
|
||||
{
|
||||
row.Name = vault.Name;
|
||||
|
||||
@@ -1633,6 +1633,34 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with the rename form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The name-a-vault form is in the left column under the team list, and it is the taller of the two
|
||||
/// forms that can appear there — one field, but two sentences under it. Worth its own case because the
|
||||
/// column is 268 wide and both sentences wrap.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheTeamsScreen_FitsWithTheNewVaultFormOpen() =>
|
||||
OnTheTeamsScreenAsync(
|
||||
teams => teams.NewVaultInItsOwnTeamCommand.Execute(null),
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with the new-vault form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// Both forms at once, which is reachable: NEW at the top of the team list and New vault… in the tab
|
||||
/// strip's menu arm different forms and neither closes the other. Together they are the most the left
|
||||
/// column can be asked to hold.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheTeamsScreen_FitsWithBothCreateFormsOpen() =>
|
||||
OnTheTeamsScreenAsync(
|
||||
teams =>
|
||||
{
|
||||
teams.NewTeamCommand.Execute(null);
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
},
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with both create forms open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
|
||||
/// tallest thing that can appear above the members list, so it is the case most likely to push the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
@@ -249,10 +250,68 @@ public sealed class TerminalTabsTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The caret is the second half of the Vaults pill and the only control in this strip that opens a
|
||||
/// popup. Asserted as behaviour rather than as markup, because what makes it correct is the order in
|
||||
/// the handler rather than the flyout being attached — see the test below.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheCaretBesideVaults_OpensTheVaultMenu()
|
||||
{
|
||||
await OnTheStripAsync((strip, window) =>
|
||||
{
|
||||
var caret = CaretButton(strip);
|
||||
|
||||
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
|
||||
|
||||
Click(caret, window);
|
||||
|
||||
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opening the vault menu selects the Vaults tab first, so the renderer is collapsed under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The occlusion guard, and the reason this strip may have a flyout at all.</b> The comment on the
|
||||
/// <c>+</c> button refuses one because a popup dropping into the terminal's rectangle would have to
|
||||
/// composite above a native child window, which this project does not claim without a screenshot. The
|
||||
/// caret sidesteps the question rather than answering it: it goes to the Vaults tab before it opens,
|
||||
/// and a page surface is one where the renderer is not drawn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the assertion is on <see cref="MainWindowViewModel.IsTerminalShowing"/> rather than on anything
|
||||
/// about the popup. A change that opened the flyout without moving the surface first would still show a
|
||||
/// menu in every screenshot anybody took on a machine where it happened to work.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt()
|
||||
{
|
||||
await OnTheStripAsync((strip, window) =>
|
||||
{
|
||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue("this test is meaningless without one in the way");
|
||||
|
||||
Click(CaretButton(strip), window);
|
||||
|
||||
shell.IsVaultsTab.ShouldBeTrue();
|
||||
shell.IsTerminalShowing.ShouldBeFalse(
|
||||
"the flyout must never have to composite over the renderer's native child window");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
|
||||
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
|
||||
/// SFTP.
|
||||
/// <para>
|
||||
/// The Vaults caret is a sibling of its tab rather than a child, which is what keeps this assertion
|
||||
/// meaning what it says: a button inside a fixed tab would still be a close box.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheFixedTabsCarryNoCloseBox()
|
||||
@@ -411,6 +470,11 @@ public sealed class TerminalTabsTests : IAsyncLifetime
|
||||
.OfType<Button>()
|
||||
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
|
||||
|
||||
/// <summary>The half of the Vaults pill that opens the vault menu.</summary>
|
||||
/// <inheritdoc cref="TabButton" path="/remarks" />
|
||||
private static Button CaretButton(Visual strip) =>
|
||||
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("caret"));
|
||||
|
||||
/// <inheritdoc cref="TabButton" />
|
||||
private static Button PlusButton(Visual strip) =>
|
||||
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
|
||||
|
||||
@@ -63,6 +63,24 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
/// </remarks>
|
||||
internal bool CorruptKeyLog { get; set; }
|
||||
|
||||
/// <summary>Slugs this fake refuses, as the real server refuses one already in use.</summary>
|
||||
/// <remarks>
|
||||
/// A vault's slug is derived from its name rather than typed, so a collision is something the client
|
||||
/// has to get out of on its own — and a fake that accepted every slug could not tell whether it does.
|
||||
/// </remarks>
|
||||
internal HashSet<string> TakenSlugs { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>How many vault creates to refuse before answering normally.</summary>
|
||||
/// <remarks>
|
||||
/// Creating a vault of its own is two calls, and the failure worth testing is the one between them:
|
||||
/// the team is made and the vault is not. One refusal is enough to leave the client in that state and
|
||||
/// let the test press CREATE again.
|
||||
/// </remarks>
|
||||
internal int VaultCreateFailures { get; set; }
|
||||
|
||||
/// <summary>How many team creates have been asked for, for a test to assert on.</summary>
|
||||
internal int TeamCreates { get; private set; }
|
||||
|
||||
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
|
||||
/// <returns>Their user id.</returns>
|
||||
internal Guid AddAccount(string email, string displayName)
|
||||
@@ -119,6 +137,24 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TeamCreates++;
|
||||
|
||||
// Idempotent on the client-chosen id, as the real one is. That is the whole of how a create whose
|
||||
// second half failed is retried without leaving a second team behind, so a fake that made one
|
||||
// anyway would let the bug through.
|
||||
if (teams.Find(row => row.TeamId == request.TeamId) is { } existing)
|
||||
{
|
||||
return Task.FromResult(existing);
|
||||
}
|
||||
|
||||
if (TakenSlugs.Contains(request.Slug))
|
||||
{
|
||||
throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.Conflict,
|
||||
ProblemCodes.TeamSlugTaken,
|
||||
$"The slug '{request.Slug}' is already in use.");
|
||||
}
|
||||
|
||||
var team = new TeamSummary(
|
||||
request.TeamId,
|
||||
request.Name,
|
||||
@@ -433,6 +469,16 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (VaultCreateFailures > 0)
|
||||
{
|
||||
VaultCreateFailures--;
|
||||
|
||||
throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.ServiceUnavailable,
|
||||
code: null,
|
||||
"The server is not answering.");
|
||||
}
|
||||
|
||||
var vault = new VaultSummary(
|
||||
request.VaultId,
|
||||
request.Name,
|
||||
|
||||
@@ -112,7 +112,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
teams.Vaults.Count.ShouldBe(1, teams.Status);
|
||||
|
||||
teams.InviteEmail = "bob@example.com";
|
||||
@@ -156,7 +156,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
teams.InviteEmail = "mallory@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
@@ -186,7 +186,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
var vaultId = teams.Vaults[0].VaultId;
|
||||
var session = shell.Vault!.Session;
|
||||
@@ -213,7 +213,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var teamVaultId = teams.Vaults[0].VaultId;
|
||||
@@ -254,7 +254,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
var teamVaultId = teams.Vaults[0].VaultId;
|
||||
var vault = shell.Vault!;
|
||||
@@ -288,7 +288,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
teams.InviteEmail = "bob@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
@@ -414,7 +414,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
teams.ArchiveTeamCommand.Execute(null);
|
||||
await teams.ConfirmActionCommand.ExecuteAsync(null);
|
||||
@@ -634,6 +634,21 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Through the form rather than straight at the command, because the name is what the form is for: a
|
||||
/// vault used to be named after its team, which gave a team with three of them three vaults called the
|
||||
/// same thing.
|
||||
/// </remarks>
|
||||
private async Task CreateVaultAsync(TeamsViewModel teams, string name)
|
||||
{
|
||||
teams.NewVaultCommand.Execute(null);
|
||||
teams.NewVaultName = name;
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
|
||||
}
|
||||
|
||||
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
||||
{
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
using DodoSSH.Client.Session;
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
// see the csproj for why it is shared rather than reimplemented.
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Making a vault by naming it, and switching one off without switching it out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two features that meet in the same place. <b>Creating</b> a vault takes a name and nothing else — the
|
||||
/// team that owns it is derived and made behind it — so the half worth testing is the failure between the
|
||||
/// two calls, where the team exists and the vault does not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Hiding</b> one is a preference about what is drawn, and every test below that says "still" is
|
||||
/// guarding the line it must not cross. A hidden vault goes on syncing, its keys go on authenticating
|
||||
/// hosts that are still on screen, and it stays choosable as somewhere to file a new item. What changes is
|
||||
/// the lists a person reads, and nothing else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeVaultServer server = new();
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
private string directory = null!;
|
||||
private ClientPaths paths = null!;
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultKnownHostStore knownHosts = null!;
|
||||
private FakeDeviceKeyStore deviceKeys = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-visibility-{Guid.CreateVersion7():N}");
|
||||
paths = new ClientPaths(directory);
|
||||
|
||||
caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||
knownHosts = new VaultKnownHostStore();
|
||||
deviceKeys = new FakeDeviceKeyStore();
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(
|
||||
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
ssh,
|
||||
TimeProvider.System);
|
||||
|
||||
shell = NewShell();
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
caches.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A cache file the process has not finished releasing. The directory is under the temp path
|
||||
// and named per run, so leaving it costs a few kilobytes and never collides.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Making one ----
|
||||
|
||||
/// <remarks>
|
||||
/// The whole feature in one test. A name is all that is asked for, and what comes back is a vault this
|
||||
/// machine can already write to inside a team this account owns — which is what makes the rest of the
|
||||
/// screen, members and roles and key holders, apply to it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingAVaultByNameAlone_MakesATeamForItAndOwnsIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
var team = teams.Teams.ShouldHaveSingleItem();
|
||||
|
||||
team.Name.ShouldBe("Platform secrets");
|
||||
team.Slug.ShouldBe("platform-secrets", "the slug is derived rather than asked for");
|
||||
team.Role.ShouldBe("OWNER");
|
||||
|
||||
var vault = teams.Vaults.ShouldHaveSingleItem();
|
||||
|
||||
vault.Name.ShouldBe("Platform secrets");
|
||||
shell.Vault!.Session.ReadableVaults
|
||||
.Select(row => row.VaultId)
|
||||
.ShouldContain(vault.VaultId, "a vault made here is usable here, without a relock");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Where the flow lands, and it is the point of routing the tab strip's entry through this screen: the
|
||||
/// next thing anybody making a shared vault wants is the people, and the people are here.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheTeamsScreen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
|
||||
teams.SelectedTeam.Name.ShouldBe("Platform secrets");
|
||||
teams.SelectedVault.ShouldNotBeNull(teams.Status);
|
||||
teams.SelectedVault.Name.ShouldBe("Platform secrets");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The failure between the two calls. The team is real and stays — a client that archived it because a
|
||||
/// later step failed is a client that will one day archive a team somebody has just been added to — so
|
||||
/// the sentence has to carry the whole state rather than "creating the vault failed".
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AVaultCreateThatFailsAfterTheTeam_KeepsTheTeamAndSaysSo()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
server.VaultCreateFailures = 1;
|
||||
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
teams.NewVaultName = "Platform secrets";
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Teams.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
|
||||
teams.Vaults.ShouldBeEmpty();
|
||||
|
||||
teams.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
|
||||
teams.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
|
||||
|
||||
teams.Status.ShouldContain("was created, but its vault was not");
|
||||
teams.Status.ShouldContain("Press CREATE again");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The retry, and the reason the team id is generated once and held rather than per attempt. A second
|
||||
/// team would leave somebody with two identically named ones and no way to tell which is which.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RetryingAfterTheVaultCreateFailed_ReusesTheTeamRatherThanMakingASecond()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
server.VaultCreateFailures = 1;
|
||||
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
teams.NewVaultName = "Platform secrets";
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var teamId = teams.Teams.ShouldHaveSingleItem().TeamId;
|
||||
|
||||
// Pressed again on the form that is still open, which is exactly what the message tells the user
|
||||
// to do.
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Teams.ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
|
||||
teams.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
|
||||
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A slug is derived, so a collision is something the user cannot see coming and cannot fix by editing
|
||||
/// a field they were never shown. One retry with a disambiguated slug, and the name they typed is left
|
||||
/// alone — the name is theirs, the slug is a handle.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASlugAlreadyInUse_IsRetriedOnceWithADisambiguatedOne()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
server.TakenSlugs.Add("platform-secrets");
|
||||
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
var team = teams.Teams.ShouldHaveSingleItem();
|
||||
|
||||
team.Name.ShouldBe("Platform secrets", "the name is what the user typed");
|
||||
team.Slug.ShouldStartWith("platform-secrets-");
|
||||
team.Slug.ShouldNotBe("platform-secrets");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A name written in a script with no a-z or 0-9 in it leaves nothing to slugify. It still has to be a
|
||||
/// vault a person can make, so the fallback is the team's own id rather than a refusal pointing at a
|
||||
/// field that does not exist.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await CreateVaultAsync("διαχείριση");
|
||||
|
||||
var team = teams.Teams.ShouldHaveSingleItem();
|
||||
|
||||
team.Name.ShouldBe("διαχείριση");
|
||||
team.Slug.ShouldStartWith("vault-");
|
||||
team.Slug.Length.ShouldBeGreaterThan("vault-".Length);
|
||||
}
|
||||
|
||||
// ---- Switching one off ----
|
||||
|
||||
/// <remarks>
|
||||
/// What the switch is for. Somebody in four teams does not want four teams' machines in front of them
|
||||
/// all day, and this is the list that gets shorter.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingATeamVault_TakesItsHostsOffTheHostsScreen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.VisibleHosts.ShouldContain(row => row.VaultId == teamVaultId);
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
|
||||
vault.VisibleHosts.ShouldNotContain(row => row.VaultId == teamVaultId);
|
||||
vault.Hosts.ShouldContain(
|
||||
row => row.VaultId == teamVaultId,
|
||||
"the unfiltered list stays whole — everything that resolves a binding reads it");
|
||||
|
||||
vault.HasVisibleHosts.ShouldBeFalse("the personal vault has nothing in it in this test");
|
||||
vault.NoVisibleHostsMessage.ShouldContain("switched off");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The keychain is vault-scoped too, so the same switch has to reach it. The table is what is filtered
|
||||
/// rather than the typed lists behind it — see the test below for why that distinction is load-bearing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingATeamVault_TakesItsKeysOffTheKeychain()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teamVaultId = await VaultWithAKeyAsync("Platform secrets", "deploy");
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.VaultItems.ShouldContain(row => string.Equals(row.Name, "deploy", StringComparison.Ordinal));
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
|
||||
vault.VaultItems.ShouldNotContain(
|
||||
row => string.Equals(row.Name, "deploy", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <b>The regression this whole design is shaped around.</b> A host in one vault may authenticate with
|
||||
/// a key filed in another, and the only authentication resolution in the product reads the keychain's
|
||||
/// typed list. Filtering that list rather than the table would make switching a vault off break
|
||||
/// connections to hosts still on screen — a preference about reading turning into an outage.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingATeamVault_LeavesAHostThatBindsItsKeyStillConnectable()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teamVaultId = await VaultWithAKeyAsync("Platform secrets", "deploy");
|
||||
var vault = shell.Vault!;
|
||||
|
||||
var key = vault.Keys.Single(row => string.Equals(row.Label, "deploy", StringComparison.Ordinal));
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
|
||||
vault.Keys.ShouldContain(
|
||||
row => row.EntityId == key.EntityId,
|
||||
"a hidden vault's keys still have to resolve for the hosts that name them");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorAuthenticationChoices.ShouldContain(
|
||||
choice => choice.EntityId == key.EntityId,
|
||||
"and still have to be offerable, or the binding could never be repaired");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Hiding is about reading. A destination you cannot choose is a vault you cannot put anything in, so
|
||||
/// switching a team's forty hosts out of the way must not quietly stop you filing into it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingATeamVault_LeavesItInTheSaveTargetPicker()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
|
||||
shell.Vault!.TargetVaults.Select(choice => choice.VaultId).ShouldContain(teamVaultId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The line the feature must not cross. A vault that stopped syncing because somebody tidied it off
|
||||
/// their screen would be found out weeks later, by a host that was never there.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingATeamVault_DoesNotStopItSyncing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
|
||||
vault.Session.ReadableVaults.Select(row => row.VaultId).ShouldContain(teamVaultId);
|
||||
|
||||
// And it still accepts writes and still pushes them, which is the part a user would notice.
|
||||
var before = server.LiveRowCount;
|
||||
|
||||
await AddHostAsync(vault, teamVaultId, "prod-cache", "cache.internal");
|
||||
|
||||
server.LiveRowCount.ShouldBe(before + 1, vault.Status);
|
||||
vault.PendingChanges.ShouldBe(0, "saving pushes, hidden or not");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The pin list describes a pin nothing dials as unused, which is a hint that invites withdrawing
|
||||
/// trust. That answer is taken over every host rather than the shown ones, so switching a vault off
|
||||
/// cannot turn a pin somebody relies on into one they are being nudged to delete.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingATeamVault_StillCountsItsHostsWhenDecidingWhichPinsNothingDials()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
|
||||
var vault = shell.Vault!;
|
||||
|
||||
// Trusted into the personal vault, which is where the handshake writes; the host it is for lives in
|
||||
// the team's. That crossing is exactly the case the count has to survive.
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost
|
||||
.ShouldBeTrue("hiding a vault must not make a pin look abandoned");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// It is a preference, so it belongs to the machine rather than to the session. Somebody who set a
|
||||
/// vault aside yesterday has not asked to be shown it again this morning.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingAVault_SurvivesLockingAndUnlocking()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.VaultToggles.ShouldBeEmpty("the switches belong to the session that was open");
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
|
||||
shell.VaultToggles.Single(toggle => toggle.VaultId == teamVaultId).IsShown.ShouldBeFalse();
|
||||
shell.Vault!.VisibleHosts.ShouldNotContain(row => row.VaultId == teamVaultId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// It is drawn in the menu and ticked, because a vault missing from a list of vaults reads as something
|
||||
/// having gone wrong — and it cannot be switched off, because snippets, logs, buckets and the editable
|
||||
/// group and tag lists are all read from it alone. Switching it off would empty half the application
|
||||
/// rather than filter it, so the refusal says why instead of doing nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePersonalVaultIsListedAndCannotBeHidden()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
var personal = shell.VaultToggles.Single(toggle => toggle.IsPersonal);
|
||||
|
||||
personal.IsShown.ShouldBeTrue();
|
||||
personal.CanHide.ShouldBeFalse();
|
||||
|
||||
await shell.ToggleVaultCommand.ExecuteAsync(personal);
|
||||
|
||||
shell.VaultToggles.Single(toggle => toggle.IsPersonal).IsShown.ShouldBeTrue();
|
||||
shell.StatusMessage.ShouldContain("always shown");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The switches are the readable vaults, personal first. A vault whose grant awaits re-wrap has nothing
|
||||
/// that would decrypt, so a switch for it would do nothing at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultMenu_ListsEveryReadableVaultWithThePersonalOneFirst()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.HasVaultSwitches.ShouldBeFalse("one vault is a menu with nothing to choose between");
|
||||
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
shell.HasVaultSwitches.ShouldBeTrue();
|
||||
shell.VaultToggles.Count.ShouldBe(2);
|
||||
shell.VaultToggles[0].IsPersonal.ShouldBeTrue();
|
||||
shell.VaultToggles[1].Display.ShouldBe("Platform secrets · TEAM");
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private MainWindowViewModel NewShell() =>
|
||||
new(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
deviceKeys,
|
||||
(_, _) => Task.FromResult<IVaultServer>(server),
|
||||
TimeProvider.System,
|
||||
NSubstitute.Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
/// <summary>Names a vault, from the form the tab strip's menu opens.</summary>
|
||||
private async Task<Guid> CreateVaultAsync(string name)
|
||||
{
|
||||
var teams = shell.Teams;
|
||||
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
teams.NewVaultName = name;
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
|
||||
|
||||
return teams.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
|
||||
.VaultId;
|
||||
}
|
||||
|
||||
private async Task<Guid> VaultWithAHostAsync(string vaultName, string hostLabel)
|
||||
{
|
||||
await shell.Teams.LoadAsync(Token);
|
||||
|
||||
var vaultId = await CreateVaultAsync(vaultName);
|
||||
|
||||
await AddHostAsync(shell.Vault!, vaultId, hostLabel, "db.internal");
|
||||
|
||||
return vaultId;
|
||||
}
|
||||
|
||||
private async Task<Guid> VaultWithAKeyAsync(string vaultName, string keyLabel)
|
||||
{
|
||||
await shell.Teams.LoadAsync(Token);
|
||||
|
||||
var vaultId = await CreateVaultAsync(vaultName);
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == vaultId);
|
||||
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
vault.KeyEditorLabel = keyLabel;
|
||||
vault.KeyEditorPrivateKey =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nMATERIAL\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
await vault.SaveKeyCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditingKey.ShouldBeFalse(vault.Status);
|
||||
|
||||
return vaultId;
|
||||
}
|
||||
|
||||
private async Task AddHostAsync(
|
||||
VaultViewModel vault,
|
||||
Guid vaultId,
|
||||
string label,
|
||||
string hostname)
|
||||
{
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == vaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = hostname;
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
}
|
||||
|
||||
/// <summary>Switches a vault off through the menu, as the tab strip does.</summary>
|
||||
private async Task HideAsync(Guid vaultId)
|
||||
{
|
||||
var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);
|
||||
|
||||
await shell.ToggleVaultCommand.ExecuteAsync(toggle);
|
||||
|
||||
shell.VaultToggles.Single(row => row.VaultId == vaultId).IsShown
|
||||
.ShouldBeFalse(shell.StatusMessage);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="TeamSharingTests.UnlockedAsync" />
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await shell.StartAsync(Token);
|
||||
await shell.SignInCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
shell.ConfirmPassphrase = Passphrase;
|
||||
await shell.EnrollCommand.ExecuteAsync(null);
|
||||
|
||||
shell.RecoveryCodeWrittenDown = true;
|
||||
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,61 @@ public sealed class CacheStoreTests : IAsyncLifetime
|
||||
read!.WrappedVaultKey.ShouldBe(wrapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HidingAVault_SurvivesTheNextVaultListFromTheServer()
|
||||
{
|
||||
// The whole reason the flag is a column here rather than a field on StoredVault. /me is fetched
|
||||
// once a minute; a refresh that carried this preference along would un-hide every vault within
|
||||
// the minute, and the user would never work out what kept switching them back on.
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
|
||||
await harness.Vaults.SetHiddenAsync(vaultId, hidden: true, Token);
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops renamed", 2)], Token);
|
||||
|
||||
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBe([vaultId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheServersVaultList_NeverHidesAVaultByItself()
|
||||
{
|
||||
// The other direction, and worth its own test: the server knows nothing about which vaults this
|
||||
// machine is showing, so no answer it gives may switch one off.
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
|
||||
|
||||
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HidingAVaultThatIsNoLongerCached_DoesNothing()
|
||||
{
|
||||
// A grant withdrawn between the click and the write. There is nothing left to record a reading
|
||||
// preference about, and the vault is already off every list the preference would have applied to.
|
||||
await Should.NotThrowAsync(
|
||||
() => harness.Vaults.SetHiddenAsync(Guid.CreateVersion7(), hidden: true, Token));
|
||||
|
||||
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AVaultThatLosesItsGrantAndGetsItBack_ComesBackShowing()
|
||||
{
|
||||
// Absence from the server's list means access was lost, and the row goes with it. Being re-granted
|
||||
// is a new vault as far as this machine is concerned, and a new vault is shown.
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
|
||||
await harness.Vaults.SetHiddenAsync(vaultId, hidden: true, Token);
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync([], Token);
|
||||
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
|
||||
|
||||
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- Unlock material ----
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user