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