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
|
**Failure means:** the prompt is behind the screen that asked for it, and the connection is blocked on a
|
||||||
question that cannot be reached.
|
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
|
## Phase 2 — Known Hosts as its own page
|
||||||
|
|||||||
@@ -446,6 +446,36 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
</Style>
|
</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
|
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
|
.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
|
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.
|
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="Background" Value="{StaticResource Chrome}" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
|||||||
@@ -62,22 +62,53 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- The create form, in place rather than in a modal: this window has no idiom for one. -->
|
<!-- The create forms, 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"
|
<StackPanel Grid.Row="2">
|
||||||
IsVisible="{Binding IsCreatingTeam}">
|
|
||||||
<StackPanel Spacing="8">
|
<Border Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
IsVisible="{Binding IsCreatingTeam}">
|
||||||
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
<StackPanel Spacing="8">
|
||||||
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
<TextBlock Classes="label" Text="NEW TEAM" />
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
||||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
||||||
|
</StackPanel>
|
||||||
</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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -276,8 +307,13 @@
|
|||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
|
<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"
|
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
|
||||||
Command="{Binding CreateVaultCommand}"
|
Command="{Binding NewVaultCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
|
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -75,14 +75,101 @@
|
|||||||
"a page, and not one of these two", and the other two are the existing IsTransfersShowing and
|
"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.
|
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}"
|
Two buttons drawn as one pill: the tab, and a caret that opens the vault menu. Split rather than
|
||||||
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
|
one button with a menu, because the tab's job is to go somewhere and that must stay a single
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
click — a tab you cannot press without being asked a question is not a tab.
|
||||||
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Text="Vaults" VerticalAlignment="Center" />
|
── WHY A FLYOUT IS SAFE HERE, WHEN THE + BUTTON BELOW STILL REFUSES ONE ────────────────────────
|
||||||
</StackPanel>
|
That refusal stands and its reasoning is unchanged: this strip sits directly above the WebView's
|
||||||
</Button>
|
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}"
|
<Button Classes="flat tab fixed" Classes.active="{Binding IsTransfersShowing}"
|
||||||
Command="{Binding ShowFilesCommand}"
|
Command="{Binding ShowFilesCommand}"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
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.
|
// it. Its failures are the workspace's to report, not this strip's.
|
||||||
shell.CloseTabCommand.Execute(tab);
|
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]
|
[ObservableProperty]
|
||||||
private KnownHostRowViewModel? selected;
|
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;
|
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||||
|
|
||||||
@@ -153,14 +169,14 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
var total = vault.KnownHostPins.Count;
|
var total = Shown.Count();
|
||||||
|
|
||||||
if (total == 0)
|
if (total == 0)
|
||||||
{
|
{
|
||||||
return string.Empty;
|
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";
|
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
|
||||||
|
|
||||||
return unused == 0
|
return unused == 0
|
||||||
@@ -169,10 +185,21 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal string EmptyMessage => HasPins
|
/// <remarks>
|
||||||
? "No approved host key matches that."
|
/// The hidden-vault case is its own sentence rather than falling into "nothing approved yet", which
|
||||||
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
|
/// would be a screen telling somebody they have never approved a host key while the keys they approved
|
||||||
+ "check — approving it puts it here.";
|
/// 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>
|
/// <summary>Withdraws trust in the selected pin.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -212,7 +239,7 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
|||||||
|
|
||||||
VisiblePins.Clear();
|
VisiblePins.Clear();
|
||||||
|
|
||||||
foreach (var pin in vault.KnownHostPins.Where(Matches))
|
foreach (var pin in Shown.Where(Matches))
|
||||||
{
|
{
|
||||||
VisiblePins.Add(pin);
|
VisiblePins.Add(pin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,36 @@ using DodoSSH.Crypto;
|
|||||||
|
|
||||||
namespace DodoSSH.Client.Shell.ViewModels;
|
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>
|
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
|
||||||
internal enum ShellState
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1038,7 +1074,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (row.HostId is { } hostId
|
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;
|
vault.SelectedHost = known;
|
||||||
ShowScreen(ShellScreen.Hosts);
|
ShowScreen(ShellScreen.Hosts);
|
||||||
@@ -1101,6 +1138,148 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ShowVaults() => ShowScreen(vaultsScreen);
|
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 ----
|
// ---- The phone's connect menu ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1735,6 +1914,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
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(
|
Vault = new VaultViewModel(
|
||||||
session,
|
session,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -1742,7 +1925,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
() => connection,
|
() => connection,
|
||||||
ReconnectAsync,
|
ReconnectAsync,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
connectionLog);
|
connectionLog,
|
||||||
|
visibility);
|
||||||
State = ShellState.Unlocked;
|
State = ShellState.Unlocked;
|
||||||
|
|
||||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
// 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);
|
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
|
// 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.
|
// empty one would leave its picker blank until the next unlock.
|
||||||
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||||
@@ -2037,6 +2226,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
await open.DisposeAsync().ConfigureAwait(true);
|
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;
|
LiveSessionCount = workspace.LiveSessionCount;
|
||||||
|
|
||||||
// A confirmation armed on the preferences screen must not survive onto the unlock screen, where
|
// 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);
|
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?.Dispose();
|
||||||
connection = null;
|
connection = null;
|
||||||
rememberedToken = null;
|
rememberedToken = null;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using DodoSSH.Client.Api;
|
using DodoSSH.Client.Api;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Shell.ViewModels;
|
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.
|
/// would be a second copy of something the server is authoritative for.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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(
|
internal sealed partial class TeamsViewModel(
|
||||||
Func<IVaultServer?> connection,
|
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>
|
/// <summary>Teams this account belongs to.</summary>
|
||||||
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
||||||
|
|
||||||
@@ -249,6 +271,36 @@ internal sealed partial class TeamsViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string newTeamSlug = string.Empty;
|
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 ----
|
// ---- Renaming a team ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -327,6 +379,17 @@ internal sealed partial class TeamsViewModel(
|
|||||||
/// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
|
/// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
|
||||||
internal bool HasInvitations => Invitations.Count > 0;
|
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 AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;
|
||||||
|
|
||||||
internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member;
|
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
|
/// 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.
|
/// a team that was created moments ago missing from the list it was just added to.
|
||||||
/// </remarks>
|
/// </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)
|
if (connection() is not { } server)
|
||||||
{
|
{
|
||||||
@@ -370,7 +442,7 @@ internal sealed partial class TeamsViewModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedId = SelectedTeam?.TeamId;
|
var selectedId = select ?? SelectedTeam?.TeamId;
|
||||||
|
|
||||||
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -457,9 +529,9 @@ internal sealed partial class TeamsViewModel(
|
|||||||
|
|
||||||
IsCreatingTeam = false;
|
IsCreatingTeam = false;
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
// 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.
|
||||||
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
|
await ReloadAsync(cancellationToken, select: created.TeamId).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
||||||
+ "whoever needs it.";
|
+ "whoever needs it.";
|
||||||
@@ -872,28 +944,224 @@ internal sealed partial class TeamsViewModel(
|
|||||||
}).ConfigureAwait(true);
|
}).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]
|
[RelayCommand]
|
||||||
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (connection() is not { } server
|
if (connection() is not { } server)
|
||||||
|| session() is not { } open
|
|
||||||
|| SelectedTeam is not { } team)
|
|
||||||
{
|
{
|
||||||
|
Status = "Offline. Creating a vault needs a connection.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await RunAsync(async () =>
|
if (session() is not { } open)
|
||||||
{
|
{
|
||||||
var vault = await open
|
Status = "Unlock your keychain first: a vault's key is generated on this machine.";
|
||||||
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
|
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);
|
.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 "
|
IsCreatingVault = false;
|
||||||
+ "hosts and credentials can be filed into it from the Vault screen.";
|
pendingVaultTeamId = null;
|
||||||
}).ConfigureAwait(true);
|
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>
|
/// <summary>
|
||||||
@@ -1110,6 +1378,10 @@ internal sealed partial class TeamsViewModel(
|
|||||||
OnPropertyChanged(nameof(OwnsSelected));
|
OnPropertyChanged(nameof(OwnsSelected));
|
||||||
OnPropertyChanged(nameof(HasInvitations));
|
OnPropertyChanged(nameof(HasInvitations));
|
||||||
OnPropertyChanged(nameof(IsOnline));
|
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>
|
/// <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.
|
/// clipboard rather than one that failed to copy, and the difference is worth saying out loud.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </param>
|
/// </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(
|
internal sealed partial class VaultViewModel(
|
||||||
VaultSession session,
|
VaultSession session,
|
||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
@@ -1012,7 +1020,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
Func<IVaultServer?> connection,
|
Func<IVaultServer?> connection,
|
||||||
ServerReconnectHandler? reconnect = null,
|
ServerReconnectHandler? reconnect = null,
|
||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
ConnectionRecorder? connectionLog = null) : ObservableObject, IAsyncDisposable
|
ConnectionRecorder? connectionLog = null,
|
||||||
|
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
/// 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>
|
/// </remarks>
|
||||||
internal VaultSession Session => session;
|
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>
|
/// <summary>The hosts to show, unpushed local state included.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
|
/// 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.
|
/// What the hosts grid says when it has nothing in it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
|
/// Four answers rather than one, because "there are no hosts", "you have set a vault aside", "this group
|
||||||
/// matches what you typed" are three different situations and only the first is an invitation to add
|
/// is empty" and "nothing matches what you typed" are four different situations and only the first is an
|
||||||
/// something. Telling somebody with thirty machines to add their first one is answering a question they
|
/// invitation to add something. Telling somebody with thirty machines to add their first one is
|
||||||
/// did not ask.
|
/// 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>
|
/// </remarks>
|
||||||
internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
|
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 "
|
(0, _, _, _) =>
|
||||||
+ "~/.ssh/config from Preferences.",
|
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this "
|
||||||
(_, not null, 0) =>
|
+ "computer's ~/.ssh/config from Preferences.",
|
||||||
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto this "
|
(_, true, null, 0) =>
|
||||||
+ "group's card — or choose the group in a host's own editor.",
|
"Every host here is in a vault you have switched off. Press the ⌄ beside Vaults in the tab "
|
||||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
+ "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>
|
/// <summary>
|
||||||
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
/// 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);
|
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>
|
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private void RebuildTargetVaults()
|
private void RebuildTargetVaults()
|
||||||
{
|
{
|
||||||
@@ -2968,8 +3051,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
foreach (var tag in tagItems)
|
foreach (var tag in tagItems)
|
||||||
{
|
{
|
||||||
|
// Over the shown vaults, for the reason the group counts are — see RebuildGroups.
|
||||||
Tags.Add(new TagRowViewModel(
|
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);
|
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
|
||||||
@@ -3031,7 +3117,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
foreach (var group in groupItems)
|
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));
|
Groups.Add(new HostGroupRowViewModel(group, count));
|
||||||
}
|
}
|
||||||
@@ -3414,6 +3503,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool Matches(HostRowViewModel row)
|
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
|
// 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
|
// 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.
|
// 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.
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private void RebuildVaultItems()
|
private void RebuildVaultItems()
|
||||||
{
|
{
|
||||||
@@ -7148,7 +7250,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (Section is VaultSection.All or VaultSection.Keys)
|
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(
|
VaultItems.Add(new VaultItemRowViewModel(
|
||||||
VaultItemKind.Key,
|
VaultItemKind.Key,
|
||||||
@@ -7163,7 +7265,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (Section is VaultSection.All or VaultSection.Credentials)
|
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(
|
VaultItems.Add(new VaultItemRowViewModel(
|
||||||
VaultItemKind.Credential,
|
VaultItemKind.Credential,
|
||||||
|
|||||||
@@ -137,6 +137,25 @@ internal sealed class CachedVaultRow
|
|||||||
|
|
||||||
public bool RekeyRequired { get; set; }
|
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; }
|
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")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("vault_id");
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<bool>("Hidden")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("hidden");
|
||||||
|
|
||||||
b.Property<bool>("IsPersonal")
|
b.Property<bool>("IsPersonal")
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("is_personal");
|
.HasColumnName("is_personal");
|
||||||
|
|||||||
@@ -119,6 +119,57 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
|||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
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)
|
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
row.Name = vault.Name;
|
row.Name = vault.Name;
|
||||||
|
|||||||
@@ -1633,6 +1633,34 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
window => LayoutHarness.Unreachable(window)
|
window => LayoutHarness.Unreachable(window)
|
||||||
.ShouldBeEmpty("the teams screen with the rename form open"));
|
.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>
|
/// <remarks>
|
||||||
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
|
/// 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
|
/// 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;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Presenters;
|
using Avalonia.Controls.Presenters;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Headless;
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Media;
|
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>
|
/// <remarks>
|
||||||
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
|
/// 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
|
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
|
||||||
/// SFTP.
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheFixedTabsCarryNoCloseBox()
|
public async Task TheFixedTabsCarryNoCloseBox()
|
||||||
@@ -411,6 +470,11 @@ public sealed class TerminalTabsTests : IAsyncLifetime
|
|||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
|
.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" />
|
/// <inheritdoc cref="TabButton" />
|
||||||
private static Button PlusButton(Visual strip) =>
|
private static Button PlusButton(Visual strip) =>
|
||||||
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
|
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool CorruptKeyLog { get; set; }
|
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>
|
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
|
||||||
/// <returns>Their user id.</returns>
|
/// <returns>Their user id.</returns>
|
||||||
internal Guid AddAccount(string email, string displayName)
|
internal Guid AddAccount(string email, string displayName)
|
||||||
@@ -119,6 +137,24 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
CreateTeamRequest request,
|
CreateTeamRequest request,
|
||||||
CancellationToken cancellationToken)
|
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(
|
var team = new TeamSummary(
|
||||||
request.TeamId,
|
request.TeamId,
|
||||||
request.Name,
|
request.Name,
|
||||||
@@ -433,6 +469,16 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
CreateTeamVaultRequest request,
|
CreateTeamVaultRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (VaultCreateFailures > 0)
|
||||||
|
{
|
||||||
|
VaultCreateFailures--;
|
||||||
|
|
||||||
|
throw new DodoSshApiException(
|
||||||
|
System.Net.HttpStatusCode.ServiceUnavailable,
|
||||||
|
code: null,
|
||||||
|
"The server is not answering.");
|
||||||
|
}
|
||||||
|
|
||||||
var vault = new VaultSummary(
|
var vault = new VaultSummary(
|
||||||
request.VaultId,
|
request.VaultId,
|
||||||
request.Name,
|
request.Name,
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateTeamAsync(teams, "Platform", "platform");
|
||||||
|
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
await CreateVaultAsync(teams, "Platform secrets");
|
||||||
teams.Vaults.Count.ShouldBe(1, teams.Status);
|
teams.Vaults.Count.ShouldBe(1, teams.Status);
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
teams.InviteEmail = "bob@example.com";
|
||||||
@@ -156,7 +156,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateTeamAsync(teams, "Platform", "platform");
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
await CreateVaultAsync(teams, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "mallory@example.com";
|
teams.InviteEmail = "mallory@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||||
@@ -186,7 +186,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
var teams = shell.Teams;
|
var teams = shell.Teams;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateTeamAsync(teams, "Platform", "platform");
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
await CreateVaultAsync(teams, "Platform secrets");
|
||||||
|
|
||||||
var vaultId = teams.Vaults[0].VaultId;
|
var vaultId = teams.Vaults[0].VaultId;
|
||||||
var session = shell.Vault!.Session;
|
var session = shell.Vault!.Session;
|
||||||
@@ -213,7 +213,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
var teams = shell.Teams;
|
var teams = shell.Teams;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateTeamAsync(teams, "Platform", "platform");
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
await CreateVaultAsync(teams, "Platform secrets");
|
||||||
|
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
var teamVaultId = teams.Vaults[0].VaultId;
|
var teamVaultId = teams.Vaults[0].VaultId;
|
||||||
@@ -254,7 +254,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
var teams = shell.Teams;
|
var teams = shell.Teams;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateTeamAsync(teams, "Platform", "platform");
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
await CreateVaultAsync(teams, "Platform secrets");
|
||||||
|
|
||||||
var teamVaultId = teams.Vaults[0].VaultId;
|
var teamVaultId = teams.Vaults[0].VaultId;
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
@@ -288,7 +288,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateTeamAsync(teams, "Platform", "platform");
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
await CreateVaultAsync(teams, "Platform secrets");
|
||||||
|
|
||||||
teams.InviteEmail = "bob@example.com";
|
teams.InviteEmail = "bob@example.com";
|
||||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||||
@@ -414,7 +414,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
var teams = shell.Teams;
|
var teams = shell.Teams;
|
||||||
|
|
||||||
await CreateTeamAsync(teams, "Platform", "platform");
|
await CreateTeamAsync(teams, "Platform", "platform");
|
||||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
await CreateVaultAsync(teams, "Platform secrets");
|
||||||
|
|
||||||
teams.ArchiveTeamCommand.Execute(null);
|
teams.ArchiveTeamCommand.Execute(null);
|
||||||
await teams.ConfirmActionCommand.ExecuteAsync(null);
|
await teams.ConfirmActionCommand.ExecuteAsync(null);
|
||||||
@@ -634,6 +634,21 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
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)
|
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
||||||
{
|
{
|
||||||
await teams.LoadAsync(Token);
|
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);
|
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 ----
|
// ---- Unlock material ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user