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:
@@ -0,0 +1,91 @@
|
||||
using DodoSSH.Client.Storage;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Which vaults this machine has been asked to leave off the screens that list their contents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A reading preference, and only that.</b> Somebody in four teams does not want four teams' hosts in
|
||||
/// front of them all day, and the answer is a switch per vault rather than four sign-ins. What this must
|
||||
/// never become is an access control: a hidden vault still syncs, its key stays in the keyring, and a
|
||||
/// shown host that authenticates with a key filed in it still connects. Hiding changes what is drawn and
|
||||
/// nothing else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately unknown to <see cref="VaultSession"/>.</b> Nothing in that type takes one of these, and
|
||||
/// nothing in it should: <see cref="VaultSession.ReadableVaults"/> is what the sync loop walks and what the
|
||||
/// keyring is filled from, so a filter reaching it would be a preference that quietly stopped a team's
|
||||
/// vault from syncing — and the user would find out weeks later, from a host that was never there. The
|
||||
/// dependency runs one way, from here to the session's store, and this file is the only place the two meet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per machine, which is why it lives in the local cache rather than in the vault: the vault you set aside
|
||||
/// on a work laptop is not the one you set aside on a phone. It is in the <em>encrypted</em> cache rather
|
||||
/// than in <see cref="ClientSettings"/> because it is a list of vault ids, and that file is plaintext.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not thread-safe, and not meant to be. Every caller is a view model on the UI thread, which is the same
|
||||
/// reason the observable collections beside them are not either.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultVisibility
|
||||
{
|
||||
private readonly VaultStore store;
|
||||
private readonly HashSet<Guid> hidden;
|
||||
|
||||
private VaultVisibility(VaultStore store, IEnumerable<Guid> hidden)
|
||||
{
|
||||
this.store = store;
|
||||
this.hidden = [.. hidden];
|
||||
}
|
||||
|
||||
/// <summary>Reads this machine's preferences for an open session.</summary>
|
||||
/// <remarks>
|
||||
/// Read once, at unlock, rather than per list rebuild. The set is small and changes only when somebody
|
||||
/// presses a switch, and the lists that consult it are rebuilt on every background sync.
|
||||
/// </remarks>
|
||||
public static async Task<VaultVisibility> LoadAsync(
|
||||
VaultSession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
var vaults = session.Vault;
|
||||
|
||||
var stored = await vaults.ListHiddenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new VaultVisibility(vaults, stored);
|
||||
}
|
||||
|
||||
/// <summary>Whether this vault's items are kept off the screens.</summary>
|
||||
public bool IsHidden(Guid vaultId) => hidden.Contains(vaultId);
|
||||
|
||||
/// <summary>Whether this vault's items are drawn.</summary>
|
||||
/// <remarks>
|
||||
/// A vault nobody has said anything about is shown. That is what makes this feature cost nothing to
|
||||
/// ignore, and it is also what a fresh cache, a new grant and a re-granted vault all land on.
|
||||
/// </remarks>
|
||||
public bool IsShown(Guid vaultId) => !hidden.Contains(vaultId);
|
||||
|
||||
/// <summary>Records whether one vault's items are drawn.</summary>
|
||||
/// <remarks>
|
||||
/// The disk write happens first and the set is updated only if it returned. A preference that took
|
||||
/// effect on screen but never reached the cache would come back on the next launch, and a switch that
|
||||
/// silently forgets is worse than one that refuses.
|
||||
/// </remarks>
|
||||
public async Task SetHiddenAsync(Guid vaultId, bool value, CancellationToken cancellationToken)
|
||||
{
|
||||
await store.SetHiddenAsync(vaultId, value, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (value)
|
||||
{
|
||||
hidden.Add(vaultId);
|
||||
}
|
||||
else
|
||||
{
|
||||
hidden.Remove(vaultId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user