Public Access
Merge branch 'claude/adding-hosts-phone-docs-e472b0'
This commit is contained in:
@@ -73,7 +73,7 @@ internal enum ShellScreen
|
||||
/// <summary>Everything in the vault that is not a host.</summary>
|
||||
Vault = 2,
|
||||
|
||||
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
|
||||
/// <summary>Shared vaults and the people in them. Both heads draw it.</summary>
|
||||
Team = 3,
|
||||
|
||||
/// <summary>Preferences.</summary>
|
||||
@@ -777,14 +777,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of
|
||||
/// its destinations would be a bar that only ever lights three of its four entries. This is the one
|
||||
/// place where "which tab" and "which screen" are deliberately not the same question — the other three
|
||||
/// tabs are each exactly one screen, and this one is six.
|
||||
/// tabs are each exactly one screen, and this one is seven.
|
||||
///
|
||||
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
|
||||
/// the rail and never asks this.
|
||||
/// the rail and never asks this. <see cref="ShellScreen.Team"/> is in it for the same reason and no
|
||||
/// other: the desktop has a rail entry for teams and the phone reaches them through MORE, so a screen
|
||||
/// missing here is one whose arrival darkens the tab that led to it and brings the shell's own header
|
||||
/// back over a screen that already has one.
|
||||
/// </remarks>
|
||||
internal bool IsMoreSurface =>
|
||||
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
|
||||
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences;
|
||||
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
|
||||
or ShellScreen.Team;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's WebView may be on screen at this instant.
|
||||
|
||||
@@ -50,18 +50,119 @@ internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsS
|
||||
/// What the account can be given, in one phrase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a two-factor column, not a last-active column. The server records neither: there is no
|
||||
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
|
||||
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
|
||||
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
|
||||
/// 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 a 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>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 TeamGrantRowViewModel(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>One invitation, as a row under the members it will join.</summary>
|
||||
internal sealed record TeamInvitationRowViewModel(TeamInvitationSummary Invitation)
|
||||
{
|
||||
internal Guid InvitationId => Invitation.InvitationId;
|
||||
|
||||
internal string Email => Invitation.Email;
|
||||
|
||||
internal string Role => Invitation.Role.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// What has become of it, said as a sentence rather than a status word.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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.",
|
||||
};
|
||||
|
||||
/// <summary>Whether this invitation can still be withdrawn.</summary>
|
||||
internal bool IsPending => Invitation.State == TeamInvitationState.Pending;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A destructive team operation, armed and waiting to be confirmed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="TeamId">The team the action is aimed at.</param>
|
||||
/// <param name="MemberId">The member it is aimed at, for a transfer.</param>
|
||||
/// <param name="Question">What is being asked.</param>
|
||||
/// <param name="Consequence">What will actually happen, stated honestly.</param>
|
||||
internal sealed record TeamActionRequest(
|
||||
Guid TeamId,
|
||||
Guid MemberId,
|
||||
string Question,
|
||||
string Consequence);
|
||||
|
||||
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
|
||||
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
|
||||
{
|
||||
@@ -108,6 +209,17 @@ internal sealed partial class TeamsViewModel(
|
||||
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
|
||||
internal ObservableCollection<TeamVaultRowViewModel> Vaults { 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<TeamGrantRowViewModel> Grants { get; } = [];
|
||||
|
||||
/// <summary>Invitations to addresses that are not accounts here yet.</summary>
|
||||
internal ObservableCollection<TeamInvitationRowViewModel> Invitations { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamRowViewModel? selectedTeam;
|
||||
|
||||
@@ -117,6 +229,9 @@ internal sealed partial class TeamsViewModel(
|
||||
[ObservableProperty]
|
||||
private TeamVaultRowViewModel? selectedVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamInvitationRowViewModel? selectedInvitation;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
@@ -134,26 +249,92 @@ internal sealed partial class TeamsViewModel(
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// The role a newly added or invited 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 TeamActionRequest? pendingAction;
|
||||
|
||||
/// <summary>Whether there is a server to talk to at all.</summary>
|
||||
internal bool IsOnline => connection() is not null;
|
||||
|
||||
/// <summary>Whether the selected team can be administered by this account.</summary>
|
||||
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
|
||||
|
||||
/// <summary>Whether this account owns the selected team.</summary>
|
||||
/// <remarks>
|
||||
/// A narrower gate than <see cref="CanAdministerSelected"/>, 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.
|
||||
/// </remarks>
|
||||
internal bool OwnsSelected => SelectedTeam?.Team.Role == TeamMemberRole.Owner;
|
||||
|
||||
/// <summary>Whether there is anything to show below the team list.</summary>
|
||||
internal bool HasSelection => SelectedTeam is not null;
|
||||
|
||||
internal bool HasTeams => Teams.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 team 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 ShowsTeamActions => !IsConfirming;
|
||||
|
||||
/// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
|
||||
internal bool HasInvitations => Invitations.Count > 0;
|
||||
|
||||
internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;
|
||||
|
||||
internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member;
|
||||
|
||||
internal bool AddsAsAdmin => NewMemberRole == TeamMemberRole.Admin;
|
||||
|
||||
/// <summary>Reads the teams this account belongs to, 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 on this screen 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>
|
||||
@@ -289,16 +470,14 @@ internal sealed partial class TeamsViewModel(
|
||||
|
||||
if (found.Count == 0)
|
||||
{
|
||||
Status = $"No account here has the address '{email}'. They have to sign in to this "
|
||||
+ "server once before they can be added — that is what publishes the key a vault "
|
||||
+ "would be shared with.";
|
||||
await InviteAsync(server, team, email, cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var member = await server.Teams
|
||||
.AddTeamMemberAsync(
|
||||
team.TeamId,
|
||||
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
|
||||
new AddTeamMemberRequest(found[0].UserId, NewMemberRole),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
@@ -313,6 +492,282 @@ internal sealed partial class TeamsViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invites an address the directory does not know.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Reached by falling through from <see cref="AddMemberAsync"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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.";
|
||||
}
|
||||
|
||||
/// <summary>Withdraws an invitation that has not been taken up.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Picks the role a newly added or invited 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
|
||||
|| 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);
|
||||
}
|
||||
|
||||
/// <summary>Opens the rename form for the selected team.</summary>
|
||||
[RelayCommand]
|
||||
private void RenameTeam()
|
||||
{
|
||||
if (SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EditTeamName = team.Name;
|
||||
EditTeamDescription = team.Team.Description ?? string.Empty;
|
||||
IsEditingTeam = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the rename form.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelRenameTeam()
|
||||
{
|
||||
IsEditingTeam = false;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Saves the renamed team.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Arms the archive confirmation for the selected team.</summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>Arms the hand-over confirmation for the selected member.</summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <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(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);
|
||||
}
|
||||
|
||||
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
||||
@@ -432,17 +887,70 @@ internal sealed partial class TeamsViewModel(
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Reads the selected team's members and vaults.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The grants list belongs to a vault rather than to a team, so it is reloaded on selection here
|
||||
/// rather than in <see cref="LoadSelectedAsync"/> — which would leave it showing the previous
|
||||
/// vault's key-holders after a click.
|
||||
/// </remarks>
|
||||
partial void OnSelectedVaultChanged(TeamVaultRowViewModel? value) =>
|
||||
_ = LoadGrantsAsync(CancellationToken.None);
|
||||
|
||||
/// <summary>Reads who holds a key to the selected vault.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Reads the selected team's members, invitations and vaults.</summary>
|
||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Members.Clear();
|
||||
Invitations.Clear();
|
||||
Vaults.Clear();
|
||||
Grants.Clear();
|
||||
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
@@ -461,6 +969,19 @@ internal sealed partial class TeamsViewModel(
|
||||
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;
|
||||
@@ -485,6 +1006,8 @@ internal sealed partial class TeamsViewModel(
|
||||
OnPropertyChanged(nameof(HasTeams));
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanAdministerSelected));
|
||||
OnPropertyChanged(nameof(OwnsSelected));
|
||||
OnPropertyChanged(nameof(HasInvitations));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user