Merge main into the desktop redesign branch
ci / build and test (push) Successful in 1m26s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 20s

Four conflicts. Three were two people adding to the same spot, and one was a
real collision: main gave the connect bar a REMEMBER tick in the same pass that
this branch took the connect bar off the hosts screen.

REMEMBER is now in the drawer, beside the password box it qualifies. Nothing
about the feature changed — RemembersConnectPassword, its refusal to fire until
the remote has accepted the password, and the six tests over it are main's
untouched — only where it is drawn. The move improves it slightly and it is
worth saying why rather than claiming a merge was neutral: the bar had one row
and had to fit the box, the tick, the authentication note and CONNECT along it,
which is why the tick was a bare "REMEMBER" in tracked capitals. A column has
room to put it under the box as a sentence, where it reads as a property of the
password rather than as a fourth control in a row.

MainWindowViewModel: both sides added members after ShowTerminal — the desktop's
three fixed tabs here, the phone's connect menu on main. They do not interact,
so both are kept, each under its own heading.

TeamsScreen: main added the team's own RENAME, HAND OVER and ARCHIVE, a member's
LastActive, the role a new member arrives as, and the KEY HOLDERS list. This
branch had only bumped the file's font sizes a point. Resolved by taking main's
file whole and re-running the bump over it, so the new controls join the scale
rather than sitting a point below everything around them.

README: both sides described a different head's third pass in the same
paragraph. Both kept.

Two things checked rather than assumed, because this branch moved the furniture
the merged commits sit on. The chrome heights main's terminal work touched are
the phone's, not the desktop's — 44, 42 and 24 are unchanged, so the layout
harness's budget still describes the window. And main's keychain DELETE did not
reach VaultScreen.axaml, whose header this branch rearranged, so the five
buttons that overflowed at the larger type are still five.

2415 tests pass, up from 2369 by the 46 main brought.
This commit is contained in:
2026-08-03 15:17:55 +02:00
62 changed files with 8175 additions and 413 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>
@@ -418,25 +418,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </summary>
/// <remarks>
/// <para>
/// Two defaults, because the two audiences never overlap. A release build is installed by somebody
/// signing in to the hosted deployment, and typing its address is the only thing standing between
/// them and a working application. A debug build is run from a clone, next to
/// <c>dotnet run --project src/DodoSSH.Api</c>, and shipping the hosted address there would point
/// every development launch at production — which is worse than an inconvenience, since sign-in is
/// the step that enrolls a device.
/// </para>
/// <para>
/// Note the schemes. <c>http</c> locally is not an oversight: the API's first launch profile — the
/// one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing an
/// HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
/// One default for every build. The hosted deployment is what all but a handful of launches are
/// aiming at, and typing its address is the only thing standing between an installed application and
/// a working one. Running against a clone means replacing this with
/// <c>http://localhost:5233</c> by hand — note the scheme, because the API's first launch profile —
/// the one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing
/// an HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
/// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
/// </para>
/// <para>
/// This was once split on <c>DEBUG</c> so a development launch could not enroll a device against
/// production by accident. That protection is gone: a debug build now offers the hosted address like
/// any other, and the first sign-in accepted unread lands there.
/// </para>
/// </remarks>
#if DEBUG
internal const string DefaultServerUrl = "http://localhost:5233";
#else
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
#endif
[ObservableProperty]
private string serverUrl = DefaultServerUrl;
@@ -781,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.
@@ -799,8 +799,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
/// quick-connect palette.
/// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
/// palette, and the phone's connect sheet.
/// </para>
/// <para>
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
/// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
/// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
/// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
/// sheet floating over the page underneath. So only the renderer's rectangle is given up.
/// </para>
/// <para>
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
@@ -829,7 +836,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
internal bool IsTerminalShowing =>
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
/// <summary>
/// Whether the terminal half of the window is the half being shown, pane or no pane.
@@ -945,6 +953,54 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void ShowVaults() => ShowScreen(vaultsScreen);
// ---- The phone's connect menu ----
/// <summary>
/// Whether the phone's connect menu is open over the terminal.
/// </summary>
/// <remarks>
/// <para>
/// Drawn by the Android head alone, and shell state rather than something that view could hold on its
/// own for the reason <see cref="IsSearching"/> is: it has to collapse the renderer while it is up. See
/// <see cref="IsTerminalShowing"/>.
/// </para>
/// <para>
/// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
/// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
/// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
/// doing". The menu is that, and every entry on it is one of the two navigation commands above.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isConnectSheetOpen;
/// <summary>Raises the connect menu over the terminal.</summary>
/// <remarks>
/// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
/// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
/// holding a terminal hidden that nothing would put back.
/// </remarks>
[RelayCommand]
private void OpenConnectSheet()
{
if (!IsTerminalSurface)
{
return;
}
IsConnectSheetOpen = true;
}
/// <summary>Lowers the connect menu, leaving the terminal where it was.</summary>
/// <remarks>
/// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
/// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
/// — see <see cref="OnSurfaceChanged"/>, which is what makes "the sheet is only ever up over a terminal"
/// true of routes nobody wrote it for.
/// </remarks>
[RelayCommand]
private void CloseConnectSheet() => IsConnectSheetOpen = false;
// ---- Open terminals ----
/// <summary>
@@ -2595,7 +2651,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
/// <inheritdoc cref="OnScreenChanged" />
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
/// <remarks>
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
/// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
/// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
/// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
/// terminal would draw the sheet again over a rectangle held blank by it.
/// </remarks>
partial void OnSurfaceChanged(ShellSurface value)
{
if (value is not ShellSurface.Terminal)
{
IsConnectSheetOpen = false;
}
RaiseSurfaceState();
}
/// <remarks>
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
@@ -2659,6 +2730,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
/// <inheritdoc cref="OnIsSearchingChanged" />
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
/// <remarks>
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
/// <see cref="IsAskingForThePassphrase"/>.
@@ -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));
}
@@ -333,7 +333,13 @@ internal sealed partial class HostRowViewModel(
/// <summary>What a host can authenticate with.</summary>
internal enum AuthenticationKind
{
/// <summary>Typed at the moment of connecting, and never stored.</summary>
/// <summary>Typed at the moment of connecting.</summary>
/// <remarks>
/// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it
/// creates a credential and moves the host to <see cref="Credential"/>, so a stored password is always
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
/// </remarks>
Typed,
/// <summary>An SSH key in this vault.</summary>
@@ -2079,14 +2085,36 @@ internal sealed partial class VaultViewModel(
// ---- Connecting ----
/// <remarks>
/// Typed per connection, never persisted, and now only reached by a host bound to nothing. It stays because
/// not every password is worth storing — a one-off on a machine somebody will never open again, or one
/// they would rather this vault did not hold — and because a credential has to be created before it can be
/// bound, which means the first connection to a new host happens through this box.
/// Typed per connection, not persisted unless <see cref="RemembersConnectPassword"/> says otherwise, and
/// only reached by a host bound to nothing. It stays because not every password is worth storing — a
/// one-off on a machine somebody will never open again, or one they would rather this vault did not hold
/// — and because a credential has to be created before it can be bound, which means the first connection
/// to a new host happens through this box.
/// </remarks>
[ObservableProperty]
private string connectPassword = string.Empty;
/// <summary>
/// Whether a password typed here should be kept, so this host stops asking for it.
/// </summary>
/// <remarks>
/// <para>
/// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a
/// password can live. The two-step chore it replaces — add a password under Keychain, then open the host
/// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand
/// means typing the secret into a second screen while the first one already has it.
/// </para>
/// <para>
/// <b>Off by default, and it stays a decision.</b> The reason a typed password exists at all is that not
/// every password belongs in a synchronised vault; remembering silently would move each of them there and
/// tell nobody. It also only takes effect once the handshake has succeeded — see
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
/// the one not worth keeping.
/// </para>
/// </remarks>
[ObservableProperty]
private bool remembersConnectPassword;
/// <summary>
/// Whether the selected host will want something typed into the password box.
/// </summary>
@@ -5354,6 +5382,95 @@ internal sealed partial class VaultViewModel(
sessionId,
row.Label,
Dialled(row, authentication)));
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear.
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Turns the password that just worked into a keychain credential bound to this host.
/// </summary>
/// <remarks>
/// <para>
/// <b>Only after a handshake the remote accepted.</b> Storing a password the moment it is typed would
/// bind whatever was in the box — including the typo that is about to be refused — and the host would
/// then stop asking, leaving a machine that cannot be connected to until somebody works out that the
/// keychain is where the wrong password now lives.
/// </para>
/// <para>
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
/// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type
/// exists — bound to the other nineteen machines that share the account. See <see cref="HostSecret"/>
/// on why the binding is an id and not a copy.
/// </para>
/// <para>
/// The credential carries no username of its own, so it keeps taking the host's — which is what the
/// connection that just succeeded did. Copying the resolved username into it would pin whatever the
/// group happened to say at this moment, and quietly stop following the group afterwards.
/// </para>
/// <para>
/// Every failure is reported and swallowed. The caller's <c>catch</c> blocks describe a connection that
/// did not happen, and this one did: a vault write that fails here must not tell the user their terminal
/// was abandoned, and a cancellation must not report it as cancelled.
/// </para>
/// </remarks>
private async Task RememberTypedPasswordAsync(
HostRowViewModel row,
HostAuthentication authentication,
CancellationToken cancellationToken)
{
// The password as dialled, not as the box currently reads: the two can differ by now, because a
// handshake takes time and the box stays typeable throughout it.
if (!RemembersConnectPassword
|| row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword
|| authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed)
{
return;
}
if (row.IsReadOnly)
{
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
return;
}
var credential = new CredentialSecret { Label = row.Label, Password = typed.Password };
try
{
var credentialId = await session.Credentials
.CreateAsync(row.VaultId, credential, cancellationToken)
.ConfigureAwait(true);
// Into the same vault as the host, deliberately: a credential in the personal vault bound to a
// team's host is a binding every other member can see and none of them can resolve.
await session.Hosts
.UpdateAsync(
row.VaultId,
row.EntityId,
row.Host with { CredentialId = credentialId, AsksForPassword = null },
cancellationToken)
.ConfigureAwait(true);
}
catch (Exception exception)
{
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
return;
}
// Cleared together. The box is about to disappear — the host answers "credential" now — and a tick
// left behind would apply to the next host somebody selects.
RemembersConnectPassword = false;
ConnectPassword = string.Empty;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
+ "will not be asked for again.";
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>The address as actually dialled.</summary>