Public Access
1788 lines
74 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|