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:
2026-08-03 21:52:27 +02:00
parent 1f2607cc9a
commit 7b7fd7b2ef
21 changed files with 2340 additions and 80 deletions
@@ -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;