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; /// One vault, as a row in the list. /// /// /// Built from what this machine holds — — with the membership facts /// laid over it from the server. That order is the right way round: whether a vault exists and whether /// this machine can open it are things the session knows and the server cannot, and how many people are /// in it is the reverse. A row assembled the other way would go blank the moment the network did. /// /// /// is for a personal vault and for any /// vault whose team has not been read yet, which is what distinguishes. /// /// /// The vault. /// What it is called. Plaintext, unlike everything in it. /// Whether this is the vault nobody else can ever be added to. /// The membership list behind it, for a shared vault. /// This account's own role in it. /// How many people are in it, including this account. /// /// How many other vaults the same people are in by virtue of the same membership list. /// /// Zero for everything this screen makes, because a vault made here gets a membership list of its own. /// It is not zero for a team that owned several vaults before this screen existed, or one an operator /// arranged that way — and in that case adding somebody here adds them to those vaults too, which is the /// one thing a vault-shaped screen could otherwise quietly hide. /// /// /// Whether this machine holds the key. /// Whether the server would accept a change to it. /// Whether a membership change has left it owing a rekey. internal sealed record VaultRowViewModel( Guid VaultId, string Name, bool IsPersonal, Guid? TeamId, TeamMemberRole Role, int MemberCount, int SharedWithOtherVaults, bool IsReadable, bool CanWrite, bool RekeyRequired) { /// Whether anybody else can be in this vault at all. internal bool IsShared => TeamId is not null; /// Whether the server has told us who is in it. internal bool MembershipIsKnown => Role != TeamMemberRole.Unspecified; /// The chip on the right of the row. /// /// A personal vault says PERSONAL rather than a role. It has no membership list, so OWNER would be /// naming a position in a group of one — and the fact worth carrying about it is precisely that it /// is the one vault sharing cannot reach. /// internal string RoleLabel => (IsPersonal, MembershipIsKnown) switch { (true, _) => "PERSONAL", (_, true) => Role.ToString().ToUpperInvariant(), _ => string.Empty, }; /// What the row says under its name. internal string Detail => (IsPersonal, MembershipIsKnown) switch { (true, _) => "only you — nothing in here is shared", (_, false) => "shared — connect to see who is in it", _ => string.Create(CultureInfo.CurrentCulture, $"{MemberCount} member(s)"), }; /// /// Whether this account may add people to it and rename it. /// /// /// True of the personal vault, where there is nobody to add and the rename is still the owner's to /// make. The server agrees: a personal vault answers to its owner with every permission there is. /// internal bool CanAdminister => IsPersonal || (IsShared && Role is TeamMemberRole.Admin or TeamMemberRole.Owner); /// Whether this account is the one that can hand the vault on. internal bool IsOwned => IsShared && Role == TeamMemberRole.Owner; /// What is true of this vault right now, where that is worth a line. /// /// Empty in the ordinary case rather than saying "fine". The two states here are both temporary and /// both mean somebody has to do something, so a row that carried a permanent reassurance beside them /// would train people to stop reading it. /// internal string State => (IsReadable, RekeyRequired) switch { (false, _) => "waiting for a key — ask somebody who holds one to share it", (true, true) => "a rekey is owed after a membership change", _ => string.Empty, }; internal bool HasState => State.Length > 0; /// The single letter the settings page's card draws on this vault's tile. internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?"; } /// One member of a vault, as a row in the members table. internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool IsSelf) { internal Guid UserId => Member.UserId; /// What to call them. The address, or the id when the account has neither. /// /// Falling through to the id rather than to "Unknown": an account with no display name and no email is /// rare and is exactly the row somebody needs to be able to identify in order to remove it. /// internal string Name => Member.DisplayName ?? Member.Email ?? Member.UserId.ToString(); internal string Email => Member.Email ?? "—"; internal string Role => Member.Role.ToString().ToUpperInvariant(); /// /// What the account can be given, in one phrase. /// /// /// Not a two-factor column: there is no second-factor concept anywhere in the server. What is /// true and worth a column is whether a vault key can be wrapped to them at all. /// internal string KeyState => Member.IsEnrolled ? "key published" : "no key yet — cannot be given this vault"; /// /// The day they were last here, or that they never have been. /// /// /// A date to the day, not a time and not a "3 hours ago". Two reasons, and they point the same /// way: the server writes this at most once an hour, so anything finer would be reading a /// precision into it that is not there — and a relative phrase would have to be recomputed against /// a clock, which this row does not have and which the pinned-host list already decided against by /// rendering its own dates the same way. /// internal string LastActive => Member.LastActiveAt is { } seen ? "last here " + seen.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture) : "never signed in"; internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner; /// /// The two letters the members panel draws on this row's avatar. /// /// /// The same rule HostRowViewModel.Monogram draws a card's monogram by — the first letters of the /// first two words in the name, or the first two characters where it is one word — except uppercase, /// which is how the design draws a person's initials rather than a host's. /// internal string Initials { get { var words = Name.Split( InitialsWordSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var letters = words.Length >= 2 ? string.Concat(words[0][0], words[1][0]) : Name.Length >= 2 ? Name[..2] : Name; return letters.ToUpperInvariant(); } } private static readonly char[] InitialsWordSeparators = [' ', '-', '_', '.']; /// Whether this member's role can be changed at all. /// /// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is /// only meaningful as half of a transfer. That is its own command. /// internal bool CanChangeRole => Member.Role != TeamMemberRole.Owner; } /// One vault key grant, as a row under the vault it opens. /// /// This is the "shared with" list the design drew as a row of avatars. It is drawn as names and a /// state instead, and it is a list rather than a count for a reason worth keeping: a grant is per /// vault, so a number on an item row would imply per-item sharing, which does not exist. /// internal sealed record VaultGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration) { internal Guid UserId => Grant.RecipientUserId; internal string Name => Grant.DisplayName ?? Grant.Email ?? Grant.RecipientUserId.ToString(); /// /// What this grant is worth, in one phrase. /// /// /// Staleness is decided by comparing generations rather than by reading /// alone, which is what VaultGrantsResponse.KeyGeneration /// exists for: a grant can be Active and still open nothing, because it was wrapped to a key the /// vault has since moved past. /// internal string State => Grant.State switch { VaultGrantState.Revoked => "withdrawn — blocks future reads only", VaultGrantState.AwaitingRewrap => "needs wrapping again — their key changed", _ when Grant.KeyGeneration < VaultGeneration => "stale — wrapped to an older key, opens nothing", _ => "holds a key", }; /// Whether this row still represents somebody who can read the vault. internal bool IsLive => Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration; } /// /// A destructive vault operation, armed and waiting to be confirmed. /// /// /// The armed-state idiom the keychain screen uses, and for the same reason: this window has no modal, so /// a confirmation is drawn in place of the buttons that armed it. The target ids are carried here rather /// than read from the selection at confirm time — otherwise selecting a different row between arming /// and confirming would apply the answer to something else. /// /// Which operation the answer applies to. /// The membership list the action is aimed at. /// The member it is aimed at, or empty where it is not aimed at one. /// The vault it is aimed at. /// What is being asked. /// What will actually happen, stated honestly. internal sealed record VaultActionRequest( VaultActionKind Kind, Guid TeamId, Guid MemberId, Guid VaultId, string Question, string Consequence); /// Which destructive operation a confirmation is standing in front of. /// /// Carried on the request rather than inferred from which fields are set. There were two of these the /// moment deletion existed, and a confirm handler that guessed from a member id being empty would be one /// refactor away from carrying out the wrong one of the two. /// internal enum VaultActionKind { /// Not a legal value. Unspecified = 0, /// Hand the vault's membership list to somebody else. HandOver = 1, /// Delete the vault. Delete = 2, } /// /// The vaults screen: which vaults there are, who is in each, and who holds a key to it. /// /// /// /// This was the teams screen, and the team is now behind the vault rather than above it. A team is /// still what the server authorises against — VaultAccessService resolves a team vault through /// team_membership, and every membership call here names a team id. What changed is that nobody is /// asked to make one: naming a vault makes the membership list to carry it, so the thing people came to /// share is the thing they create, and "which team is this in" stops being a question they have to have an /// answer to before they can share four servers with two colleagues. /// /// /// Two separate acts, and the screen is still built around saying so. Adding somebody to a vault is /// a server-side authorization change and takes effect immediately. Giving them the vault key is a /// cryptographic act only a machine with that key can perform, and until somebody does it their vault list /// shows an entry they cannot open. Every product that hides this ends up implying the server can hand out /// access on its own — which, here, it cannot. See ADR 0009. /// /// /// The vault list is read from the session and works with no network. Everything under it — members, /// key holders — is read from the server on selection and after each change, because /// membership is not vault content and has no local mirror. /// /// /// The server, or null where there is none. /// The unlocked session, or null while the keychain is locked. /// /// Told when this screen has made or renamed a vault, or null where nobody is listening. /// /// 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. /// internal sealed partial class VaultsViewModel( Func connection, Func session, Func? vaultsChanged = null) : ObservableObject { /// /// How long a slug may be, mirroring the server's own cap. /// /// /// Mirrored rather than shared because it belongs to TeamService.RequireSlug, 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. /// private const int MaximumSlugLength = 128; /// Every vault this account can see, readable or not. internal ObservableCollection Vaults { get; } = []; /// The people in the selected vault. internal ObservableCollection Members { get; } = []; /// Who holds a key to the selected vault. /// /// Read from the server rather than from the session, and it is the one list on this screen that /// has to be: the keyring can only answer whether this machine can open a vault, and this /// question is about everybody else. /// internal ObservableCollection Grants { get; } = []; [ObservableProperty] private VaultRowViewModel? selectedVault; [ObservableProperty] private VaultMemberRowViewModel? selectedMember; [ObservableProperty] private string status = string.Empty; [ObservableProperty] private bool isBusy; /// /// Whether the members panel — v5c's settings-page overlay over the selected vault's people — is open. /// /// /// Desktop-only presentation state, not a fact about any vault: it says which rectangle is on screen and /// nothing else. Selecting a vault is what actually reads its members, in /// ; opening this panel over one already selected re-reads nothing. /// [ObservableProperty] private bool isMembersPanelOpen; /// Opens the members panel, selecting the vault it is about first if it is not selected already. [RelayCommand] private void OpenMembersPanel(VaultRowViewModel? vault) { if (vault is not null && !ReferenceEquals(vault, SelectedVault)) { SelectedVault = vault; } IsMembersPanelOpen = true; } /// Closes the members panel. [RelayCommand] private void CloseMembersPanel() => IsMembersPanelOpen = false; // ---- Creating a vault ---- [ObservableProperty] private bool isCreatingVault; [ObservableProperty] private string newVaultName = string.Empty; /// /// The membership list a half-finished create already made, held so the retry does not make a second. /// /// /// Creating a vault 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: TeamService /// 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. /// private Guid? pendingVaultTeamId; // ---- Renaming a vault ---- [ObservableProperty] private bool isRenamingVault; [ObservableProperty] private string editVaultName = string.Empty; // ---- Adding somebody ---- [ObservableProperty] private string newMemberEmail = string.Empty; /// /// The role a newly added account gets. /// /// /// Member by default, which is the role somebody adding a colleague almost always means. Viewer /// would be safer and would be the wrong default: an interface whose default is wrong teaches /// people to change it without reading it. /// [ObservableProperty] private TeamMemberRole newMemberRole = TeamMemberRole.Member; // ---- Confirming something that cannot be undone ---- [ObservableProperty] private VaultActionRequest? pendingAction; /// Set while reselects, so the handler does not read as well. private bool isReselecting; /// /// Which selection read owns the lists beside the vault list. /// /// /// Selecting a second vault before the first one's read has answered leaves two reads in flight /// against the same collections, and the one that started first can answer last — so the /// superseded read drops its answer instead of appending another vault's members to the list. UI /// thread only, which is where every selection change and every continuation on this screen runs. /// private int selectionGeneration; /// Whether there is a server to talk to at all. internal bool IsOnline => connection() is not null; /// Whether the keychain is unlocked, which is what makes the vault list readable. internal bool IsUnlocked => session() is not null; /// Whether the selected vault can be administered by this account. internal bool CanAdministerSelected => SelectedVault?.CanAdminister == true; /// Whether this account owns the selected vault's membership list. /// /// A narrower gate than , and the server draws the same line: /// handing a vault on decides who controls it, so an admin the owner promoted must not be able to. /// internal bool OwnsSelected => SelectedVault?.IsOwned == true; /// Whether the selected vault is one other people can be in. /// /// False for the personal vault, which is not a shortcoming of this screen: the server refuses a /// grant on a personal vault outright, because a key sealed to somebody it will go on refusing to /// serve is a row that looks like sharing and is not. See VaultGrantService. /// internal bool SelectedIsShared => SelectedVault?.IsShared == true; /// Whether the selected vault is the personal one. internal bool SelectedIsPersonal => SelectedVault?.IsPersonal == true; /// /// Whether the selected vault is one this account may delete. /// /// /// Narrower than by exactly the personal vault, which cannot be /// deleted by anybody: it is where everything filed nowhere else lives and nothing can make another. /// The server refuses one too, so a button offered here would be a button that leads to a refusal. /// internal bool CanDeleteSelected => SelectedVault is { IsPersonal: false, CanAdminister: true }; /// Whether there is anything to show beside the vault list. internal bool HasSelection => SelectedVault is not null; internal bool HasVaults => Vaults.Count > 0; /// Whether a destructive action is armed and waiting for an answer. internal bool IsConfirming => PendingAction is not null; /// Whether the ordinary vault buttons should be showing. /// /// The inverse of , so the confirmation replaces the buttons that armed /// it rather than appearing beneath them still pressable. /// internal bool ShowsVaultActions => !IsConfirming; /// /// The warning a vault sharing its membership list with others has to carry. /// /// /// Empty in every case this screen creates, because a vault made here gets a membership list of its /// own. It is not empty for the arrangements that pre-date this screen, and there the sentence is the /// whole point: on a vault-shaped screen, adding somebody to one vault silently adding them to three /// others is exactly the fact a vault-shaped screen is in a position to hide. /// internal string SharedMembershipWarning => SelectedVault is { SharedWithOtherVaults: > 0 } row ? string.Create( CultureInfo.CurrentCulture, $"These people are in {row.SharedWithOtherVaults} other vault(s) as well: this vault's membership list is shared with them. Adding or removing somebody here does the same there.") : string.Empty; internal bool HasSharedMembershipWarning => SharedMembershipWarning.Length > 0; internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer; internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member; internal bool AddsAsAdmin => NewMemberRole == TeamMemberRole.Admin; /// Reads the vaults this account can see, and the selected one's detail. internal Task LoadAsync(CancellationToken cancellationToken) => RunAsync(() => ReloadAsync(cancellationToken)); /// Reads it all again. /// /// The same work as , exposed as a command because markup cannot invoke a /// method. The phone needs it and the desktop does not: this screen is loaded on arrival, and on /// the desktop leaving the rail and coming back is one click, where on the phone it is a trip out /// to MORE and back. Nothing below the vault list is cached, so a re-read is the only way to see a /// change somebody else made. /// [RelayCommand] private Task RefreshAsync(CancellationToken cancellationToken) => RunAsync(() => ReloadAsync(cancellationToken)); /// /// The reload itself, without the busy gate. /// /// /// Separate from because every command ends by reloading, and a command that /// called the gated version would find the gate held by itself and skip the reload silently — leaving /// a vault that was created moments ago missing from the list it was just added to. /// /// Cancellation token. /// /// The vault to land on, or null to keep the one already selected. /// /// Here rather than assigned after the call, because the assignment fires /// and that starts a read nothing can await — so a caller wanting /// the new vault's members 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. /// /// private async Task ReloadAsync(CancellationToken cancellationToken, Guid? select = null) { var selectedId = select ?? SelectedVault?.VaultId; Vaults.Clear(); if (session() is not { } open) { Members.Clear(); Grants.Clear(); RaiseState(); Status = "Locked. Unlock your keychain to see your vaults."; return; } // The membership facts, where there is a server to ask. Offline the vault list is still right — // it is what this machine holds — and every row simply says its membership is not known. var teams = connection() is { } server ? await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true) : []; BuildRows(open, teams); // The assignment reselects the same vault through a new row object, so the selection handler // would start its own read of the very lists this method is about to read — two reads clearing // and then appending into the same collections, which draws every member and key holder twice. Suppressed rather than deduplicated, because the read below is awaited and the // handler's is not: this is the one that has to be the reload's. isReselecting = true; try { SelectedVault = Vaults.FirstOrDefault(row => row.VaultId == selectedId) ?? Vaults.FirstOrDefault(); } finally { isReselecting = false; } RaiseState(); await LoadSelectedAsync(cancellationToken).ConfigureAwait(true); Status = connection() is null ? "Offline. The vaults are this machine's; who is in them is read from the server." : string.Empty; } /// Fills the vault list from the session, with the server's membership facts laid over it. /// /// Personal first and then by name, which is the order every other vault list in the application /// uses — and the personal one is first because it is the one that is always there. /// private void BuildRows(VaultSession open, IReadOnlyList teams) { var byTeam = teams.ToDictionary(team => team.TeamId); var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet(); // How many vaults each membership list carries, so a row can say when it is not the only one. var perTeam = open.Vaults .Where(vault => vault.TeamId is not null) .GroupBy(vault => vault.TeamId!.Value) .ToDictionary(group => group.Key, group => group.Count()); foreach (var vault in open.Vaults .OrderByDescending(vault => vault.IsPersonal) .ThenBy(vault => vault.Name, StringComparer.CurrentCulture)) { var team = vault.TeamId is { } teamId && byTeam.TryGetValue(teamId, out var found) ? found : null; Vaults.Add(new VaultRowViewModel( vault.VaultId, vault.Name, vault.IsPersonal, vault.TeamId, team?.Role ?? TeamMemberRole.Unspecified, team?.MemberCount ?? 0, vault.TeamId is { } id && perTeam.TryGetValue(id, out var count) ? count - 1 : 0, readable.Contains(vault.VaultId), vault.CanWrite, vault.RekeyRequired)); } } /// Opens the name-a-vault form. /// /// What the tab strip's vault menu reaches as well as the button above the list. One field: the /// membership list behind the vault is made with it and named after it — see /// — because somebody who wants to share four servers with two /// colleagues is not asking to found an organisation first. /// [RelayCommand] private void NewVault() { // Opening the form is a fresh attempt, so a membership list 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 it // would put a vault called one thing inside a list 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; NewVaultName = string.Empty; IsCreatingVault = true; IsRenamingVault = false; Status = string.Empty; } /// /// Abandons the name-a-vault form. /// /// /// Tidies away the membership list a half-finished create left behind, which is a deliberate /// exception to this application's rule against cleaning up on the user's behalf. The reason is that /// nothing on this screen can reach it: a membership list with no vault has no row here, so leaving it /// would be leaving a thing the user cannot see, cannot use and cannot remove. Cancelling is the one /// moment somebody says they are done with this attempt, and it is empty, so archiving it is refused /// by nothing. A failure is ignored: the alternative is an error about something the user was not /// told existed. /// [RelayCommand] private async Task CancelNewVaultAsync(CancellationToken cancellationToken) { IsCreatingVault = false; Status = string.Empty; if (pendingVaultTeamId is not { } orphan) { return; } pendingVaultTeamId = null; if (connection() is not { } server) { return; } try { await server.Teams.ArchiveTeamAsync(orphan, cancellationToken).ConfigureAwait(true); } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { // Nothing to say. It is a membership list nobody was shown, holding nobody but its creator. } } /// /// Creates a vault, and the membership list to carry it. /// /// /// /// A vault always belongs to a team, and this is what keeps that from being the user's problem. /// 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 and key /// holders all hang off it, so they are all there the moment the vault is. /// /// /// Two calls, and the first can succeed alone. When it does, the membership list is kept rather /// than tidied away here — see for how the retry avoids making a /// second one, and for where it does get tidied away. /// /// [RelayCommand] private async Task CreateVaultAsync(CancellationToken cancellationToken) { if (connection() is not { } server) { Status = "Offline. Creating a vault needs a connection."; return; } if (session() is not { } open) { 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); } /// The two calls behind , once its arguments are known good. private async Task AddVaultAsync( IVaultServer server, VaultSession open, string name, CancellationToken cancellationToken) { var teamId = 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 (exception is not OperationCanceledException) { // The whole state, not "creating the vault failed". Pressing CREATE again finishes the job // rather than making a second membership list, and cancelling takes the half-made one away — // none of which the user can work out from the failure alone. Status = $"The vault '{name}' was not created: {exception.Message} Press CREATE again to " + "finish it, or CANCEL to undo what was made."; return; } IsCreatingVault = false; pendingVaultTeamId = null; NewVaultName = string.Empty; await ReloadAsync(cancellationToken, select: vault.VaultId).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. await NotifyVaultsChangedAsync(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 beside it, then press SHARE KEY."; } /// Makes the membership list a new vault will belong to, or returns the one a retry made. /// /// 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. /// private async Task 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 one. 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; } /// /// Turns a vault name into a slug the server will accept. /// /// /// Mirrors TeamService.RequireSlug: 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 id /// rather than to a refusal the user cannot see the cause of in what they typed. /// 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); } /// Adds enough of the id to a slug to get past one somebody else has taken. 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; } /// Opens the rename form for the selected vault. [RelayCommand] private void RenameVault() { if (SelectedVault is not { } vault) { return; } EditVaultName = vault.Name; IsRenamingVault = true; IsCreatingVault = false; Status = string.Empty; } /// Abandons the rename form. [RelayCommand] private void CancelRenameVault() { IsRenamingVault = false; Status = string.Empty; } /// Opens the rename form for a specific vault, from the settings page's per-card edit icon. /// /// A thin wrapper around rather than a second implementation: the settings /// page draws one card per vault with no list selection to lean on the way this screen used to have, so /// this selects the row first and then asks the command that already knows how to open the form. /// /// The vault to rename. [RelayCommand] private void RenameVaultRow(VaultRowViewModel? vault) { if (vault is not null) { SelectedVault = vault; } RenameVaultCommand.Execute(null); } /// Arms the delete confirmation for a specific vault, from the settings page's per-card delete icon. /// /// The vault to ask about deleting. [RelayCommand] private void DeleteVaultRow(VaultRowViewModel? vault) { if (vault is not null) { SelectedVault = vault; } DeleteVaultCommand.Execute(null); } /// /// Saves the renamed vault. /// /// /// The whole shell is told, because a vault's name is drawn in more places than this screen: the badge /// on every host card in a session holding several, the file-this-into picker, and the tab strip's /// vault menu. /// [RelayCommand] private async Task SaveVaultNameAsync(CancellationToken cancellationToken) { if (connection() is not { } server || session() is not { } open || SelectedVault is not { } vault) { Status = connection() is null ? "Offline. A vault's name is the server's, so renaming it needs a connection." : string.Empty; return; } var name = EditVaultName.Trim(); if (name.Length == 0) { Status = "A vault needs a name."; return; } await RunAsync(async () => { var renamed = await open .RenameVaultAsync(server.Grants, vault.VaultId, name, cancellationToken) .ConfigureAwait(true); IsRenamingVault = false; await ReloadAsync(cancellationToken, select: vault.VaultId).ConfigureAwait(true); await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true); // What a rename does not touch, said once rather than left to be discovered. A vault name is // plaintext on the server — it always was, because a person has to pick a vault before // anything is decrypted — and nothing inside it is re-encrypted by this. Status = $"Renamed to '{renamed.Name}'. The name is stored in plain text, as it was before; " + "nothing in the vault was re-encrypted, and everybody's key still opens it."; }).ConfigureAwait(true); } /// /// Adds somebody to the selected vault, by looking their address up in the directory first. /// /// /// /// The directory is asked first, and the order is the point: it is what turns an address into an /// account and a public key, and the key that gets verified before any sharing is the one /// that lookup returned. Resolving the address server-side when the directory could answer would /// put an unwitnessed step between the two. /// /// /// A directory miss is not an absent account, and treating it as one was a bug worth naming. /// The directory returns only accounts that have published a key, so everybody between their first /// sign-in and their enrollment is missing from it. Reporting that miss as "no account here" told /// somebody who was standing right there that they were not, and left the members list unchanged. So /// the miss is retried as an add by address, and only a server saying there is no such account is /// taken as an answer. /// /// /// And that answer is the end of it. There is nothing to fall through to: a membership is /// granted to an account, so somebody who has never signed in here cannot be added yet, and the /// remedy belongs to them rather than to the person at this screen. Saying so plainly is the whole /// of what this command can do about it — see docs/adr/0009-team-access-model.md. /// /// [RelayCommand] private async Task AddMemberAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedVault?.TeamId is not { } teamId) { // Never silent. This command's failures used to be visible only as a flicker of the busy // flag, which reads as a button that does nothing at all. Status = WhyNobodyCanBeAdded(); return; } var email = NewMemberEmail.Trim(); if (email.Length == 0) { Status = "Type the email address of somebody who has signed in to this server."; return; } await RunAsync(() => AddAsync(server, teamId, email, cancellationToken)) .ConfigureAwait(true); } /// Which of the three reasons the ADD button had nothing to act on. private string WhyNobodyCanBeAdded() => connection() is null ? "Offline. Adding somebody changes who the server will serve, so it needs a connection." : SelectedIsPersonal ? "Your personal vault is yours alone and cannot be shared. Make a vault for the things you " + "want to share, and put them in it." : "Select a vault on the left first — somebody is added to one vault, not to all."; /// The calls behind , once its arguments are known good. private async Task AddAsync( IVaultServer server, Guid teamId, string email, CancellationToken cancellationToken) { var found = await server.Directory.LookupByEmailAsync(email, cancellationToken) .ConfigureAwait(true); var request = found.Count > 0 ? new AddTeamMemberRequest(found[0].UserId, NewMemberRole) : new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email); TeamMemberSummary member; try { member = await server.Teams .AddTeamMemberAsync(teamId, request, cancellationToken) .ConfigureAwait(true); } catch (DodoSshApiException exception) when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal)) { // The address really is unknown here, which only the server can say. Reported rather than // rethrown, because it is the answer rather than a failure — and the sentence has to carry // what happens next, or somebody retypes the address expecting a different outcome. Status = $"No account on this server uses '{email}'. Ask them to sign in here once, " + "which is what creates the account, and then add them. Nothing is held for them in " + "the meantime — an address is not a way into a vault."; return; } NewMemberEmail = string.Empty; // Before the reload, so the vault list this screen redraws already shows what they can open. The // sharing is what makes the membership worth anything, and doing it here rather than leaving a // SHARE KEY button to be pressed is the difference between adding a colleague and adding a // colleague who then waits for somebody to notice. var shared = await ShareWithAsync(server, teamId, member, cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = Describe(member, shared); } /// /// Wraps every vault behind this membership list that this machine can open to somebody just added. /// /// What to tell the user about the keys, or null when there was nothing to say. /// /// /// The membership list rather than the one vault, and that is not a slip: adding somebody is a /// change to the list, so it is every vault the list carries that they can now fetch. This screen /// makes lists that carry one vault, so the sentence names one — and where it does not, naming them /// all is the honest report of what just happened. /// /// /// Skipped outright for an account with no identity key: there is nothing to wrap to, and a /// refusal per vault would bury that one fact under a list. Their row says so, and adding them was /// still worth doing. /// /// /// A failure here is reported and never thrown. The membership has already been recorded on the /// server and is not undone by a key that could not be wrapped — so the honest outcome is "they are /// in it, and this vault still needs sharing", which is a state somebody can act on. /// /// private async Task ShareWithAsync( IVaultServer server, Guid teamId, TeamMemberSummary member, CancellationToken cancellationToken) { if (!member.IsEnrolled) { return null; } if (session() is not { } open) { // Distinguished from holding no keys, because the two lead somewhere different: this one is // fixed by unlocking, and the other by asking somebody who holds the vault. return "Nothing was shared with them — a vault key is wrapped on an unlocked machine, and " + "this keychain is locked."; } var reports = await open .ShareTeamVaultsAsync( server.Grants, server.Directory, teamId, member.UserId, cancellationToken) .ConfigureAwait(true); if (reports.Count == 0) { return null; } var shared = reports.Where(report => report.Succeeded).ToList(); var refused = reports.Where(report => !report.Succeeded).ToList(); var sentence = shared.Count > 0 ? $"Shared {VaultCount(shared.Count)} with them: {Join(shared.Select(r => r.Name))}." : null; if (refused.Count == 0) { return sentence; } // Named one by one rather than counted. Each of these is a vault somebody now expects them to // be able to open, and which one it is decides who has to fix it. var reasons = refused.Select(report => $"'{report.Name}' ({report.Failure?.Message ?? report.Outcome?.Message})"); return (sentence is null ? string.Empty : sentence + " ") + $"Could not share {Join(reasons)}."; } /// "1 vault" or "3 vaults", for a sentence that has to read either way. private static string VaultCount(int count) => string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}"); /// Joins names into a phrase a person would say, rather than a comma-separated list. private static string Join(IEnumerable parts) { var list = parts.ToList(); return list.Count switch { 0 => string.Empty, 1 => list[0], 2 => $"{list[0]} and {list[1]}", _ => string.Join(", ", list.Take(list.Count - 1)) + " and " + list[^1], }; } /// /// What just happened to the account that was added, and what is still owed them. /// /// /// /// The enrolled branch reports what the keys did, because that is the half of "adding somebody" /// that this machine performs and the half that can partly fail. A vault that could not be wrapped /// is named there rather than left to be noticed when they say they cannot open it. /// /// /// The unenrolled branch says more, and has to: their row will sit in the list saying it holds no /// key, and without this somebody would read that as the addition having half-failed rather than as /// a colleague who has not finished setting their machine up. Nothing was shared with them and /// nothing could have been — there is no key to wrap to — so the membership is all there is yet. /// /// private static string Describe(TeamMemberSummary member, string? shared) { var who = member.Email ?? member.DisplayName ?? "the account"; if (!member.IsEnrolled) { return $"Added {who}. They have no key yet, so their row says so and this vault cannot be " + "shared with them until they finish signing in on their own machine. The membership " + "is real in the meantime."; } return shared is null ? $"Added {who}. This machine holds no key to give them — press SHARE KEY from one that " + "does." : $"Added {who}. {shared}"; } /// Picks the role a newly added account will get. [RelayCommand] private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role; /// Changes the selected member's role. /// /// Owner is not offered, and the command refuses it rather than relying on the view not to send /// it: the server refuses it too, and a button that produced a server error would be reporting a /// rule the interface should have known. /// [RelayCommand] private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken) { if (connection() is not { } server || SelectedVault?.TeamId is not { } teamId || SelectedMember is not { } member) { return; } if (role is TeamMemberRole.Owner or TeamMemberRole.Unspecified) { Status = "Ownership is handed over rather than assigned. Use HAND OVER below."; return; } if (member.Member.Role == role) { return; } await RunAsync(async () => { var changed = await server.Teams .ChangeTeamMemberRoleAsync( teamId, member.UserId, new ChangeTeamMemberRoleRequest(role), cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedMember = Members.FirstOrDefault(row => row.UserId == member.UserId); // What a role does and does not reach. A viewer still holds whatever key they were // wrapped, so demoting somebody is not a way of taking a vault back from them. Status = $"{member.Name} is now {changed.Role.ToString().ToLowerInvariant()}. This changes " + "what the server will serve them; it does not withdraw a vault key they already " + "hold — use WITHDRAW KEY for that."; }).ConfigureAwait(true); } /// Arms the hand-over confirmation for the selected member. [RelayCommand] private void HandOver() { if (SelectedVault is not { TeamId: { } teamId } vault || SelectedMember is not { } member) { return; } if (member.IsSelf) { Status = "You already own this vault."; return; } PendingAction = new VaultActionRequest( VaultActionKind.HandOver, teamId, member.UserId, vault.VaultId, $"Hand '{vault.Name}' to {member.Name}?", "They become its owner and you become an admin. You will not be able to take it back " + "yourself — only the new owner can hand it on. Your key to it is untouched."); } /// /// Arms the deletion confirmation for the selected vault. /// /// /// /// The consequence is spelled out at length rather than summarised, and every sentence in it is one /// somebody could otherwise be surprised by afterwards. The last is the one this product must never /// leave implied: deleting a vault does not reach the machines it has already synced to. That is the /// same limit revocation has, for the same reason, and it is written down in ADR 0001. /// /// /// The personal vault is refused here as well as by the server. A button that reached a refusal would /// be teaching somebody to try things and read errors, and the reason is a fact this screen knows. /// /// [RelayCommand] private void DeleteVault() { if (SelectedVault is not { } vault) { return; } if (vault.IsPersonal) { Status = "Your personal vault cannot be deleted. It is where everything filed nowhere else " + "lives, and there is no way to make another."; return; } if (vault.TeamId is not { } teamId) { return; } var others = Members.Count(member => !member.IsSelf); var shared = others > 0 ? string.Create( CultureInfo.CurrentCulture, $" {others} other member(s) lose it at the same moment, without being asked.") : string.Empty; PendingAction = new VaultActionRequest( VaultActionKind.Delete, teamId, Guid.Empty, vault.VaultId, $"Delete '{vault.Name}'?", $"Everything in it goes: its hosts, keys, passwords, snippets and buckets stop being readable " + $"by anybody, including you, and nothing here can undo it.{shared} What it cannot do is " + "reach a machine that has already synced this vault — a copy pulled yesterday is still " + "there. Rotate the credentials that mattered."); } /// Cancels an armed action. [RelayCommand] private void CancelAction() => PendingAction = null; /// /// Carries out whichever action was armed. /// /// /// Disarmed before the work rather than after it, so the card goes the moment it is answered and a /// second press during a slow round trip has nothing left to agree to. /// [RelayCommand] private async Task ConfirmActionAsync(CancellationToken cancellationToken) { if (connection() is not { } server || PendingAction is not { } request) { return; } PendingAction = null; await RunAsync(() => request.Kind switch { VaultActionKind.Delete => DeleteAsync(server, request, cancellationToken), _ => HandOverAsync(server, request, cancellationToken), }).ConfigureAwait(true); } private async Task HandOverAsync( IVaultServer server, VaultActionRequest request, CancellationToken cancellationToken) { await server.Teams .TransferTeamOwnershipAsync( request.TeamId, new TransferTeamOwnershipRequest(request.MemberId), cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = "Handed over. You are an admin of this vault now, and only its new owner can hand " + "it on again."; } /// /// Deletes the vault the confirmation was armed for. /// /// /// /// The name is read before the call, because afterwards there is no row to read it from and the /// sentence this ends with is about a vault that no longer exists. /// /// /// The rest of the shell is told, as a rename tells it: the tab strip's vault menu, the file-this-into /// picker and every host list are built from the session's vault list, and all of them are a vault out /// of date the moment one goes. /// /// private async Task DeleteAsync( IVaultServer server, VaultActionRequest request, CancellationToken cancellationToken) { if (session() is not { } open) { Status = "Unlock your keychain first: deleting a vault gives up this machine's key to it."; return; } var name = Vaults.FirstOrDefault(row => row.VaultId == request.VaultId)?.Name ?? "That vault"; var deleted = await open .DeleteVaultAsync(server.Grants, request.VaultId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true); Status = deleted ? $"Deleted '{name}'. Everybody's key to it is withdrawn. What anybody had already synced is " + "still on their machine — rotate the credentials that mattered." : $"'{name}' was already gone. Somebody else deleted it, or your access to it ended."; } /// /// Removes somebody, revoking their grants and rotating the vaults they could read. /// /// /// The removal and the rotation are separate acts and only the first is the server's. Nothing here /// undoes the removal if the rotation fails, and nothing waits for it: the membership change is what /// stops them fetching anything more, and it has already happened by then. /// [RelayCommand] private async Task RemoveMemberAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedVault?.TeamId is not { } teamId || SelectedMember is not { } member) { return; } // Read before the removal, because afterwards this list no longer contains them — and it is the // list of who the new key goes to. var remaining = Members .Where(row => row.UserId != member.UserId) .Select(row => row.UserId) .ToList(); await RunAsync(async () => { await server.Teams .RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken) .ConfigureAwait(true); var rotated = await RotateAfterRemovalAsync(server, teamId, remaining, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); // The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive, // and a message implying otherwise is the one thing this screen must not say. The rotation // is described in the same breath for the same reason — it decides what happens next, not // what already happened. Status = $"Removed {member.Name}. {rotated} Anything they had already downloaded is still " + "on their machine — rotate the credentials that matter."; }).ConfigureAwait(true); } /// /// Rotates every vault behind this membership list that this machine can open, handing each new key /// to the members who remain. /// /// What to tell the user about the keys. Never null — something always happened. /// /// The list rather than the one vault, for the reason gives: removing /// somebody is a change to the list, so every vault it carries is one they have just lost. A vault /// this machine cannot open is not rotated and is not counted as a failure: its key belongs to /// somebody else, the server has flagged it as owing a rekey, and its row says so until one of them /// does it. /// private async Task RotateAfterRemovalAsync( IVaultServer server, Guid teamId, IReadOnlyList remaining, CancellationToken cancellationToken) { if (session() is not { } open) { return "Their key grants are withdrawn, so they can fetch nothing more. Unlock your " + "keychain to rotate the vault keys themselves."; } var reports = await open .RekeyTeamVaultsAsync( server.Grants, server.Directory, server.Sync, teamId, remaining, cancellationToken) .ConfigureAwait(true); if (reports.Count == 0) { return "Their key grants are withdrawn, so they can fetch nothing more. This machine holds " + "no key to any of it, so there was nothing here to rotate."; } var rotated = reports.Where(report => report.Rotated).ToList(); var failed = reports.Where(report => !report.Rotated).ToList(); var sentences = new List(); if (rotated.Count > 0) { sentences.AddRange(Describe(rotated)); } if (failed.Count > 0) { sentences.Add( $"Could not rotate {Join(failed.Select(r => $"'{r.Name}' ({r.Failure?.Message})"))}."); } return string.Join(" ", sentences); } /// What the vaults that did rotate are now worth, in the order somebody needs it. private IEnumerable Describe(List rotated) { yield return $"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing " + "written from now on is readable to them."; // Two different promises, so two different sentences. A vault whose items were all moved onto // the new key is closed to them completely; one where some were left is closed to what happens // next, and the difference is not the interface's to blur. var sealedUp = rotated.Count(report => report.Sealed); yield return sealedUp == rotated.Count ? "Everything already stored was re-sealed under the new key too, so their old key opens " + "nothing." : $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new " + "key; the rest still hold items under the old one and will be picked up next time. " + "Rotate the credentials that mattered either way."; // The members who did not get the new key. They are still in the vault and can still write, but // until somebody wraps it to them they will find it stops updating. Distinct by id rather than // by name, because two accounts can share a display name and collapsing them would tell // somebody one person is owed a key when two are. var missed = rotated .SelectMany(report => report.NotShared.Select(entry => entry.UserId)) .Distinct() .Select(Name) .ToList(); if (missed.Count > 0) { yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or " + "they will stop seeing changes."; } } /// What to call a member in a sentence, from the list this screen already has. private string Name(Guid userId) => Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString(); /// /// Wraps the selected vault's key to the selected member. /// /// /// Everything that makes this safe happens inside : the key /// log is read and its chain verified, and the directory's answer has to appear in it unchanged before /// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because /// the reasons are not interchangeable — one of them means somebody is substituting keys. /// [RelayCommand] private async Task ShareVaultAsync(CancellationToken cancellationToken) { if (connection() is not { } server || session() is not { } open || SelectedVault is not { } vault || SelectedMember is not { } member) { return; } if (member.IsSelf) { Status = "You already hold this vault's key."; return; } await RunAsync(async () => { var outcome = await open .ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken) .ConfigureAwait(true); // The generation count is said out loud when there is more than one, because it is the // answer to a question somebody will have about a rotated vault: whether the person they // just shared it with can see what was in it before the rotation. var history = outcome.Generations > 1 ? $" All {outcome.Generations} generations of the key were wrapped, so they can read " + "what was in the vault before it was last rotated." : string.Empty; Status = outcome.Shared ? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}{history}" : $"Did not share '{vault.Name}': {outcome.Message}"; await LoadGrantsAsync(cancellationToken).ConfigureAwait(true); }).ConfigureAwait(true); } /// Withdraws the selected member's key to the selected vault. [RelayCommand] private async Task RevokeVaultAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedVault is not { } vault || SelectedMember is not { } member) { return; } await RunAsync(async () => { var revoked = await server.Grants .RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = revoked ? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they " + "already have is unaffected." : $"{member.Name} held no key to '{vault.Name}'."; }).ConfigureAwait(true); } partial void OnSelectedVaultChanged(VaultRowViewModel? value) { RaiseState(); // An armed confirmation names the vault it was armed for, so a selection change has to disarm // it — otherwise the card stays on screen above a different vault and reads as being about it. PendingAction = null; IsRenamingVault = false; // The members panel is drawn over one vault's people; a selection that clears altogether — the // vault it was showing got deleted, or the list emptied — leaves nothing for it to be about. if (value is null) { IsMembersPanelOpen = false; } if (isReselecting) { return; } // Fire-and-forget on purpose, and the only place in this class that is: selection changes come // from a list box, which has no cancellation token and no way to await. Wrapped here rather than // inside the read, because the read is also awaited from inside a reload that already holds the // busy gate — and a second RunAsync there would find the gate held by its own caller and skip the // members list entirely. _ = RunAsync(() => LoadSelectedAsync(CancellationToken.None)); } partial void OnPendingActionChanged(VaultActionRequest? value) { OnPropertyChanged(nameof(IsConfirming)); OnPropertyChanged(nameof(ShowsVaultActions)); } partial void OnNewMemberRoleChanged(TeamMemberRole value) { OnPropertyChanged(nameof(AddsAsViewer)); OnPropertyChanged(nameof(AddsAsMember)); OnPropertyChanged(nameof(AddsAsAdmin)); } /// Reads who holds a key to the selected vault. /// /// Every vault, including the personal one: its own self-grant is what makes it readable here, and a /// list that hid it would leave the one vault everybody has looking as though nobody could open it. /// private async Task LoadGrantsAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedVault is not { } vault) { return; } // Read rather than incremented: this is a read *within* a selection rather than a new one, and it // is also called on its own after a share. Compared again below for the reason // exists — two vaults' key holders in one list is the failure, // and it is the one an in-order answer cannot be relied on to prevent. var generation = selectionGeneration; var response = await server.Grants .ListVaultGrantsAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); if (generation != selectionGeneration) { return; } Grants.Clear(); foreach (var grant in response.Grants) { Grants.Add(new VaultGrantRowViewModel(grant, response.KeyGeneration)); } } /// Reads the selected vault's members and key holders. private async Task LoadSelectedAsync(CancellationToken cancellationToken) { var generation = ++selectionGeneration; Members.Clear(); Grants.Clear(); if (connection() is not { } server || SelectedVault is not { } vault) { return; } await LoadGrantsAsync(cancellationToken).ConfigureAwait(true); if (vault.TeamId is not { } teamId) { // The personal vault. There is no membership list behind it, and there is nothing to // read — which is a fact about this vault rather than about the connection. return; } var selfId = session()?.Profile.UserId; var members = await server.Teams .ListTeamMembersAsync(teamId, cancellationToken) .ConfigureAwait(true); if (generation != selectionGeneration) { return; } foreach (var member in members) { Members.Add(new VaultMemberRowViewModel(member, member.UserId == selfId)); } } /// Tells the shell that the set of vaults, or one of their names, has moved. private Task NotifyVaultsChangedAsync(CancellationToken cancellationToken) => vaultsChanged is { } notify ? notify(cancellationToken) : Task.CompletedTask; private void RaiseState() { OnPropertyChanged(nameof(HasVaults)); OnPropertyChanged(nameof(HasSelection)); OnPropertyChanged(nameof(CanAdministerSelected)); OnPropertyChanged(nameof(OwnsSelected)); OnPropertyChanged(nameof(SelectedIsShared)); OnPropertyChanged(nameof(SelectedIsPersonal)); OnPropertyChanged(nameof(CanDeleteSelected)); OnPropertyChanged(nameof(SharedMembershipWarning)); OnPropertyChanged(nameof(HasSharedMembershipWarning)); OnPropertyChanged(nameof(IsOnline)); OnPropertyChanged(nameof(IsUnlocked)); } /// /// One place that raises the busy flag and turns a failure into a sentence. An API exception's message /// is the server's problem detail, which is written for a person to read — see Problems — so it /// is shown rather than replaced with something vaguer. /// private async Task RunAsync(Func work) { if (IsBusy) { return; } IsBusy = true; try { await work().ConfigureAwait(true); } catch (DodoSshApiException exception) { Status = exception.Message; } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { Status = exception.Message; } finally { IsBusy = false; } } }