Public Access
Merge main into the phone connections branch
Main had already taken this branch's first two commits, so what merged is the Connections work against three things that landed beside it. Four of the six conflicts were prose about arrangements both sides changed; two were real. **The phone hub gained a Teams row while this branch was moving the keychain onto it.** Both are additions to `IsMoreSurface` and both belong: teams because the desktop reaches them from its rail and the phone through the hub, the keychain because a bottom bar is for the places a session moves between. The membership test, the back gesture's first case and the hub's own arithmetic all take the union. The distinction is now written down rather than implied — teams is the design's count plus one, and the keychain is the only rearrangement of it: the bar lost a slot to gain that row. **`ConnectAndAnnounceAsync` was the real one.** Main gave it `RememberTypedPasswordAsync`, which binds the password that just worked to the host it worked on; this branch had replaced the `HostRowViewModel` that method needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a manual connection does with a password that succeeded, and the answer was already written on the screen it is typed into: nothing. There is no item to bind a credential to and none to bind it on, and that path saves nothing by design. So `ConnectionTarget` carries the row again — as a nullable, in place of the host id it had, with `HostId` derived from it. Two things read it and both are things that can only be done to a keychain item rather than to an address: naming the log entry, and keeping the password. Null is not missing data there; it is the whole of what makes the manual path different, and having one field rather than two keeps "was this a keychain host" a question with one answer. The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's "a rail with nine slots has room" was true when it was written this afternoon and is not now. It says the room rather than the number. Phase 11's four new device checks and main's Phase 12 on teams were the same conflict twice — two appends to the end of one file — and both are kept. Verified after resolving: the solution builds, the Android head builds clean, and 837 tests pass across the seven client suites, including main's own additions (233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39 storage).
This commit is contained in:
@@ -82,11 +82,23 @@
|
||||
<!--
|
||||
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
|
||||
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
|
||||
|
||||
── The ramp is white-topped, and every step below it moved with the top. ─────────────────────────────
|
||||
The design's #E3E7F4 is a blue-tinted white, and against these blue-black surfaces the tint costs
|
||||
contrast twice: once because it is darker than white, and once because it shares a hue with what it is
|
||||
drawn on. Pure white against #0E1220 is 18.3:1, where #E3E7F4 was 15.5:1.
|
||||
|
||||
Raising only the top would have been the wrong half of the change. The three steps are a ramp rather
|
||||
than three colours, and their job is that a glance can tell them apart — so lifting Text and leaving
|
||||
TextDim where it was would have widened the first gap and left the second two crowded at the bottom.
|
||||
Every step is raised by roughly what the top gained, which keeps the intervals the design chose and
|
||||
brings the lower steps up to where a secondary label is comfortably readable rather than merely
|
||||
present. TextDim clears 9:1 now, against 6.4:1 before.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Text" Color="#E3E7F4" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#8B93B0" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#565E7E" />
|
||||
<SolidColorBrush x:Key="TextGhost" Color="#414A6B" />
|
||||
<SolidColorBrush x:Key="Text" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#B4BCD4" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#7B849F" />
|
||||
<SolidColorBrush x:Key="TextGhost" Color="#5A6280" />
|
||||
|
||||
<!--
|
||||
The accent, and it is two colours rather than one. The design fills with #5B8CFF — the button, the
|
||||
|
||||
@@ -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;
|
||||
@@ -784,18 +780,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// tabs are each exactly one thing, 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 the hub, 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.
|
||||
///
|
||||
/// <b>The keychain joined it, and that is why the bar went from four entries to three.</b> A phone's
|
||||
/// bottom bar is for the places a session moves between, and the keychain is not one of those: hosts
|
||||
/// and connections are what somebody opens the application to do, and keys, credentials and tags are
|
||||
/// what they go and manage occasionally. The desktop keeps its rail entry — it has room for nine — so
|
||||
/// this is the second thing the two heads deliberately arrange differently, after the hub itself.
|
||||
/// <b>The keychain joined it too, and that is why the bar went from four entries to three.</b> Unlike
|
||||
/// the two above, that one is a move rather than an addition: a phone's bottom bar is for the places a
|
||||
/// session moves between, and the keychain is not one of those — hosts and connections are what
|
||||
/// somebody opens the application to do, and keys, credentials and tags are what they go and manage
|
||||
/// occasionally. The desktop keeps its rail entry, having room for nine, so this is the second thing
|
||||
/// the two heads deliberately arrange differently, after the hub itself.
|
||||
/// </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.Vault;
|
||||
or ShellScreen.Team or ShellScreen.Vault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's WebView may be on screen at this instant.
|
||||
@@ -1028,6 +1028,60 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
vault.ManualStatus = string.Empty;
|
||||
}
|
||||
|
||||
// ---- The desktop's fixed tabs ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
|
||||
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
|
||||
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
|
||||
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
|
||||
/// which is the screen you are looking at.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
|
||||
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
|
||||
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
|
||||
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
|
||||
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
|
||||
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
|
||||
|
||||
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
|
||||
private static bool IsVaultsPage(ShellScreen screen) =>
|
||||
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
|
||||
|
||||
/// <summary>
|
||||
/// Which page the Vaults tab returns to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
|
||||
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
|
||||
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
|
||||
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
|
||||
/// screen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
|
||||
/// worth stating because the two look alike. That one would have been a second copy of "which page",
|
||||
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
|
||||
/// remembering its page, the way any tab does — and nothing else reads it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private ShellScreen vaultsScreen = ShellScreen.Hosts;
|
||||
|
||||
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowVaults() => ShowScreen(vaultsScreen);
|
||||
|
||||
// ---- The phone's connect menu ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's connect menu is open over the terminal.
|
||||
/// </summary>
|
||||
@@ -2663,6 +2717,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
RaiseSurfaceState();
|
||||
|
||||
// What the Vaults tab comes back to; see the field.
|
||||
if (IsVaultsPage(value))
|
||||
{
|
||||
vaultsScreen = value;
|
||||
}
|
||||
|
||||
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
||||
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
||||
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
||||
@@ -2762,6 +2822,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsBucketsScreen));
|
||||
|
||||
OnPropertyChanged(nameof(IsShowingPages));
|
||||
OnPropertyChanged(nameof(IsVaultsTab));
|
||||
OnPropertyChanged(nameof(IsHostsShowing));
|
||||
OnPropertyChanged(nameof(IsTransfersShowing));
|
||||
OnPropertyChanged(nameof(IsVaultShowing));
|
||||
|
||||
@@ -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>
|
||||
@@ -1022,6 +1028,34 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||||
|
||||
/// <summary>Whether the grid has anything to draw.</summary>
|
||||
/// <remarks>
|
||||
/// A property rather than <c>{Binding !VisibleHosts.Count}</c> in the markup. Avalonia's <c>!</c> is a
|
||||
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
|
||||
/// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts.
|
||||
/// </remarks>
|
||||
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// What the hosts grid says when it has nothing in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
|
||||
/// matches what you typed" are three different situations and only the first is an invitation to add
|
||||
/// something. Telling somebody with thirty machines to add their first one is answering a question they
|
||||
/// did not ask.
|
||||
/// </remarks>
|
||||
internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
|
||||
{
|
||||
(0, _, _) =>
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||
+ "~/.ssh/config from Preferences.",
|
||||
(_, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Drag a host onto its heading in the grid, or choose the "
|
||||
+ "group in a host's own editor.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
||||
/// </summary>
|
||||
@@ -1068,10 +1102,6 @@ internal sealed partial class VaultViewModel(
|
||||
internal string HostsHeading =>
|
||||
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
|
||||
|
||||
/// <summary>Whether the host list under the heading is folded away.</summary>
|
||||
[ObservableProperty]
|
||||
private bool areHostsExpanded = true;
|
||||
|
||||
/// <summary>The SSH keys to show, unpushed local state included.</summary>
|
||||
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
|
||||
|
||||
@@ -1153,6 +1183,40 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? selectedGroup;
|
||||
|
||||
/// <summary>
|
||||
/// The group the hosts grid is narrowed to, or null for every host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
|
||||
/// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
|
||||
/// an explicit control rather than a second press on the chosen card: the cards are a
|
||||
/// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
|
||||
/// application uses, and a <c>ListBox</c> does not unselect on a second click.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
|
||||
/// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
|
||||
/// card means both, which is why the change handler assigns one from the other. They are not one
|
||||
/// property because the phone sets <see cref="SelectedGroup"/> on its own account:
|
||||
/// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
|
||||
/// would have made opening that editor silently filter the phone's host list to the group being renamed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? groupFilter;
|
||||
|
||||
/// <summary>Whether the grid is showing one group rather than every host.</summary>
|
||||
internal bool IsFilteredByGroup => GroupFilter is not null;
|
||||
|
||||
/// <summary>Shows every host again.</summary>
|
||||
[RelayCommand]
|
||||
private void ClearGroupFilter()
|
||||
{
|
||||
GroupFilter = null;
|
||||
SelectedGroup = null;
|
||||
}
|
||||
|
||||
/// <summary>What the group name box holds, for both creating and renaming.</summary>
|
||||
[ObservableProperty]
|
||||
private string groupEditorLabel = string.Empty;
|
||||
@@ -1424,6 +1488,8 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
private bool isEditing;
|
||||
|
||||
/// <summary>
|
||||
@@ -1431,10 +1497,17 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop does not need this: its group editor is a bar under the group list that is always there,
|
||||
/// and <see cref="EditingGroupId"/> is enough to tell adding from saving. The phone has no room for a
|
||||
/// permanent bar, so its group editor is a card that replaces the list — and "is the card showing" is a
|
||||
/// different question from "which group is being edited", because adding one has no id.
|
||||
/// It was the phone's alone. The phone has no room for a permanent bar, so its group editor is a card
|
||||
/// that replaces the list — and "is the card showing" is a different question from "which group is being
|
||||
/// edited", because adding one has no id.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The desktop sets it too now.</b> Its group editor used to be a bar under the group list that was
|
||||
/// always on screen, which is why <see cref="EditingGroupId"/> was enough there. The hosts screen has no
|
||||
/// such bar since it became a grid of cards: the group editor is a panel in the drawer, raised by
|
||||
/// <c>+ NEW GROUP</c> or by <c>EDIT</c>, and "is it raised" is exactly this. So
|
||||
/// <see cref="AGroupEditorIsInTheWay"/> now answers for both heads rather than being false on one of
|
||||
/// them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Held here rather than on the phone's own control so that the two heads cannot disagree about
|
||||
@@ -1444,8 +1517,36 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
private bool isEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hosts screen's right-hand drawer is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The drawer is where everything that is about <em>one</em> thing lives: what a host is, the host
|
||||
/// editor, and the group editor. The grid beside it is about all of them. Splitting the screen that way
|
||||
/// is what let the 268-pixel host list go — the list was carrying both jobs, and neither at full size.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It stays open while a host deletion is in question, because the question is asked in the drawer and a
|
||||
/// deletion does not clear the selection. There is no separate term for that here: a pending deletion
|
||||
/// always has a selected host behind it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It occupies a column of the hosts screen rather than floating over it, which is the occlusion rule
|
||||
/// rather than a preference — see <c>MainWindow.axaml</c>. Nothing on this screen may be laid over the
|
||||
/// terminal's rectangle, and a drawer that slid over the grid would be doing exactly that on the day
|
||||
/// somebody moved the grid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null;
|
||||
|
||||
/// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary>
|
||||
internal bool IsShowingHostDetail => !IsEditing && !IsEditingGroup && SelectedHost is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the add sheet is showing over the host list.
|
||||
/// </summary>
|
||||
@@ -1984,10 +2085,11 @@ 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;
|
||||
@@ -2030,6 +2132,27 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry;
|
||||
|
||||
/// <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>
|
||||
@@ -2593,6 +2716,7 @@ internal sealed partial class VaultViewModel(
|
||||
private void RebuildGroups()
|
||||
{
|
||||
var selectedId = SelectedGroup?.EntityId;
|
||||
var filteredId = GroupFilter?.EntityId;
|
||||
|
||||
Groups.Clear();
|
||||
|
||||
@@ -2608,6 +2732,19 @@ internal sealed partial class VaultViewModel(
|
||||
// chose.
|
||||
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
// Re-resolved by id for the reason the selection above is: every row object here is replaced on
|
||||
// every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
|
||||
// longer in the list — and the card the user could press to clear it would be a different object
|
||||
// that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
|
||||
// grid comes back to every host rather than to none.
|
||||
//
|
||||
// This assignment is a new row object whenever there is a filter at all, so it always fires
|
||||
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
|
||||
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
|
||||
// the same thing — and it is left rather than dodged by writing the backing field, because writing
|
||||
// the field would skip SelectedGroup and IsFilteredByGroup with it.
|
||||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||||
|
||||
OnPropertyChanged(nameof(HasGroups));
|
||||
}
|
||||
|
||||
@@ -2643,6 +2780,11 @@ internal sealed partial class VaultViewModel(
|
||||
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
|
||||
// above is a null that has to be overwritten rather than read.
|
||||
SelectedSidebarRow = SelectedHost;
|
||||
|
||||
// The grid's empty state. Both of these are computed rather than stored, and neither has a change
|
||||
// notification of its own — VisibleHosts raises collection changes, which is not the same event.
|
||||
OnPropertyChanged(nameof(HasVisibleHosts));
|
||||
OnPropertyChanged(nameof(NoVisibleHostsMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2842,6 +2984,14 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row)
|
||||
{
|
||||
// The group cards, and they narrow before the box does — a host outside the chosen group is out
|
||||
// whatever was typed. The two are deliberately not one control: the box is what you type when you
|
||||
// know the name, and the cards are what you press when you do not.
|
||||
if (GroupFilter is { } group && row.Host.GroupId != group.EntityId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = HostFilter.Trim();
|
||||
|
||||
if (filter.Length == 0)
|
||||
@@ -3429,9 +3579,10 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Folds the host list away, or brings it back.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
|
||||
// AreHostsExpanded and ToggleHosts were here, and they went with the control that used them. They folded
|
||||
// the sidebar's whole host list away under its one heading — an affordance that existed because that
|
||||
// list was 268 pixels wide and the editor beneath it needed the room. The grid has neither the heading
|
||||
// nor the problem. Folding one *group* away is a different thing and is still here: see ToggleGroup.
|
||||
|
||||
/// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
|
||||
/// <remarks>
|
||||
@@ -3590,9 +3741,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
|
||||
/// It answers only for the phone, where the group editor is a raised card. On the desktop the bar is
|
||||
/// always present and <see cref="IsEditingGroup"/> is never set by anything the user can reach, so this
|
||||
/// is false there and nothing is refused.
|
||||
/// It answered only for the phone while the desktop's group editor was a bar that was always present.
|
||||
/// Both heads raise a card now — the desktop's is the panel in the hosts drawer — so this refuses on
|
||||
/// both, which is what it was always meant to do.
|
||||
/// </remarks>
|
||||
private bool AGroupEditorIsInTheWay()
|
||||
{
|
||||
@@ -4950,7 +5101,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
await ConnectToAsync(
|
||||
new ConnectionTarget(row.Label, row.EntityId, row.Host.Hostname, row.Resolved.Port.Value),
|
||||
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
|
||||
authentication,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -5007,7 +5158,6 @@ internal sealed partial class VaultViewModel(
|
||||
// the log entry this also names.
|
||||
new ConnectionTarget(
|
||||
$"{endpoint.Username}@{endpoint.Hostname}",
|
||||
HostId: null,
|
||||
endpoint.Hostname,
|
||||
endpoint.Port),
|
||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
|
||||
@@ -5451,6 +5601,102 @@ internal sealed partial class VaultViewModel(
|
||||
sessionId,
|
||||
target.Label,
|
||||
Dialled(target, 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.
|
||||
//
|
||||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
||||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||||
// screen it is typed on says so.
|
||||
if (target.Row is { } row)
|
||||
{
|
||||
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>
|
||||
@@ -5551,17 +5797,36 @@ internal sealed partial class VaultViewModel(
|
||||
/// The machine a connection is being made to, however it was named.
|
||||
/// </summary>
|
||||
/// <param name="Label">What to call it — a keychain host's alias, or what was typed.</param>
|
||||
/// <param name="HostId">The keychain item, or null for somewhere that is not in it.</param>
|
||||
/// <param name="Hostname">The address to dial.</param>
|
||||
/// <param name="Port">The port to dial, already resolved through any group.</param>
|
||||
/// <param name="Row">
|
||||
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||||
/// below the resolution needs four facts and a row carries dozens; taking the four is what let a
|
||||
/// below the resolution needs three facts and a row carries dozens; taking the three is what let a
|
||||
/// connection to an address that has no keychain item share the ladder rather than grow a second one.
|
||||
/// <see cref="ConnectionRecorder.Record"/> and <c>Identify</c> both take a nullable id already, so the
|
||||
/// log has always been able to hold a connection with no item behind it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The row is still here, and only two things read it.</b> Both are things that can only be done to
|
||||
/// a keychain item rather than to an address: identifying the log entry, and binding the password that
|
||||
/// just worked. Null is not missing data — it is the whole of what makes the manual path different, and
|
||||
/// having it here rather than as a separate id keeps "was this a keychain host" one question with one
|
||||
/// answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed record ConnectionTarget(string Label, Guid? HostId, string Hostname, int Port);
|
||||
private sealed record ConnectionTarget(
|
||||
string Label,
|
||||
string Hostname,
|
||||
int Port,
|
||||
HostRowViewModel? Row = null)
|
||||
{
|
||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out how a host authenticates, or says why it cannot.
|
||||
@@ -6200,6 +6465,10 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
OnPropertyChanged(nameof(ShowsConnectBar));
|
||||
|
||||
// The drawer opens on a selection and closes when there is none, so both of these move with it.
|
||||
OnPropertyChanged(nameof(IsDrawerOpen));
|
||||
OnPropertyChanged(nameof(IsShowingHostDetail));
|
||||
|
||||
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
|
||||
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
|
||||
// other.
|
||||
@@ -6245,6 +6514,20 @@ internal sealed partial class VaultViewModel(
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
|
||||
/// property. Assigning the same value again is a no-op, so this and
|
||||
/// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
|
||||
/// </remarks>
|
||||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||||
{
|
||||
SelectedGroup = value;
|
||||
|
||||
OnPropertyChanged(nameof(IsFilteredByGroup));
|
||||
|
||||
RebuildVisibleHosts();
|
||||
}
|
||||
|
||||
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||||
|
||||
Reference in New Issue
Block a user