Merge branch 'claude/adding-hosts-phone-docs-e472b0'
ci / build and test (push) Successful in 1m16s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 45s

This commit is contained in:
2026-08-03 14:41:18 +02:00
43 changed files with 6976 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>
@@ -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));
}