Merge branch 'claude/vault-creation-sharing-62c0b6'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s

This commit is contained in:
2026-08-03 21:53:09 +02:00
21 changed files with 2340 additions and 80 deletions
@@ -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,