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