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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user