Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
T
jaap-jan a4b81da597
ci / build and test (push) Successful in 3m26s
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 17s
Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
  panel kept showing the previous vault's people under the new name. The read now starts regardless;
  the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
  title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
  leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
  ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
  workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
  Keychain names trim inside their column instead of printing over TYPE, and the Security page's
  caption wraps inside its card.
2026-10-02 13:16:55 +02:00

1809 lines
75 KiB
C#

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;
/// <summary>One vault, as a row in the list.</summary>
/// <remarks>
/// <para>
/// Built from what this machine holds — <see cref="VaultSession.Vaults"/> — 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.
/// </para>
/// <para>
/// <paramref name="Role"/> is <see cref="TeamMemberRole.Unspecified"/> for a personal vault and for any
/// vault whose team has not been read yet, which is what <see cref="MembershipIsKnown"/> distinguishes.
/// </para>
/// </remarks>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">What it is called. Plaintext, unlike everything in it.</param>
/// <param name="IsPersonal">Whether this is the vault nobody else can ever be added to.</param>
/// <param name="TeamId">The membership list behind it, for a shared vault.</param>
/// <param name="Role">This account's own role in it.</param>
/// <param name="MemberCount">How many people are in it, including this account.</param>
/// <param name="SharedWithOtherVaults">
/// How many <em>other</em> vaults the same people are in by virtue of the same membership list.
/// <para>
/// 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.
/// </para>
/// </param>
/// <param name="IsReadable">Whether this machine holds the key.</param>
/// <param name="CanWrite">Whether the server would accept a change to it.</param>
/// <param name="RekeyRequired">Whether a membership change has left it owing a rekey.</param>
internal sealed record VaultRowViewModel(
Guid VaultId,
string Name,
bool IsPersonal,
Guid? TeamId,
TeamMemberRole Role,
int MemberCount,
int SharedWithOtherVaults,
bool IsReadable,
bool CanWrite,
bool RekeyRequired)
{
/// <summary>Whether anybody else can be in this vault at all.</summary>
internal bool IsShared => TeamId is not null;
/// <summary>Whether the server has told us who is in it.</summary>
internal bool MembershipIsKnown => Role != TeamMemberRole.Unspecified;
/// <summary>The chip on the right of the row.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal string RoleLabel => (IsPersonal, MembershipIsKnown) switch
{
(true, _) => "PERSONAL",
(_, true) => Role.ToString().ToUpperInvariant(),
_ => string.Empty,
};
/// <summary>What the row says under its name.</summary>
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)"),
};
/// <summary>
/// Whether this account may add people to it and rename it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal bool CanAdminister =>
IsPersonal || (IsShared && Role is TeamMemberRole.Admin or TeamMemberRole.Owner);
/// <summary>Whether this account is the one that can hand the vault on.</summary>
internal bool IsOwned => IsShared && Role == TeamMemberRole.Owner;
/// <summary>What is true of this vault right now, where that is worth a line.</summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>The single letter the settings page's card draws on this vault's tile.</summary>
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
}
/// <summary>One member of a vault, as a row in the members table.</summary>
internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
{
internal Guid UserId => Member.UserId;
/// <summary>What to call them. The address, or the id when the account has neither.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal string Name =>
Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();
internal string Email => Member.Email ?? "—";
internal string Role => Member.Role.ToString().ToUpperInvariant();
/// <summary>
/// What the account can be given, in one phrase.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal string KeyState => Member.IsEnrolled
? "key published"
: "no key yet — cannot be given this vault";
/// <summary>
/// The day they were last here, or that they never have been.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>
/// The two letters the members panel draws on this row's avatar.
/// </summary>
/// <remarks>
/// The same rule <c>HostRowViewModel.Monogram</c> 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.
/// </remarks>
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 = [' ', '-', '_', '.'];
/// <summary>Whether this member's role can be changed at all.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal bool CanChangeRole => Member.Role != TeamMemberRole.Owner;
}
/// <summary>One vault key grant, as a row under the vault it opens.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed record VaultGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration)
{
internal Guid UserId => Grant.RecipientUserId;
internal string Name => Grant.DisplayName ?? Grant.Email ?? Grant.RecipientUserId.ToString();
/// <summary>
/// What this grant is worth, in one phrase.
/// </summary>
/// <remarks>
/// Staleness is decided by comparing generations rather than by reading
/// <see cref="VaultGrantState"/> alone, which is what <c>VaultGrantsResponse.KeyGeneration</c>
/// exists for: a grant can be Active and still open nothing, because it was wrapped to a key the
/// vault has since moved past.
/// </remarks>
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",
};
/// <summary>Whether this row still represents somebody who can read the vault.</summary>
internal bool IsLive =>
Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration;
}
/// <summary>
/// A destructive vault operation, armed and waiting to be confirmed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="Kind">Which operation the answer applies to.</param>
/// <param name="TeamId">The membership list the action is aimed at.</param>
/// <param name="MemberId">The member it is aimed at, or empty where it is not aimed at one.</param>
/// <param name="VaultId">The vault it is aimed at.</param>
/// <param name="Question">What is being asked.</param>
/// <param name="Consequence">What will actually happen, stated honestly.</param>
internal sealed record VaultActionRequest(
VaultActionKind Kind,
Guid TeamId,
Guid MemberId,
Guid VaultId,
string Question,
string Consequence);
/// <summary>Which destructive operation a confirmation is standing in front of.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal enum VaultActionKind
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Hand the vault's membership list to somebody else.</summary>
HandOver = 1,
/// <summary>Delete the vault.</summary>
Delete = 2,
}
/// <summary>
/// The vaults screen: which vaults there are, who is in each, and who holds a key to it.
/// </summary>
/// <remarks>
/// <para>
/// <b>This was the teams screen, and the team is now behind the vault rather than above it.</b> A team is
/// still what the server authorises against — <c>VaultAccessService</c> resolves a team vault through
/// <c>team_membership</c>, 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.
/// </para>
/// <para>
/// <b>Two separate acts, and the screen is still built around saying so.</b> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="connection">The server, or null where there is none.</param>
/// <param name="session">The unlocked session, or null while the keychain is locked.</param>
/// <param name="vaultsChanged">
/// Told when this screen has made or renamed 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.
/// </param>
internal sealed partial class VaultsViewModel(
Func<IVaultServer?> connection,
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>Every vault this account can see, readable or not.</summary>
internal ObservableCollection<VaultRowViewModel> Vaults { get; } = [];
/// <summary>The people in the selected vault.</summary>
internal ObservableCollection<VaultMemberRowViewModel> Members { get; } = [];
/// <summary>Who holds a key to the selected vault.</summary>
/// <remarks>
/// 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 <em>this</em> machine can open a vault, and this
/// question is about everybody else.
/// </remarks>
internal ObservableCollection<VaultGrantRowViewModel> Grants { get; } = [];
[ObservableProperty]
private VaultRowViewModel? selectedVault;
[ObservableProperty]
private VaultMemberRowViewModel? selectedMember;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool isBusy;
/// <summary>
/// Whether the members panel — v5c's settings-page overlay over the selected vault's people — is open.
/// </summary>
/// <remarks>
/// 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
/// <see cref="OnSelectedVaultChanged"/>; opening this panel over one already selected re-reads nothing.
/// </remarks>
[ObservableProperty]
private bool isMembersPanelOpen;
/// <summary>Opens the members panel, selecting the vault it is about first if it is not selected already.</summary>
[RelayCommand]
private void OpenMembersPanel(VaultRowViewModel? vault)
{
if (vault is not null && !ReferenceEquals(vault, SelectedVault))
{
SelectedVault = vault;
}
IsMembersPanelOpen = true;
}
/// <summary>Closes the members panel.</summary>
[RelayCommand]
private void CloseMembersPanel() => IsMembersPanelOpen = false;
// ---- Creating a vault ----
[ObservableProperty]
private bool isCreatingVault;
[ObservableProperty]
private string newVaultName = string.Empty;
/// <summary>
/// The membership list a half-finished create already made, held so the retry does not make a second.
/// </summary>
/// <remarks>
/// 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: <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 vault ----
[ObservableProperty]
private bool isRenamingVault;
[ObservableProperty]
private string editVaultName = string.Empty;
// ---- Adding somebody ----
[ObservableProperty]
private string newMemberEmail = string.Empty;
/// <summary>
/// The role a newly added account gets.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private TeamMemberRole newMemberRole = TeamMemberRole.Member;
// ---- Confirming something that cannot be undone ----
[ObservableProperty]
private VaultActionRequest? pendingAction;
/// <summary>Set while <see cref="ReloadAsync"/> reselects, so the handler does not read as well.</summary>
private bool isReselecting;
/// <summary>
/// Which selection read owns the lists beside the vault list.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private int selectionGeneration;
/// <summary>Whether there is a server to talk to at all.</summary>
internal bool IsOnline => connection() is not null;
/// <summary>Whether the keychain is unlocked, which is what makes the vault list readable.</summary>
internal bool IsUnlocked => session() is not null;
/// <summary>Whether the selected vault can be administered by this account.</summary>
internal bool CanAdministerSelected => SelectedVault?.CanAdminister == true;
/// <summary>Whether this account owns the selected vault's membership list.</summary>
/// <remarks>
/// A narrower gate than <see cref="CanAdministerSelected"/>, 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.
/// </remarks>
internal bool OwnsSelected => SelectedVault?.IsOwned == true;
/// <summary>Whether the selected vault is one other people can be in.</summary>
/// <remarks>
/// 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 <c>VaultGrantService</c>.
/// </remarks>
internal bool SelectedIsShared => SelectedVault?.IsShared == true;
/// <summary>Whether the selected vault is the personal one.</summary>
internal bool SelectedIsPersonal => SelectedVault?.IsPersonal == true;
/// <summary>
/// Whether the selected vault is one this account may delete.
/// </summary>
/// <remarks>
/// Narrower than <see cref="CanAdministerSelected"/> 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.
/// </remarks>
internal bool CanDeleteSelected => SelectedVault is { IsPersonal: false, CanAdminister: true };
/// <summary>Whether there is anything to show beside the vault list.</summary>
internal bool HasSelection => SelectedVault is not null;
internal bool HasVaults => Vaults.Count > 0;
/// <summary>Whether a destructive action is armed and waiting for an answer.</summary>
internal bool IsConfirming => PendingAction is not null;
/// <summary>Whether the ordinary vault buttons should be showing.</summary>
/// <remarks>
/// The inverse of <see cref="IsConfirming"/>, so the confirmation replaces the buttons that armed
/// it rather than appearing beneath them still pressable.
/// </remarks>
internal bool ShowsVaultActions => !IsConfirming;
/// <summary>
/// The warning a vault sharing its membership list with others has to carry.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>Reads the vaults this account can see, and the selected one's detail.</summary>
internal Task LoadAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
/// <summary>Reads it all again.</summary>
/// <remarks>
/// The same work as <see cref="LoadAsync"/>, 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.
/// </remarks>
[RelayCommand]
private Task RefreshAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
/// <summary>
/// The reload itself, without the busy gate.
/// </summary>
/// <remarks>
/// Separate from <see cref="LoadAsync"/> 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.
/// </remarks>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="select">
/// The vault 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="OnSelectedVaultChanged"/> 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.
/// </para>
/// </param>
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;
}
/// <summary>Fills the vault list from the session, with the server's membership facts laid over it.</summary>
/// <remarks>
/// 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.
/// </remarks>
private void BuildRows(VaultSession open, IReadOnlyList<TeamSummary> 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));
}
}
/// <summary>Opens the name-a-vault form.</summary>
/// <remarks>
/// 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
/// <see cref="CreateVaultAsync"/> — because somebody who wants to share four servers with two
/// colleagues is not asking to found an organisation first.
/// </remarks>
[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;
}
/// <summary>
/// Abandons the name-a-vault form.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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.
}
}
/// <summary>
/// Creates a vault, and the membership list to carry it.
/// </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 and key
/// holders all hang off it, 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 membership list is kept rather
/// than tidied away here — see <see cref="pendingVaultTeamId"/> for how the retry avoids making a
/// second one, and <see cref="CancelNewVaultAsync"/> for where it does get tidied away.
/// </para>
/// </remarks>
[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);
}
/// <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 = 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.";
}
/// <summary>Makes the membership list a new vault will belong to, or returns the one a retry 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 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;
}
/// <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 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 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 rename form for the selected vault.</summary>
[RelayCommand]
private void RenameVault()
{
if (SelectedVault is not { } vault)
{
return;
}
EditVaultName = vault.Name;
IsRenamingVault = true;
IsCreatingVault = false;
Status = string.Empty;
}
/// <summary>Abandons the rename form.</summary>
[RelayCommand]
private void CancelRenameVault()
{
IsRenamingVault = false;
Status = string.Empty;
}
/// <summary>Opens the rename form for a specific vault, from the settings page's per-card edit icon.</summary>
/// <remarks>
/// A thin wrapper around <see cref="RenameVault"/> 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.
/// </remarks>
/// <param name="vault">The vault to rename.</param>
[RelayCommand]
private void RenameVaultRow(VaultRowViewModel? vault)
{
if (vault is not null)
{
SelectedVault = vault;
}
RenameVaultCommand.Execute(null);
}
/// <summary>Arms the delete confirmation for a specific vault, from the settings page's per-card delete icon.</summary>
/// <inheritdoc cref="RenameVaultRow" path="/remarks" />
/// <param name="vault">The vault to ask about deleting.</param>
[RelayCommand]
private void DeleteVaultRow(VaultRowViewModel? vault)
{
if (vault is not null)
{
SelectedVault = vault;
}
DeleteVaultCommand.Execute(null);
}
/// <summary>
/// Saves the renamed vault.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>
/// Adds somebody to the selected vault, by looking their address up in the directory first.
/// </summary>
/// <remarks>
/// <para>
/// The directory is asked first, and the order is the point: it is what turns an address into an
/// account <em>and a public key</em>, 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.
/// </para>
/// <para>
/// <b>A directory miss is not an absent account, and treating it as one was a bug worth naming.</b>
/// 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.
/// </para>
/// <para>
/// <b>And that answer is the end of it.</b> 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 <c>docs/adr/0009-team-access-model.md</c>.
/// </para>
/// </remarks>
[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);
}
/// <summary>Which of the three reasons the ADD button had nothing to act on.</summary>
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.";
/// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary>
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);
}
/// <summary>
/// Wraps every vault behind this membership list that this machine can open to somebody just added.
/// </summary>
/// <returns>What to tell the user about the keys, or null when there was nothing to say.</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
private async Task<string?> 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)}.";
}
/// <summary>"1 vault" or "3 vaults", for a sentence that has to read either way.</summary>
private static string VaultCount(int count) =>
string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}");
/// <summary>Joins names into a phrase a person would say, rather than a comma-separated list.</summary>
private static string Join(IEnumerable<string> 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],
};
}
/// <summary>
/// What just happened to the account that was added, and what is still owed them.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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}";
}
/// <summary>Picks the role a newly added account will get.</summary>
[RelayCommand]
private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;
/// <summary>Changes the selected member's role.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>Arms the hand-over confirmation for the selected member.</summary>
[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.");
}
/// <summary>
/// Arms the deletion confirmation for the selected vault.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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.");
}
/// <summary>Cancels an armed action.</summary>
[RelayCommand]
private void CancelAction() => PendingAction = null;
/// <summary>
/// Carries out whichever action was armed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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.";
}
/// <summary>
/// Deletes the vault the confirmation was armed for.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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.";
}
/// <summary>
/// Removes somebody, revoking their grants and rotating the vaults they could read.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>
/// Rotates every vault behind this membership list that this machine can open, handing each new key
/// to the members who remain.
/// </summary>
/// <returns>What to tell the user about the keys. Never null — something always happened.</returns>
/// <remarks>
/// The list rather than the one vault, for the reason <see cref="ShareWithAsync"/> 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.
/// </remarks>
private async Task<string> RotateAfterRemovalAsync(
IVaultServer server,
Guid teamId,
IReadOnlyList<Guid> 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<string>();
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);
}
/// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
private IEnumerable<string> Describe(List<VaultRekeyReport> 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.";
}
}
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
private string Name(Guid userId) =>
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
/// <summary>
/// Wraps the selected vault's key to the selected member.
/// </summary>
/// <remarks>
/// Everything that makes this safe happens inside <see cref="VaultSession.ShareVaultAsync"/>: 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.
/// </remarks>
[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);
}
/// <summary>Withdraws the selected member's key to the selected vault.</summary>
[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.
//
// ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
// a share, a reload — so a vault chosen in that moment never had its people read, and the panel
// went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
// and every answer for an earlier selection is discarded by it.
_ = LoadSelectionAsync();
}
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
private async Task LoadSelectionAsync()
{
try
{
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (DodoSshApiException exception)
{
Status = exception.Message;
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
Status = exception.Message;
}
}
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));
}
/// <summary>Reads who holds a key to the selected vault.</summary>
/// <remarks>
/// 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.
/// </remarks>
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
// <see cref="selectionGeneration"/> 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));
}
}
/// <summary>Reads the selected vault's members and key holders.</summary>
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));
}
}
/// <summary>Tells the shell that the set of vaults, or one of their names, has moved.</summary>
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));
}
/// <remarks>
/// 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 <c>Problems</c> — so it
/// is shown rather than replaced with something vaguer.
/// </remarks>
private async Task RunAsync(Func<Task> 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;
}
}
}