Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
T

1788 lines
74 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. 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));
}
/// <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;
}
}
}