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