Let a team change hands, and be joined by somebody with no account yet

M3 built teams and stopped short of the two operations that decide who
controls one. Both were written down as refusals rather than omissions:
ADR 0009 listed ownership transfer under "deliberately not built", and
design-import-gaps said an invitation needed "a token with a lifetime and an
outbound mail path". One of those reasons had expired and the other never
applied — an invitation does not need a token if it is not a thing anybody
presents.

Handing a team over is one write. The member you name becomes owner and you
become an admin, in a single transaction, because ownership is sole: promoting
first leaves the team owned twice, demoting first leaves it owned by nobody,
and there is nobody left with the authority to finish a transfer that stopped
in the middle. That is also why it is not two calls to the role endpoint, which
refuses Owner outright. The outgoing owner is demoted rather than removed —
removing them would revoke their vault key grants and flag every team vault for
rekey, which is a far larger act than the one asked for, and somebody handing
over a team is usually staying in it. It unblocks the thing that was impossible
before: an owner can now leave, by handing the team on first.

An invitation is a standing instruction rather than a message. This server has
no outbound mail path, so nothing is sent and there is nothing for the invitee
to present. The row says the next account signing in with that address joins
this team at this role, and telling them to sign in is the caller's job over a
channel this server does not carry. A link nobody can deliver would be worse
than none. It lives in its own table rather than becoming a membership with
MembershipStatus.Invited, and that member stays unwritten for the reason it
always was: team_membership.user_id is not nullable and carries a foreign key,
so somebody who has never signed in has nothing for that row to point at.
Widening it would make the unique index on (team, user) meaningless, because
PostgreSQL counts every NULL as distinct.

Verification is the security boundary, and nothing in this server read it
before. A claim requires the access token to assert email_verified. An
invitation decides what the server will serve, so one claimable by anybody able
to obtain a token carrying somebody else's address is a way into a team — which
is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it
would have been reintroduced by the back door. There is deliberately no setting
that relaxes it: a flag that exists is one somebody turns on for the afternoon
their provider is misconfigured. Absence is refused rather than trusted, and
logged, because a provider that never sends the claim otherwise leaves every
invitation pending with nothing anywhere saying why.

Claiming happens at just-in-time provisioning and again on an hourly sweep. The
sweep is what makes it recoverable rather than one-shot — an invitation issued
between an account being created and that person next signing in would
otherwise be stranded for ever — and it shares its rate with the last-seen
write because both are housekeeping nobody is waiting on.

Archiving is refused while a team owns a vault, and that refusal is the end of
the road rather than a step on it. A team vault is readable because of
membership, so archiving one that still owned vaults would take them away from
everybody holding a key, including the caller, quietly and all at once. Nothing
in this product deletes a vault, so no order of operations gets past it today —
which is stated with a count of what is in the way, for the reason the SFTP
layer refuses a recursive delete: a refusal is visible and a quiet removal is
not. It is owner-only, as handing over is; renaming is not, because a rename is
visible to everybody and reversible by anybody who can do it. The slug is not
renameable at all: it is unique only among live teams, so a rename could take
one an archived team is still holding, and that team could then never be
restored.

LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is
refreshed on ordinary authenticated requests, at most once per account per
hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency
token, so a read-then-write on the hot path would start losing races between
one user's own overlapping requests. An hour is the granularity the question is
actually asked at, and the interface draws it to the day rather than the minute
so it does not read as a precision that is not there. The remarks in Contracts
and in the view model that argued at length for the column's absence are
rewritten rather than extended; both had become false.

Two endpoints already existed and nothing called them. ChangeTeamMemberRole and
ListVaultGrants have been reachable since M3. The role picker refuses Owner
itself rather than letting the server do it, since the interface already knew
the rule; the key-holder list sits under the vault rather than beside the
member, because a grant is per vault and a count on a member row would imply
per-item sharing, which is M5. It lists withdrawn and stale grants and says
which they are — a list that dropped them would show a departed colleague as
merely absent rather than as somebody whose key was taken away — and staleness
is decided by comparing generations, since a grant can be Active and still open
nothing.

ADD MEMBER stopped being a dead end. An address the directory did not know used
to end at a sentence telling the user their colleague had to sign in first. It
invites them instead, from the same button, because which of the two applies is
a fact about the server's account table rather than about what the user is
doing; which one happened is reported afterwards, because that decides what
they do next. An address that merely has an account is invited rather than
refused: refusing would have made the endpoint an oracle for which addresses
have accounts here, answerable by anybody willing to create a team first.

The phone has a TEAMS screen, behind MORE, and it is the reverse of every other
row in design-import-gaps: a shipped screen the design had no slot for. It is
there because an invitation is claimed by signing in, so somebody told they are
now in a team is at least as likely to be holding a phone — and a membership
visible only on a head they never installed is one they cannot see. It draws
SHARE KEY and nothing that takes something away: wrapping a key is the one act
on that screen a server cannot perform at all, and the desktop guards its
revocations with a tooltip, which is a control a touch screen cannot show.

Two defects were found by an adversarial pass and both were green against the
whole suite at the time. The owner-only check on archiving and handing over had
been weakened to the admin check while their messages and comments still said
owner — and since nothing behind the archive endpoint re-checks it, an admin
the owner had promoted could have archived the team out from under them. And
the rename endpoint built its response with a hardcoded Owner role, so an admin
who renamed a team was handed a summary claiming they owned it, and a client
trusting that instead of re-listing would have offered them the two owner-only
buttons the server then refuses.

The new table gets its constraints tested rather than merely migrated: live
uniqueness per (team, address), the citext proof that an address typed by a
person matches one cased by a provider, and reissue after both revocation and
acceptance. The teams screen gets its first entries in the layout suite, at the
minimum window with every list populated and with each of the two states that
cover half of it — it had none, and it just grew four sections and a second
line in the member row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:31:43 +02:00
co-authored by Claude Opus 5
parent 16e0051e89
commit a43286ece8
43 changed files with 6974 additions and 147 deletions
@@ -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>
@@ -781,14 +781,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));
}