Public Access
This commit is contained in:
@@ -132,8 +132,9 @@ dotnet run --project src/DodoSSH.Client.App
|
|||||||
|
|
||||||
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
|
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
|
||||||
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
|
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
|
||||||
skipped and cannot be recovered from the server. You can then add a host and open a shell on it. Keycloak's
|
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
|
||||||
admin console is at `http://localhost:18080` (`admin` / `admin`).
|
it in the sidebar, or select it and press **CONNECT**, which is the same command with the password box beside
|
||||||
|
it. Keycloak's admin console is at `http://localhost:18080` (`admin` / `admin`).
|
||||||
|
|
||||||
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the
|
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the
|
||||||
private key, then edit a host and pick that key from its **key** dropdown. From then on that host
|
private key, then edit a host and pick that key from its **key** dropdown. From then on that host
|
||||||
@@ -146,6 +147,14 @@ the next sync. If a server is legitimately rebuilt and offers a new key, the con
|
|||||||
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
|
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
|
||||||
somewhere you have to go on purpose.
|
somewhere you have to go on purpose.
|
||||||
|
|
||||||
|
**Deleting asks first, and the question is worth reading.** DELETE on a host, an SSH key or a stored password
|
||||||
|
puts a question where the buttons were, and what it says is counted rather than generic: how many hosts
|
||||||
|
authenticate with the key about to go — they refuse to connect afterwards rather than falling back to a typed
|
||||||
|
password — whether a terminal is open on the host about to go, and whether this machine can push the deletion
|
||||||
|
yet or is queuing it. There is no undo, which is the other thing it says. Withdrawing host key trust is the
|
||||||
|
deliberate exception: it costs one fingerprint check on the next connection, and the dangerous button there is
|
||||||
|
the one that *adds* trust.
|
||||||
|
|
||||||
**Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own
|
**Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own
|
||||||
key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that
|
key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that
|
||||||
key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps
|
key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps
|
||||||
@@ -186,6 +195,9 @@ beside its destination and is renamed into place at the end, so an interrupted t
|
|||||||
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
|
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
|
||||||
build artefact onto a server and then running it. A destination that already exists is refused outright
|
build artefact onto a server and then running it. A destination that already exists is refused outright
|
||||||
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
|
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
|
||||||
|
DELETE asks first and names the full path, and it carries the strongest warning in the application on
|
||||||
|
purpose: everything else DodoSSH deletes is a tombstone against a copy the server still holds, and a file on
|
||||||
|
somebody's host is bytes with nothing behind them.
|
||||||
**RESUME** on a stopped transfer carries on from what the part file already holds.
|
**RESUME** on a stopped transfer carries on from what the part file already holds.
|
||||||
|
|
||||||
Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing
|
Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing
|
||||||
|
|||||||
@@ -174,6 +174,40 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Something on the host that has been asked about and not yet agreed to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
|
||||||
|
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
|
||||||
|
/// has no wastebasket to put them in.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It carries the full path rather than only the name, because the name is the half that does not identify
|
||||||
|
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
|
||||||
|
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Name">What the row was called.</param>
|
||||||
|
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
|
||||||
|
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
|
||||||
|
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
|
||||||
|
{
|
||||||
|
/// <summary>The question, naming the kind because the two behave differently.</summary>
|
||||||
|
internal string Question => IsDirectory
|
||||||
|
? $"Delete the directory '{Name}' on the host?"
|
||||||
|
: $"Delete '{Name}' on the host?";
|
||||||
|
|
||||||
|
/// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
|
||||||
|
internal string Consequence => IsDirectory
|
||||||
|
? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
|
||||||
|
+ "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
|
||||||
|
+ "copy and there is no undo."
|
||||||
|
: "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
|
||||||
|
+ "touched, and there is no undo.";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -284,6 +318,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private RemoteDeletionRequest? pendingRemoteDeletion;
|
||||||
|
|
||||||
|
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
||||||
|
|
||||||
|
/// <summary>Whether the pane's DELETE is live.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
|
||||||
|
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
|
||||||
|
/// move UP and REFRESH out from under the pointer.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
|
||||||
|
|
||||||
// ---- The local pane ----
|
// ---- The local pane ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -672,15 +720,16 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes the chosen remote file, or an empty directory.
|
/// Asks whether the chosen remote file, or empty directory, should go.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
|
||||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
|
||||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
/// what it is — the only thing this application destroys that neither the server nor this machine has a
|
||||||
|
/// copy of.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
private void DeleteRemote()
|
||||||
{
|
{
|
||||||
if (SelectedRemoteEntry is not { } row)
|
if (SelectedRemoteEntry is not { } row)
|
||||||
{
|
{
|
||||||
@@ -688,18 +737,44 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes what was agreed to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||||
|
/// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
|
||||||
|
/// which is what makes the question a promise: nothing between asking and answering can point it
|
||||||
|
/// somewhere else.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (PendingRemoteDeletion is not { } request)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingRemoteDeletion = null;
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
$"Deleting {row.Name}…",
|
$"Deleting {request.Name}…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = $"Deleted {row.Name}.";
|
Status = $"Deleted {request.Name}.";
|
||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Thinks better of it.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
@@ -919,11 +994,28 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanDownload));
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
OnPropertyChanged(nameof(CanUpload));
|
OnPropertyChanged(nameof(CanUpload));
|
||||||
|
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
|
/// <remarks>
|
||||||
|
/// Any change to the selection takes the question away, which is stricter than the vault's rule and can
|
||||||
|
/// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
|
||||||
|
/// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
|
||||||
|
/// selection, so this one hook covers all three ways the answer could stop being about what was asked.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
||||||
|
{
|
||||||
OnPropertyChanged(nameof(CanDownload));
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
|
|
||||||
|
PendingRemoteDeletion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
||||||
|
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||||
OnPropertyChanged(nameof(CanUpload));
|
OnPropertyChanged(nameof(CanUpload));
|
||||||
|
|
||||||
|
|||||||
@@ -422,6 +422,55 @@ internal sealed record VaultItemRowViewModel(
|
|||||||
internal bool HasBadge => Badge.Length > 0;
|
internal bool HasBadge => Badge.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
||||||
|
internal enum DeletionTarget
|
||||||
|
{
|
||||||
|
/// <summary>A host, from the sidebar beside the terminal.</summary>
|
||||||
|
Host,
|
||||||
|
|
||||||
|
/// <summary>An SSH key, from the vault screen.</summary>
|
||||||
|
Key,
|
||||||
|
|
||||||
|
/// <summary>A stored password, from the vault screen.</summary>
|
||||||
|
Credential,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A deletion that has been asked for and not yet agreed to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A state rather than a dialog, on the same reasoning as the sign-out confirmation — see
|
||||||
|
/// <c>MainWindowViewModel.IsConfirmingSignOut</c>. What makes it worth having at all is that the sentences
|
||||||
|
/// below are <em>computed</em>: how many hosts authenticate with the key about to go, whether a terminal is
|
||||||
|
/// open on the host about to go, and whether this machine can push the tombstone yet. A confirmation that
|
||||||
|
/// only said "are you sure?" would be a click to train people out of.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It carries the item's id rather than pointing at the selection, so that whatever moves the selection
|
||||||
|
/// between the question and the answer — a background sync, a filter, a click in the list — cannot turn an
|
||||||
|
/// agreement about one item into the deletion of another.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Target">Which list to delete from.</param>
|
||||||
|
/// <param name="EntityId">The item the question is about.</param>
|
||||||
|
/// <param name="Question">The question itself, naming the item.</param>
|
||||||
|
/// <param name="Consequence">Where it goes, and how far.</param>
|
||||||
|
/// <param name="Usage">
|
||||||
|
/// What is riding on this particular item — hosts that authenticate with it, a terminal open on it — or
|
||||||
|
/// empty when nothing is. The line that changes the answer, as opposed to the one every deletion shares.
|
||||||
|
/// </param>
|
||||||
|
internal sealed record DeletionRequest(
|
||||||
|
DeletionTarget Target,
|
||||||
|
Guid EntityId,
|
||||||
|
string Question,
|
||||||
|
string Consequence,
|
||||||
|
string Usage)
|
||||||
|
{
|
||||||
|
/// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
|
||||||
|
internal bool HasUsage => Usage.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets this machine online, if it can be.
|
/// Gets this machine online, if it can be.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -857,6 +906,32 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <summary>The credential being edited, or null when creating.</summary>
|
/// <summary>The credential being edited, or null when creating.</summary>
|
||||||
private Guid? editingCredentialId;
|
private Guid? editingCredentialId;
|
||||||
|
|
||||||
|
// ---- Deleting ----
|
||||||
|
|
||||||
|
/// <summary>The deletion that has been asked for, or null when nothing has been.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One at a time, and one for all three kinds. Two armed deletions cannot be told apart by a user
|
||||||
|
/// looking at two cards, and this application only ever has one selected item per screen to aim a
|
||||||
|
/// question at.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private DeletionRequest? pendingDeletion;
|
||||||
|
|
||||||
|
internal bool IsConfirmingDeletion => PendingDeletion is not null;
|
||||||
|
|
||||||
|
/// <summary>Whether the sidebar's row of host buttons is showing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its own property because the markup cannot express <c>!IsEditing && !IsConfirmingDeletion</c>,
|
||||||
|
/// and because both halves are the same rule: the question about deleting a host takes the place of the
|
||||||
|
/// buttons that asked it, so that DELETE cannot be pressed a second time while its own confirmation is
|
||||||
|
/// on screen.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool ShowsHostActions => !IsEditing && !IsConfirmingDeletion;
|
||||||
|
|
||||||
|
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
||||||
|
/// <inheritdoc cref="ShowsHostActions" />
|
||||||
|
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
|
||||||
|
|
||||||
// ---- Connecting ----
|
// ---- Connecting ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1068,7 +1143,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <returns>How many keys would not decrypt.</returns>
|
/// <returns>How many keys would not decrypt.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
||||||
/// <see cref="DeleteKeyAsync" /> acts on, and a list that picked a row on every background sync would aim
|
/// <see cref="DeleteKey" /> aims at, and a list that picked a row on every background sync would point
|
||||||
/// that button at a key nobody chose.
|
/// that button at a key nobody chose.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
||||||
@@ -1094,9 +1169,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <returns>How many credentials would not decrypt.</returns>
|
/// <returns>How many credentials would not decrypt.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
|
/// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
|
||||||
/// as the key list and matters more here. <see cref="DeleteCredentialAsync" /> acts on the selection, so a
|
/// as the key list and matters more here. <see cref="DeleteCredential" /> reads the selection, so a list
|
||||||
/// list that fell back to its first row would put a one-click deletion of somebody's password behind a
|
/// that fell back to its first row would point the deletion — and the question in front of it — at a
|
||||||
/// button they never aimed.
|
/// password nobody chose.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -1475,26 +1550,23 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes whatever the selected row is.</summary>
|
/// <summary>Asks about deleting whatever the selected row is.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
||||||
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
||||||
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteSelectedItemAsync()
|
private void DeleteSelectedItem()
|
||||||
{
|
{
|
||||||
switch (SelectedVaultItem?.Kind)
|
switch (SelectedVaultItem?.Kind)
|
||||||
{
|
{
|
||||||
// Null rather than a token, and deliberately: a [RelayCommand] over a method whose only
|
|
||||||
// parameter is a CancellationToken generates ExecuteAsync(object? parameter) that ignores the
|
|
||||||
// argument and supplies a token from its own source. Passing one would read as plumbing.
|
|
||||||
case VaultItemKind.Key:
|
case VaultItemKind.Key:
|
||||||
await DeleteKeyCommand.ExecuteAsync(null).ConfigureAwait(true);
|
DeleteKeyCommand.Execute(null);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VaultItemKind.Credential:
|
case VaultItemKind.Credential:
|
||||||
await DeleteCredentialCommand.ExecuteAsync(null).ConfigureAwait(true);
|
DeleteCredentialCommand.Execute(null);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -1560,15 +1632,42 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues a tombstone for the selected host.</summary>
|
/// <summary>Asks whether the selected host should go.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
||||||
|
/// not close one — a session outlives the row that opened it, exactly as it outlives a lock. Somebody
|
||||||
|
/// deleting a machine they are still working on should know that is what they have done.
|
||||||
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteHostAsync(CancellationToken cancellationToken)
|
private void DeleteHost()
|
||||||
{
|
{
|
||||||
if (SelectedHost is not { } row)
|
if (SelectedHost is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.Host,
|
||||||
|
row.EntityId,
|
||||||
|
$"Delete the host '{row.Label}'?",
|
||||||
|
HowFarADeletionGoes("The host and everything saved about it"),
|
||||||
|
row.IsConnected
|
||||||
|
? "A terminal is open on this host. It stays open — deleting the host does not close it, and "
|
||||||
|
+ "nothing will reopen it afterwards."
|
||||||
|
: string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues a tombstone for the host that was agreed to.</summary>
|
||||||
|
private async Task DeleteHostNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||||
|
{
|
||||||
|
// Gone between the question and the answer — a sync that pulled somebody else's deletion is the
|
||||||
|
// realistic way. Saying so beats a silent no-op under a card that has just been agreed to.
|
||||||
|
Status = "That host is no longer here, so nothing was deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
"Deleting…",
|
"Deleting…",
|
||||||
async () =>
|
async () =>
|
||||||
@@ -1687,15 +1786,39 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues a tombstone for the selected key.</summary>
|
/// <summary>Asks whether the selected key should go.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The private key is the thing this vault holds that is least likely to exist anywhere else, which is
|
||||||
|
/// why the question says so. What it does not say is that the key is gone from the machines it was
|
||||||
|
/// installed on: deleting it here removes this vault's copy, and the <c>authorized_keys</c> file on a
|
||||||
|
/// server is not something this application has ever written to.
|
||||||
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteKeyAsync(CancellationToken cancellationToken)
|
private void DeleteKey()
|
||||||
{
|
{
|
||||||
if (SelectedKey is not { } row)
|
if (SelectedKey is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.Key,
|
||||||
|
row.EntityId,
|
||||||
|
$"Delete the SSH key '{row.Label}'?",
|
||||||
|
HowFarADeletionGoes("The private key, its passphrase and everything saved with them")
|
||||||
|
+ " If this key is not on disk anywhere else, this is the only copy.",
|
||||||
|
HostsBoundTo(host => host.SshKeyId, row.EntityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues a tombstone for the key that was agreed to.</summary>
|
||||||
|
private async Task DeleteKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Keys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||||
|
{
|
||||||
|
Status = "That key is no longer here, so nothing was deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
"Deleting…",
|
"Deleting…",
|
||||||
async () =>
|
async () =>
|
||||||
@@ -1812,15 +1935,32 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues a tombstone for the selected credential.</summary>
|
/// <summary>Asks whether the selected credential should go.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteCredentialAsync(CancellationToken cancellationToken)
|
private void DeleteCredential()
|
||||||
{
|
{
|
||||||
if (SelectedCredential is not { } row)
|
if (SelectedCredential is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.Credential,
|
||||||
|
row.EntityId,
|
||||||
|
$"Delete the password '{row.Label}'?",
|
||||||
|
HowFarADeletionGoes("The password and the account saved with it"),
|
||||||
|
HostsBoundTo(host => host.CredentialId, row.EntityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues a tombstone for the credential that was agreed to.</summary>
|
||||||
|
private async Task DeleteCredentialNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Credentials.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||||
|
{
|
||||||
|
Status = "That password is no longer here, so nothing was deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
"Deleting…",
|
"Deleting…",
|
||||||
async () =>
|
async () =>
|
||||||
@@ -1836,6 +1976,92 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Carries out the deletion that was asked about.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Disarmed before the work rather than after it, so that 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 ConfirmDeleteAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (PendingDeletion is not { } request)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingDeletion = null;
|
||||||
|
|
||||||
|
switch (request.Target)
|
||||||
|
{
|
||||||
|
case DeletionTarget.Host:
|
||||||
|
await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DeletionTarget.Key:
|
||||||
|
await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DeletionTarget.Credential:
|
||||||
|
await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thinks better of it.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CancelDelete() => PendingDeletion = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a deleted item goes, and how far.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The offline branch is the same distinction saving makes, and it matters more here: a tombstone that
|
||||||
|
/// has not been pushed is a deletion the other machines have not heard about, and somebody deleting a
|
||||||
|
/// credential because it leaked should be told which of those two they have just done.
|
||||||
|
/// </remarks>
|
||||||
|
private string HowFarADeletionGoes(string what) => connection() is null
|
||||||
|
? $"{what} goes from this machine now, and from your other machines once this one is online again. "
|
||||||
|
+ "There is no undo."
|
||||||
|
: $"{what} goes from this machine now, and from your other machines at the next synchronisation. "
|
||||||
|
+ "There is no undo.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the hosts that authenticate with an item would be left with, or nothing when none do.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Counted rather than warned about in general terms. The number is the difference between a sentence
|
||||||
|
/// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound
|
||||||
|
/// to something the vault no longer has is refused at connect time rather than quietly falling back to a
|
||||||
|
/// typed password — see <see cref="TryBuildAuthentication" />.
|
||||||
|
/// </remarks>
|
||||||
|
private string HostsBoundTo(Func<HostSecret, Guid?> binding, Guid entityId)
|
||||||
|
{
|
||||||
|
var bound = Hosts
|
||||||
|
.Where(row => binding(row.Host) == entityId)
|
||||||
|
.Select(row => row.Label)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (bound.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three names and a count past that, because this is read in a 244-pixel column and a vault with
|
||||||
|
// twenty hosts on one key would otherwise put a paragraph of names where a warning should be.
|
||||||
|
var named = bound.Length <= 3
|
||||||
|
? string.Join(", ", bound)
|
||||||
|
: $"{string.Join(", ", bound.Take(3))} and {bound.Length - 3} more";
|
||||||
|
|
||||||
|
return bound.Length == 1
|
||||||
|
? $"{named} authenticates with it, and will refuse to connect rather than fall back to a typed "
|
||||||
|
+ "password."
|
||||||
|
: $"{bound.Length} hosts authenticate with it — {named} — and will refuse to connect rather than "
|
||||||
|
+ "fall back to a typed password.";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Withdraws trust from the selected pin's endpoint.
|
/// Withdraws trust from the selected pin's endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2577,6 +2803,33 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||||
|
|
||||||
|
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||||||
|
OnPropertyChanged(nameof(ShowsHostActions));
|
||||||
|
OnPropertyChanged(nameof(ShowsItemActions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the question away when the selection it was asked about has moved on.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared by entity id rather than by row, and that is the whole point of the method. A reload
|
||||||
|
/// replaces every row object in the list, so a background pass a minute after the question would
|
||||||
|
/// otherwise take the card away from under somebody still reading it — while a click onto a different
|
||||||
|
/// item, which is the case that actually needs handling, leaves an armed deletion pointing at something
|
||||||
|
/// nobody is looking at any more.
|
||||||
|
/// </remarks>
|
||||||
|
private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId)
|
||||||
|
{
|
||||||
|
if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId)
|
||||||
|
{
|
||||||
|
PendingDeletion = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2664,6 +2917,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
||||||
OnPropertyChanged(nameof(SelectedItemIsPin));
|
OnPropertyChanged(nameof(SelectedItemIsPin));
|
||||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||||
|
OnPropertyChanged(nameof(ShowsItemActions));
|
||||||
|
|
||||||
|
// Both kinds this table can delete, because one selection covers both lists.
|
||||||
|
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
||||||
|
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
||||||
|
|
||||||
switch (value?.Kind)
|
switch (value?.Kind)
|
||||||
{
|
{
|
||||||
@@ -2714,8 +2972,34 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// flips back in every path that closes one, so this notification always observes the pair in a
|
/// flips back in every path that closes one, so this notification always observes the pair in a
|
||||||
/// consistent state.
|
/// consistent state.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
partial void OnIsEditingChanged(bool value) =>
|
partial void OnIsEditingChanged(bool value)
|
||||||
|
{
|
||||||
OnPropertyChanged(nameof(CanForgetHostKey));
|
OnPropertyChanged(nameof(CanForgetHostKey));
|
||||||
|
OnPropertyChanged(nameof(ShowsHostActions));
|
||||||
|
|
||||||
|
DisarmOnceAnEditorIsOpen(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||||||
|
|
||||||
|
partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the question away when an editor opens over the pane it was asked in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sidebar's confirmation replaces the buttons that could open the host editor, so that half cannot
|
||||||
|
/// happen; the vault screen's Add buttons stay on screen beside the detail pane, so that half can. One
|
||||||
|
/// rule for both, rather than a guard on the three commands that would have to be remembered by the
|
||||||
|
/// fourth.
|
||||||
|
/// </remarks>
|
||||||
|
private void DisarmOnceAnEditorIsOpen(bool opened)
|
||||||
|
{
|
||||||
|
if (opened)
|
||||||
|
{
|
||||||
|
PendingDeletion = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.ConfirmDeleteCard"
|
||||||
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The question in front of deleting something in the vault.
|
||||||
|
|
||||||
|
One control used in two places — the host sidebar, where it takes the place of the row of buttons that
|
||||||
|
opened it, and the vault screen's detail pane, where it takes the place of EDIT and DELETE. The two
|
||||||
|
moments are different and what has to be said is not, which is why this is a shared control rather than
|
||||||
|
two blocks that would drift apart. The sign-out confirmation is the same arrangement, for the same
|
||||||
|
reason; see SignOutCard.
|
||||||
|
|
||||||
|
A bare StackPanel and not a card, because the two hosts frame it themselves: the sidebar puts it in the
|
||||||
|
strip along its bottom edge, and the vault screen in a column that scrolls.
|
||||||
|
|
||||||
|
Everything it says is something the view model can answer. The question names the item, the consequence
|
||||||
|
knows whether this machine can push a tombstone yet, and the line in the box is a count of the hosts
|
||||||
|
that actually authenticate with the thing about to go — see VaultViewModel.HostsBoundTo. A confirmation
|
||||||
|
that only asked "are you sure?" would be a click to train people out of.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
|
||||||
|
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Question}" />
|
||||||
|
|
||||||
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Consequence}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
What else in this vault leans on it. In a box of its own because it is the line that changes the
|
||||||
|
answer: everything above is true of every deletion, and this is about the one being made.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="8,6"
|
||||||
|
IsVisible="{Binding PendingDeletion.HasUsage, FallbackValue=False}">
|
||||||
|
<TextBlock Foreground="{StaticResource Info}" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Usage}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The question in front of deleting a host, a key or a password.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its data context is the <c>VaultViewModel</c>, in both of the places it is shown, so every binding in the
|
||||||
|
/// markup is a property of the vault. See <see cref="HostSidebar"/> and <see cref="VaultScreen"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||||
|
{
|
||||||
|
public ConfirmDeleteCard() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
x:Class="DodoSSH.Client.App.Views.HostSidebar"
|
x:Class="DodoSSH.Client.App.Views.HostSidebar"
|
||||||
x:DataType="vm:VaultViewModel">
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||||
IsVisible="{Binding !IsEditing}">
|
IsVisible="{Binding ShowsHostActions}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
|
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
|
||||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
|
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
|
||||||
@@ -183,6 +184,19 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The question DELETE asks, in the place the buttons were rather than under them. This strip is at the
|
||||||
|
bottom edge of a column whose middle is a list that has already taken every spare pixel, so a second
|
||||||
|
block below the first would push its own buttons off the window — the same reasoning that swaps the
|
||||||
|
unlock card for the sign-out card rather than stacking them. Swapping also means DELETE cannot be
|
||||||
|
pressed again while its own question is up; see VaultViewModel.ShowsHostActions.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||||
|
IsVisible="{Binding IsConfirmingDeletion}">
|
||||||
|
<views:ConfirmDeleteCard />
|
||||||
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using DodoSSH.Client.App.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
@@ -11,7 +13,29 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class HostSidebar : UserControl
|
internal sealed partial class HostSidebar : UserControl
|
||||||
{
|
{
|
||||||
public HostSidebar() => InitializeComponent();
|
public HostSidebar()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
||||||
|
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
|
||||||
|
// every other client of this kind does, and the CONNECT button stays: it is the one that has the
|
||||||
|
// password box beside it, and a host that asks for a password still needs it typed first.
|
||||||
|
HostList.DoubleTapped += OnHostActivated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
||||||
|
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
|
||||||
|
/// returning a task nothing observes.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnHostActivated(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is VaultViewModel vault)
|
||||||
|
{
|
||||||
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where the keyboard should land when the terminal hands it back.
|
/// Where the keyboard should land when the terminal hands it back.
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ==== The host ==== -->
|
<!-- ==== The host ==== -->
|
||||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
|
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||||
BorderThickness="0,0,0,1">
|
BorderThickness="0,0,0,1">
|
||||||
@@ -209,12 +209,40 @@
|
|||||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" />
|
IsEnabled="{Binding IsConnected}" />
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" />
|
IsEnabled="{Binding CanDeleteRemote}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
<!--
|
||||||
|
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
|
||||||
|
still on screen and still selected while it is being answered — and it names the full path rather
|
||||||
|
than the file, because a name is the half that does not identify anything.
|
||||||
|
|
||||||
|
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||||
|
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||||||
|
host is bytes with nothing behind them.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="1" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||||
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||||
|
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemoteDeletion.Question}" />
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource Danger}"
|
||||||
|
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||||
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="danger" Content="DELETE ON THE HOST"
|
||||||
|
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
@@ -249,14 +277,14 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
<Grid Grid.Row="3" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
<ListBox Grid.Row="4" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
@@ -276,7 +304,7 @@
|
|||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
|
<StackPanel Grid.Row="4" Spacing="10" Margin="24" MaxWidth="300"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding !HasRemoteEntries}">
|
IsVisible="{Binding !HasRemoteEntries}">
|
||||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
||||||
x:DataType="vm:VaultViewModel">
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
@@ -220,11 +221,23 @@
|
|||||||
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||||
IsVisible="{Binding SelectedItemIsEditable}">
|
IsVisible="{Binding ShowsItemActions}">
|
||||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The question DELETE asks, in the place those two buttons were. Here rather than over the
|
||||||
|
screen, because this pane is where the item being deleted is described: the name, the kind and
|
||||||
|
what is stored are all still on screen above it, which is most of what somebody checks before
|
||||||
|
answering. See ConfirmDeleteCard.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="10" Margin="0,14,0,0"
|
||||||
|
IsVisible="{Binding IsConfirmingDeletion}">
|
||||||
|
<views:ConfirmDeleteCard />
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
||||||
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using DodoSSH.Client.App.ViewModels;
|
using DodoSSH.Client.App.ViewModels;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
@@ -223,6 +226,88 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The strip along the sidebar's bottom edge with the question in it instead of the three buttons. Its
|
||||||
|
/// tallest shape is a host with a terminal open on it, which adds a disclosure the ordinary case has
|
||||||
|
/// not got — in a 268-pixel column whose middle is a list that has already taken every spare pixel.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Worth measuring rather than assuming, because this is the one card in the application a user cannot
|
||||||
|
/// scroll: the sidebar's only <c>ScrollViewer</c> is inside the host list, so a button pushed past the
|
||||||
|
/// bottom edge here would leave the question unanswerable in either direction.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHostSidebarFitsWithADeletionInQuestion()
|
||||||
|
{
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
vault.SelectedHost.IsConnected = true;
|
||||||
|
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
vault.IsConfirmingDeletion.ShouldBeTrue();
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
|
||||||
|
|
||||||
|
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
|
||||||
|
/// The gesture is wired in the control rather than bound in the markup, which is exactly the sort of
|
||||||
|
/// wiring that compiles whether or not it is connected to anything — so it is worth a test that
|
||||||
|
/// performs the gesture.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Proved through a connection that is refused before any network is involved. The host is left bound
|
||||||
|
/// to a key that has been deleted, which <c>TryBuildAuthentication</c> turns into a sentence on the
|
||||||
|
/// status line rather than a socket — so what this asserts is that the command ran, with nothing
|
||||||
|
/// timing out to make it flaky.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DoubleClickingAHostConnectsToIt()
|
||||||
|
{
|
||||||
|
var keyId = vault.Keys[0].EntityId;
|
||||||
|
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||||
|
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.SelectedKey = vault.Keys.Single(row => row.EntityId == keyId);
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.SelectedHost = null;
|
||||||
|
vault.Status = string.Empty;
|
||||||
|
|
||||||
|
await OnTheSidebarAsync((sidebar, window) =>
|
||||||
|
{
|
||||||
|
var row = sidebar.HostList.GetVisualDescendants()
|
||||||
|
.OfType<ListBoxItem>()
|
||||||
|
.First();
|
||||||
|
|
||||||
|
var centre = row.TranslatePoint(
|
||||||
|
new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
|
||||||
|
?? throw new InvalidOperationException("the row is not in this window's tree");
|
||||||
|
|
||||||
|
window.MouseDown(centre, MouseButton.Left);
|
||||||
|
window.MouseUp(centre, MouseButton.Left);
|
||||||
|
window.MouseDown(centre, MouseButton.Left);
|
||||||
|
window.MouseUp(centre, MouseButton.Left);
|
||||||
|
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
|
||||||
|
vault.Status.ShouldContain(
|
||||||
|
"not in this vault any more",
|
||||||
|
Case.Insensitive,
|
||||||
|
"the double-click has to reach the connect command");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The vault screen ----
|
// ---- The vault screen ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -283,6 +368,53 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
|
||||||
|
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
|
||||||
|
/// application.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheVaultScreenFitsWithADeletionInQuestion()
|
||||||
|
{
|
||||||
|
var keyId = vault.Keys[0].EntityId;
|
||||||
|
|
||||||
|
foreach (var host in vault.Hosts.Take(4).ToList())
|
||||||
|
{
|
||||||
|
vault.SelectedHost = host;
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||||
|
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.Section = VaultSection.Keys;
|
||||||
|
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == keyId);
|
||||||
|
|
||||||
|
vault.DeleteSelectedItemCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage
|
||||||
|
.ShouldBeTrue("four bound hosts are what makes this the long shape");
|
||||||
|
|
||||||
|
await OnTheVaultAsync((screen, window) =>
|
||||||
|
{
|
||||||
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||||
|
|
||||||
|
// And it says something. A card whose bindings did not resolve would lay out perfectly as three
|
||||||
|
// empty rows, which is the one failure a fit test cannot see: compiled bindings against the
|
||||||
|
// wrong data type are a logged message rather than an exception.
|
||||||
|
var card = screen.GetVisualDescendants().OfType<ConfirmDeleteCard>().ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
var said = string.Join(
|
||||||
|
" ",
|
||||||
|
card.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text));
|
||||||
|
|
||||||
|
said.ShouldContain("key-0", Case.Insensitive, "the question has to name what is going");
|
||||||
|
said.ShouldContain("4 hosts authenticate with it");
|
||||||
|
said.ShouldContain("no undo");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
|
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
|
||||||
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
|
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
|
||||||
@@ -375,6 +507,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
||||||
|
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
||||||
|
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
||||||
|
{
|
||||||
|
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||||
|
"2026-07-30",
|
||||||
|
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||||
|
IsDirectory: true);
|
||||||
|
|
||||||
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The chrome ----
|
// ---- The chrome ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddHostAsync(vault, "prod-db");
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
await DeleteSelectedHostAsync(vault);
|
||||||
|
|
||||||
vault.Hosts.ShouldBeEmpty();
|
vault.Hosts.ShouldBeEmpty();
|
||||||
|
|
||||||
@@ -739,6 +739,219 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.PendingChanges.ShouldBe(0);
|
vault.PendingChanges.ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The question in front of a deletion ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The half that makes the confirmation worth having: pressing DELETE has to change nothing at all. A
|
||||||
|
/// card that appeared after the item had already gone would be a receipt, not a question.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
|
||||||
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
question.Question.ShouldContain("prod-db", Case.Insensitive);
|
||||||
|
vault.IsConfirmingDeletion.ShouldBeTrue();
|
||||||
|
vault.ShowsHostActions.ShouldBeFalse("the buttons are what the question replaces");
|
||||||
|
|
||||||
|
vault.Hosts.ShouldHaveSingleItem();
|
||||||
|
server.LiveRowCount.ShouldBe(1);
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Hosts.ShouldBeEmpty();
|
||||||
|
vault.PendingDeletion.ShouldBeNull("the question goes when it is answered");
|
||||||
|
vault.ShowsHostActions.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellingADeletion_LeavesTheItemWhereItWas()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
|
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
|
||||||
|
// And the answer that would have deleted it has nothing left to act on.
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Credentials.ShouldHaveSingleItem();
|
||||||
|
server.LiveRowCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// What the question is for. A key that two hosts authenticate with is not the same deletion as one
|
||||||
|
/// nothing uses, and the hosts do not fall back to a typed password when it goes — they refuse, which is
|
||||||
|
/// asserted from the connect path's side in
|
||||||
|
/// <see cref="AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheQuestionAboutAKey_CountsTheHostsThatAuthenticateWithIt()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
var keyId = vault.Keys[0].EntityId;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
await AddHostAsync(vault, "prod-web");
|
||||||
|
await BindKeyAsync(vault, Host(vault, "prod-db"), keyId);
|
||||||
|
await BindKeyAsync(vault, Host(vault, "prod-web"), keyId);
|
||||||
|
|
||||||
|
vault.SelectedKey = vault.Keys[0];
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
|
||||||
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
question.HasUsage.ShouldBeTrue();
|
||||||
|
question.Usage.ShouldContain("2 hosts");
|
||||||
|
question.Usage.ShouldContain("prod-db");
|
||||||
|
question.Usage.ShouldContain("prod-web");
|
||||||
|
|
||||||
|
// And the sentence above it says how far the deletion travels, which needs no host at all.
|
||||||
|
question.Consequence.ShouldContain("no undo", Case.Insensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A key nothing uses gets no scare line, which is the other half of counting: a warning that appeared
|
||||||
|
/// every time would say nothing the second time.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheQuestionAboutAKeyNothingUses_SaysNothingAboutHosts()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "spare");
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedKey = vault.Keys[0];
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The failure this guards against is a question answered about something else: arm the deletion, click
|
||||||
|
/// another row, press the button that is still on screen. The armed item is what the answer acts on, and
|
||||||
|
/// choosing a different one takes the question away rather than re-aiming it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ChoosingSomethingElse_TakesTheQuestionAway()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
await AddCredentialAsync(vault, "staging deploy");
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.Section = VaultSection.Credentials;
|
||||||
|
vault.SelectedVaultItem = vault.VaultItems[0];
|
||||||
|
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
vault.SelectedVaultItem = vault.VaultItems[1];
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Credentials.Count.ShouldBe(2, "nothing was agreed to");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The case the naive rule got wrong. A reload replaces every row object in the list, so disarming on
|
||||||
|
/// any change of the selected <em>row</em> would let the pass that runs every minute take the card away
|
||||||
|
/// from somebody halfway through reading it. The entity id is what the rule compares.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASyncUnderneathAnArmedQuestion_LeavesItAlone()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
var armed = vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await vault.SyncCommand.ExecuteAsync(null);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldBe(armed);
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Hosts.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Opening an editor is the other way the pane the question is in stops being about the question: the
|
||||||
|
/// vault screen's Add buttons stay on screen beside the detail pane, so a password editor can open over
|
||||||
|
/// an armed deletion. It disarms rather than stacking two forms in a 244-pixel column.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpeningAnEditor_TakesTheQuestionAway()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
|
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
vault.NewCredentialCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.IsEditingCredential.ShouldBeTrue();
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An answer to a question about something that has since gone — the realistic way being a pass that
|
||||||
|
/// pulled somebody else's deletion. The reload that brings that news normally moves the selection and
|
||||||
|
/// takes the question with it; this holds the guard behind that, which is what keeps a stale agreement
|
||||||
|
/// from being a silent no-op under a card that has just been pressed.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnsweringAboutSomethingAlreadyGone_SaysSo()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
vault.SelectedKey = vault.Keys[0];
|
||||||
|
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Underneath the question, as another machine's deletion would arrive.
|
||||||
|
await vault.Session.SshKeys.DeleteAsync(
|
||||||
|
vault.Session.ActiveVaultId, vault.Keys[0].EntityId, Token);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Status.ShouldContain("no longer here");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
||||||
{
|
{
|
||||||
@@ -1005,7 +1218,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddKeyAsync(vault, "deploy");
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
|
||||||
vault.SelectedKey = vault.Keys[0];
|
vault.SelectedKey = vault.Keys[0];
|
||||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
await DeleteSelectedKeyAsync(vault);
|
||||||
|
|
||||||
vault.Keys.ShouldBeEmpty();
|
vault.Keys.ShouldBeEmpty();
|
||||||
server.LiveRowCount.ShouldBe(0);
|
server.LiveRowCount.ShouldBe(0);
|
||||||
@@ -1387,7 +1600,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
vault.SelectedKey = vault.Keys[0];
|
vault.SelectedKey = vault.Keys[0];
|
||||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
await DeleteSelectedKeyAsync(vault);
|
||||||
vault.Keys.ShouldBeEmpty();
|
vault.Keys.ShouldBeEmpty();
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
@@ -1412,7 +1625,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
||||||
|
|
||||||
vault.SelectedKey = vault.Keys[0];
|
vault.SelectedKey = vault.Keys[0];
|
||||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
await DeleteSelectedKeyAsync(vault);
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
@@ -1541,7 +1754,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddCredentialAsync(vault, "prod deploy");
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
|
|
||||||
vault.Credentials.ShouldBeEmpty();
|
vault.Credentials.ShouldBeEmpty();
|
||||||
server.LiveRowCount.ShouldBe(0);
|
server.LiveRowCount.ShouldBe(0);
|
||||||
@@ -1573,7 +1786,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
vault.SelectedCredential.ShouldBeNull();
|
vault.SelectedCredential.ShouldBeNull();
|
||||||
|
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
// Explicitly rather than through the helper: with nothing selected there is nothing to ask about,
|
||||||
|
// and the absence of a question is what proves the button found nothing to aim at.
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vault.Credentials.ShouldHaveSingleItem();
|
vault.Credentials.ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
@@ -1594,7 +1812,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
// And a kind with nothing in it is left out rather than reported as zero.
|
// And a kind with nothing in it is left out rather than reported as zero.
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
await vault.LoadAsync(Token);
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
|
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
|
||||||
@@ -1725,7 +1943,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
||||||
|
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
vault.Credentials.ShouldBeEmpty();
|
vault.Credentials.ShouldBeEmpty();
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
@@ -1747,7 +1965,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
||||||
|
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
@@ -1900,7 +2118,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
await DeleteSelectedHostAsync(vault);
|
||||||
|
|
||||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
||||||
"the pin outlives the host, and the list has to admit it");
|
"the pin outlives the host, and the list has to admit it");
|
||||||
@@ -2673,6 +2891,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await vault.SaveKeyCommand.ExecuteAsync(null);
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The host row with a given name, which the list orders by label rather than by age.</summary>
|
||||||
|
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||||
|
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||||
|
/// which is what <c>DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered</c> holds it to. Tests
|
||||||
|
/// about something else go through these three helpers, so the two-step is spelled out in one place
|
||||||
|
/// rather than in ten.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task DeleteSelectedHostAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
||||||
|
private static async Task DeleteSelectedKeyAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
||||||
|
private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
|
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
|
||||||
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
|
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
|
||||||
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
|
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
|
||||||
|
|||||||
Reference in New Issue
Block a user