using System.Collections.ObjectModel; using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Api; using DodoSSH.Client.Session; using DodoSSH.Contracts; namespace DodoSSH.Client.Shell.ViewModels; /// One team, as a row in the list. internal sealed record TeamRowViewModel(TeamSummary Team) { internal Guid TeamId => Team.TeamId; internal string Name => Team.Name; internal string Slug => Team.Slug; /// The caller's own role, as the chip the list shows. internal string Role => Team.Role.ToString().ToUpperInvariant(); internal string Detail => string.Create( CultureInfo.CurrentCulture, $"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)"); /// Whether this account may add members and create vaults here. internal bool CanAdminister => Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner; } /// One member, as a row in the members table. internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf) { internal Guid UserId => Member.UserId; /// What to call them. The address, or the id when the account has neither. /// /// Falling through to the id rather than to "Unknown": an account with no display name and no email is /// rare and is exactly the row somebody needs to be able to identify in order to remove it. /// internal string Name => Member.DisplayName ?? Member.Email ?? Member.UserId.ToString(); internal string Email => Member.Email ?? "—"; internal string Role => Member.Role.ToString().ToUpperInvariant(); /// /// What the account can be given, in one phrase. /// /// /// Not a two-factor column: there is no second-factor concept anywhere in the server. What is /// true and worth a column is whether a vault key can be wrapped to them at all. /// internal string KeyState => Member.IsEnrolled ? "key published" : "no key yet — cannot be given a vault"; /// /// The day they were last here, or that they never have been. /// /// /// A date to the day, not a time and not a "3 hours ago". Two reasons, and they point the same /// way: the server writes this at most once an hour, so anything finer would be reading a /// precision into it that is not there — and a relative phrase would have to be recomputed against /// a clock, which this row does not have and which the pinned-host list already decided against by /// rendering its own dates the same way. /// internal string LastActive => Member.LastActiveAt is { } seen ? "last here " + seen.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture) : "never signed in"; internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner; /// Whether this member's role can be changed at all. /// /// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is /// only meaningful as half of a transfer. That is its own command. /// internal bool CanChangeRole => Member.Role != TeamMemberRole.Owner; } /// One vault key grant, as a row under the vault it opens. /// /// This is the "shared with" list the design drew as a row of avatars. It is drawn as names and a /// state instead, and it is a list rather than a count for a reason worth keeping: a grant is per /// vault, so a number on an item row would imply per-item sharing, which does not exist. /// internal sealed record TeamGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration) { internal Guid UserId => Grant.RecipientUserId; internal string Name => Grant.DisplayName ?? Grant.Email ?? Grant.RecipientUserId.ToString(); /// /// What this grant is worth, in one phrase. /// /// /// Staleness is decided by comparing generations rather than by reading /// alone, which is what VaultGrantsResponse.KeyGeneration /// exists for: a grant can be Active and still open nothing, because it was wrapped to a key the /// vault has since moved past. /// internal string State => Grant.State switch { VaultGrantState.Revoked => "withdrawn — blocks future reads only", VaultGrantState.AwaitingRewrap => "needs wrapping again — their key changed", _ when Grant.KeyGeneration < VaultGeneration => "stale — wrapped to an older key, opens nothing", _ => "holds a key", }; /// Whether this row still represents somebody who can read the vault. internal bool IsLive => Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration; } /// One invitation, as a row under the members it will join. internal sealed record TeamInvitationRowViewModel(TeamInvitationSummary Invitation) { internal Guid InvitationId => Invitation.InvitationId; internal string Email => Invitation.Email; internal string Role => Invitation.Role.ToString().ToUpperInvariant(); /// /// What has become of it, said as a sentence rather than a status word. /// /// /// The pending case has to carry the whole mechanism, because there is nothing else on this screen /// that could: nothing was sent, so somebody reading "invited" would reasonably wait for an email /// that is never coming. /// internal string State => Invitation.State switch { TeamInvitationState.Accepted => "joined", TeamInvitationState.Revoked => "withdrawn", TeamInvitationState.Expired => "expired — invite them again if they still need it", _ => "waiting — they join when they first sign in here. Nothing was sent; tell them yourself.", }; /// Whether this invitation can still be withdrawn. internal bool IsPending => Invitation.State == TeamInvitationState.Pending; } /// /// A destructive team operation, armed and waiting to be confirmed. /// /// /// The armed-state idiom the vault 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 id is 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. /// /// The team the action is aimed at. /// The member it is aimed at, for a transfer. /// What is being asked. /// What will actually happen, stated honestly. internal sealed record TeamActionRequest( Guid TeamId, Guid MemberId, string Question, string Consequence); /// One vault of the selected team, with what this account can do to it. internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired) { /// What the row says about itself. /// /// The unreadable case is the one that has to read clearly, because it is normal rather than broken: /// somebody has been added to a team and nobody has wrapped the vault key to them yet. /// internal string State => (IsReadable, RekeyRequired) switch { (false, _) => "waiting for a key — ask a member who has one to share it", (true, true) => "readable · a rekey is owed after a membership change", _ => "readable", }; } /// /// The teams screen: who is in a team, what they may do, and which vaults they hold a key to. /// /// /// /// Two separate acts, and the screen is built around saying so. Adding somebody to a team is a /// server-side authorization change and takes effect immediately. Giving them a 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 TeamService and ADR 0001. /// /// /// Nothing on this screen is cached across a lock. It reads the server on open and after each change, /// because membership is not vault content and has no local mirror — a team list in the encrypted cache /// would be a second copy of something the server is authoritative for. /// /// internal sealed partial class TeamsViewModel( Func connection, Func session) : ObservableObject { /// Teams this account belongs to. internal ObservableCollection Teams { get; } = []; /// Members of the selected team. internal ObservableCollection Members { get; } = []; /// Vaults the selected team owns, as far as this account can see them. internal ObservableCollection Vaults { get; } = []; /// Who holds a key to the selected vault. /// /// Read from the server rather than from the session, and it is the one list on this screen that /// has to be: the keyring can only answer whether this machine can open a vault, and this /// question is about everybody else. /// internal ObservableCollection Grants { get; } = []; /// Invitations to addresses that are not accounts here yet. internal ObservableCollection Invitations { get; } = []; [ObservableProperty] private TeamRowViewModel? selectedTeam; [ObservableProperty] private TeamMemberRowViewModel? selectedMember; [ObservableProperty] private TeamVaultRowViewModel? selectedVault; [ObservableProperty] private TeamInvitationRowViewModel? selectedInvitation; [ObservableProperty] private string status = string.Empty; [ObservableProperty] private bool isBusy; // ---- Creating a team ---- [ObservableProperty] private bool isCreatingTeam; [ObservableProperty] private string newTeamName = string.Empty; [ObservableProperty] private string newTeamSlug = string.Empty; // ---- Renaming a team ---- [ObservableProperty] private bool isEditingTeam; [ObservableProperty] private string editTeamName = string.Empty; [ObservableProperty] private string editTeamDescription = string.Empty; // ---- Adding a member ---- [ObservableProperty] private string inviteEmail = string.Empty; /// /// The role a newly added or invited account gets. /// /// /// Member by default, which is the role somebody adding a colleague almost always means. Viewer /// would be safer and would be the wrong default: an interface whose default is wrong teaches /// people to change it without reading it. /// [ObservableProperty] private TeamMemberRole newMemberRole = TeamMemberRole.Member; // ---- Confirming something that cannot be undone ---- [ObservableProperty] private TeamActionRequest? pendingAction; /// Whether there is a server to talk to at all. internal bool IsOnline => connection() is not null; /// Whether the selected team can be administered by this account. internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true; /// Whether this account owns the selected team. /// /// A narrower gate than , and the server draws the same line: /// archiving a team and handing it over decide whether it goes on existing and who controls it, /// so an admin the owner promoted must not be able to do either. /// internal bool OwnsSelected => SelectedTeam?.Team.Role == TeamMemberRole.Owner; /// Whether there is anything to show below the team list. internal bool HasSelection => SelectedTeam is not null; internal bool HasTeams => Teams.Count > 0; /// Whether a destructive action is armed and waiting for an answer. internal bool IsConfirming => PendingAction is not null; /// Whether the ordinary team buttons should be showing. /// /// The inverse of , so the confirmation replaces the buttons that armed /// it rather than appearing beneath them still pressable. /// internal bool ShowsTeamActions => !IsConfirming; /// Whether the selected team has any invitation worth drawing a list for. internal bool HasInvitations => Invitations.Count > 0; internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer; internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member; internal bool AddsAsAdmin => NewMemberRole == TeamMemberRole.Admin; /// Reads the teams this account belongs to, and the selected one's detail. internal Task LoadAsync(CancellationToken cancellationToken) => RunAsync(() => ReloadAsync(cancellationToken)); /// Reads it all again. /// /// The same work as , exposed as a command because markup cannot invoke a /// method. The phone needs it and the desktop does not: this screen is loaded on arrival, and on /// the desktop leaving the rail and coming back is one click, where on the phone it is a trip out /// to MORE and back. Nothing on this screen is cached, so a re-read is the only way to see a change /// somebody else made. /// [RelayCommand] private Task RefreshAsync(CancellationToken cancellationToken) => RunAsync(() => ReloadAsync(cancellationToken)); /// /// The reload itself, without the busy gate. /// /// /// Separate from because every command ends by reloading, and a command that /// called the gated version would find the gate held by itself and skip the reload silently — leaving /// a team that was created moments ago missing from the list it was just added to. /// private async Task ReloadAsync(CancellationToken cancellationToken) { if (connection() is not { } server) { Teams.Clear(); Members.Clear(); Vaults.Clear(); RaiseState(); Status = "Offline. Teams are read from the server, so this screen needs a connection."; return; } var selectedId = SelectedTeam?.TeamId; var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true); Teams.Clear(); foreach (var team in teams) { Teams.Add(new TeamRowViewModel(team)); } SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault(); RaiseState(); await LoadSelectedAsync(cancellationToken).ConfigureAwait(true); Status = Teams.Count == 0 ? "You are not in a team yet. Create one to share hosts and credentials with colleagues." : string.Empty; } /// Opens the create-a-team form. [RelayCommand] private void NewTeam() { NewTeamName = string.Empty; NewTeamSlug = string.Empty; IsCreatingTeam = true; Status = string.Empty; } /// Abandons the create-a-team form. [RelayCommand] private void CancelNewTeam() { IsCreatingTeam = false; Status = string.Empty; } /// Creates a team, with this account as its owner. /// /// The id is generated here, which is what makes a create whose response was lost safe to send again — /// the server treats an identical repeat as the same team rather than a second one. /// [RelayCommand] private async Task CreateTeamAsync(CancellationToken cancellationToken) { if (connection() is not { } server) { Status = "Offline. Creating a team needs a connection."; return; } var name = NewTeamName.Trim(); var slug = NewTeamSlug.Trim().ToLowerInvariant(); if (name.Length == 0 || slug.Length == 0) { Status = "A team needs a name and a slug."; return; } await RunAsync(async () => { var created = await server.Teams .CreateTeamAsync( new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken) .ConfigureAwait(true); IsCreatingTeam = false; await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam; Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with " + "whoever needs it."; }).ConfigureAwait(true); } /// /// Adds a member, by looking their address up in the directory first. /// /// /// Two calls rather than one, and the order is the point: the directory is what turns an address into /// an account and a public key, and the key that gets verified before any sharing is the one that /// lookup returned. Letting the server resolve an address to an account inside the add would put an /// unwitnessed step between the two. /// [RelayCommand] private async Task AddMemberAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedTeam is not { } team) { return; } var email = InviteEmail.Trim(); if (email.Length == 0) { Status = "Type the email address of somebody who has signed in to this server."; return; } await RunAsync(async () => { var found = await server.Directory.LookupByEmailAsync(email, cancellationToken) .ConfigureAwait(true); if (found.Count == 0) { await InviteAsync(server, team, email, cancellationToken).ConfigureAwait(true); return; } var member = await server.Teams .AddTeamMemberAsync( team.TeamId, new AddTeamMemberRequest(found[0].UserId, NewMemberRole), cancellationToken) .ConfigureAwait(true); InviteEmail = string.Empty; await ReloadAsync(cancellationToken).ConfigureAwait(true); // Said out loud, every time. The single most common misunderstanding this design invites is // that adding somebody gave them the vault. Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They " + "cannot read anything yet — select a vault below and share its key."; }).ConfigureAwait(true); } /// /// Invites an address the directory does not know. /// /// /// /// Reached by falling through from rather than from a second button, /// because the person typing an address does not know or care which of the two applies — that is a /// fact about the server's account table, not about what they are trying to do. Which one happened /// is reported afterwards, because the difference decides what they have to do next. /// /// /// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound /// mail — so somebody who reads "invited" and waits has been misled by an interface that knew /// better. /// /// private async Task InviteAsync( IVaultServer server, TeamRowViewModel team, string email, CancellationToken cancellationToken) { var invitation = await server.Teams .CreateTeamInvitationAsync( team.TeamId, new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole), cancellationToken) .ConfigureAwait(true); InviteEmail = string.Empty; await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"No account here has the address '{email}' yet, so it has been invited instead. " + $"They join this team as {invitation.Role.ToString().ToLowerInvariant()} the first time " + "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — " + "and their identity provider has to confirm the address is theirs."; } /// Withdraws an invitation that has not been taken up. [RelayCommand] private async Task RevokeInvitationAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedTeam is not { } team || SelectedInvitation is not { } invitation) { return; } await RunAsync(async () => { var revoked = await server.Teams .RevokeTeamInvitationAsync(team.TeamId, invitation.InvitationId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = revoked ? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them " + "in this team." : $"The invitation to {invitation.Email} was already taken up or withdrawn. If they " + "are a member now, remove them instead."; }).ConfigureAwait(true); } /// Picks the role a newly added or invited account will get. [RelayCommand] private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role; /// Changes the selected member's role. /// /// Owner is not offered, and the command refuses it rather than relying on the view not to send /// it: the server refuses it too, and a button that produced a server error would be reporting a /// rule the interface should have known. /// [RelayCommand] private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken) { if (connection() is not { } server || SelectedTeam is not { } team || 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( team.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); } /// Opens the rename form for the selected team. [RelayCommand] private void RenameTeam() { if (SelectedTeam is not { } team) { return; } EditTeamName = team.Name; EditTeamDescription = team.Team.Description ?? string.Empty; IsEditingTeam = true; Status = string.Empty; } /// Abandons the rename form. [RelayCommand] private void CancelRenameTeam() { IsEditingTeam = false; Status = string.Empty; } /// Saves the renamed team. [RelayCommand] private async Task SaveTeamAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedTeam is not { } team) { return; } var name = EditTeamName.Trim(); if (name.Length == 0) { Status = "A team needs a name."; return; } var description = EditTeamDescription.Trim(); await RunAsync(async () => { await server.Teams .UpdateTeamAsync( team.TeamId, new UpdateTeamRequest(name, description.Length == 0 ? null : description), cancellationToken) .ConfigureAwait(true); IsEditingTeam = false; await ReloadAsync(cancellationToken).ConfigureAwait(true); // The slug is named because it did not change and somebody expecting it to would // otherwise find out from a URL much later. Status = $"Renamed to '{name}'. Its slug is still '{team.Slug}' — that is what URLs and " + "the server's own records use, and it does not change."; }).ConfigureAwait(true); } /// Arms the archive confirmation for the selected team. [RelayCommand] private void ArchiveTeam() { if (SelectedTeam is not { } team) { return; } PendingAction = new TeamActionRequest( team.TeamId, Guid.Empty, $"Archive '{team.Name}'?", "Everybody loses sight of it at once, and only somebody with database access can bring it " + "back. It is refused outright if the team still owns any vault."); } /// Arms the hand-over confirmation for the selected member. [RelayCommand] private void TransferOwnership() { if (SelectedTeam is not { } team || SelectedMember is not { } member) { return; } if (member.IsSelf) { Status = "You already own this team."; return; } PendingAction = new TeamActionRequest( team.TeamId, member.UserId, $"Hand '{team.Name}' to {member.Name}?", "They become the owner and you become an admin. You will not be able to take it back " + "yourself — only the new owner can hand it on."); } /// Cancels an armed action. [RelayCommand] private void CancelAction() => PendingAction = null; /// /// Carries out whichever action was armed. /// /// /// Disarmed before the work rather than after it, so the card goes the moment it is answered and a /// second press during a slow round trip has nothing left to agree to. /// [RelayCommand] private async Task ConfirmActionAsync(CancellationToken cancellationToken) { if (connection() is not { } server || PendingAction is not { } request) { return; } PendingAction = null; await RunAsync(async () => { if (request.MemberId == Guid.Empty) { var archived = await server.Teams .ArchiveTeamAsync(request.TeamId, cancellationToken) .ConfigureAwait(true); SelectedTeam = null; await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = archived ? "Archived. It is gone from everybody's list; the rows are still in the database " + "and only an operator can bring them back." : "There was no such team to archive."; return; } 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 team now, and only its new owner can hand " + "it on again."; }).ConfigureAwait(true); } /// Removes a member, revoking every vault key grant they hold from this team. [RelayCommand] private async Task RemoveMemberAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedTeam is not { } team || SelectedMember is not { } member) { return; } await RunAsync(async () => { await server.Teams .RemoveTeamMemberAsync(team.TeamId, member.UserId, 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. Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything " + "they had already downloaded is still on their machine — rotate the credentials that " + "matter."; }).ConfigureAwait(true); } /// Creates a vault owned by the selected team. [RelayCommand] private async Task CreateVaultAsync(CancellationToken cancellationToken) { if (connection() is not { } server || session() is not { } open || SelectedTeam is not { } team) { return; } await RunAsync(async () => { var vault = await open .CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new " + "hosts and credentials can be filed into it from the Vault screen."; }).ConfigureAwait(true); } /// /// Wraps the selected vault's key to the selected member. /// /// /// Everything that makes this safe happens inside : the key /// log is read and its chain verified, and the directory's answer has to appear in it unchanged before /// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because /// the reasons are not interchangeable — one of them means somebody is substituting keys. /// [RelayCommand] private async Task ShareVaultAsync(CancellationToken cancellationToken) { if (connection() is not { } server || session() is not { } open || SelectedVault is not { } vault || SelectedMember is not { } member) { return; } if (member.IsSelf) { Status = "You already hold this vault's key."; return; } await RunAsync(async () => { var outcome = await open .ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken) .ConfigureAwait(true); Status = outcome.Shared ? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}" : $"Did not share '{vault.Name}': {outcome.Message}"; }).ConfigureAwait(true); } /// Withdraws the selected member's key to the selected vault. [RelayCommand] private async Task RevokeVaultAsync(CancellationToken cancellationToken) { if (connection() is not { } server || SelectedVault is not { } vault || SelectedMember is not { } member) { return; } await RunAsync(async () => { var revoked = await server.Grants .RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = revoked ? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they " + "already have is unaffected." : $"{member.Name} held no key to '{vault.Name}'."; }).ConfigureAwait(true); } partial void OnSelectedTeamChanged(TeamRowViewModel? value) { RaiseState(); // An armed confirmation names the team it was armed for, so a selection change has to disarm // it — otherwise the card stays on screen above a different team and reads as being about it. PendingAction = null; IsEditingTeam = false; // 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. Failures land in Status // through RunAsync exactly as a command's would. _ = LoadSelectedAsync(CancellationToken.None); } partial void OnPendingActionChanged(TeamActionRequest? value) { OnPropertyChanged(nameof(IsConfirming)); OnPropertyChanged(nameof(ShowsTeamActions)); } partial void OnNewMemberRoleChanged(TeamMemberRole value) { OnPropertyChanged(nameof(AddsAsViewer)); OnPropertyChanged(nameof(AddsAsMember)); OnPropertyChanged(nameof(AddsAsAdmin)); } /// /// The grants list belongs to a vault rather than to a team, so it is reloaded on selection here /// rather than in — which would leave it showing the previous /// vault's key-holders after a click. /// partial void OnSelectedVaultChanged(TeamVaultRowViewModel? value) => _ = LoadGrantsAsync(CancellationToken.None); /// Reads who holds a key to the selected vault. private async Task LoadGrantsAsync(CancellationToken cancellationToken) { Grants.Clear(); if (connection() is not { } server || SelectedVault is not { } vault) { return; } await RunAsync(async () => { var response = await server.Grants .ListVaultGrantsAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); Grants.Clear(); foreach (var grant in response.Grants) { Grants.Add(new TeamGrantRowViewModel(grant, response.KeyGeneration)); } }).ConfigureAwait(true); } /// Reads the selected team's members, invitations and vaults. private async Task LoadSelectedAsync(CancellationToken cancellationToken) { Members.Clear(); Invitations.Clear(); Vaults.Clear(); Grants.Clear(); if (connection() is not { } server || SelectedTeam is not { } team) { return; } var open = session(); var selfId = open?.Profile.UserId; var members = await server.Teams .ListTeamMembersAsync(team.TeamId, cancellationToken) .ConfigureAwait(true); foreach (var member in members) { Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId)); } var invitations = await server.Teams .ListTeamInvitationsAsync(team.TeamId, cancellationToken) .ConfigureAwait(true); foreach (var invitation in invitations) { Invitations.Add(new TeamInvitationRowViewModel(invitation)); } SelectedInvitation = Invitations.FirstOrDefault(row => row.IsPending); OnPropertyChanged(nameof(HasInvitations)); if (open is null) { return; } // Read from the session rather than from a team-vaults endpoint, because the interesting fact // about a team vault here is whether *this* machine can open it — which is a property of the // keyring and not something the server can answer. var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet(); foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId)) { Vaults.Add(new TeamVaultRowViewModel( vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired)); } SelectedVault = Vaults.FirstOrDefault(); } private void RaiseState() { OnPropertyChanged(nameof(HasTeams)); OnPropertyChanged(nameof(HasSelection)); OnPropertyChanged(nameof(CanAdministerSelected)); OnPropertyChanged(nameof(OwnsSelected)); OnPropertyChanged(nameof(HasInvitations)); OnPropertyChanged(nameof(IsOnline)); } /// /// One place that raises the busy flag and turns a failure into a sentence. An API exception's message /// is the server's problem detail, which is written for a person to read — see Problems — so it /// is shown rather than replaced with something vaguer. /// private async Task RunAsync(Func work) { if (IsBusy) { return; } IsBusy = true; try { await work().ConfigureAwait(true); } catch (DodoSshApiException exception) { Status = exception.Message; } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { Status = exception.Message; } finally { IsBusy = false; } } }