Public Access
Let a host be moved to another vault
The one thing the host editor's vault picker has always been unable to offer, and the comment beside it said so: an existing host's vault was not a field because the two vaults are encrypted under different keys. That is still true. What changed is that it is no longer a reason to have nothing. **A move is a copy and a tombstone, and it cannot be anything else.** A payload is sealed under its vault's key and its AAD binds the vault, the entity id and the item version, so no edit moves one and no server call could — the server holds ciphertext it cannot read. What crosses is the plaintext, in this process, between an unwrap under one key and a seal under another. VaultItemRepository gained MoveAsync for it, so the three decisions below live in one place with their reasons rather than being re-derived at each call site. The item takes a new id. Keeping it would put one entity id in two vaults, and the item table is keyed on the type and the id rather than on the vault — so the destination's row and the source's tombstone would be the same row, and the move would delete what it had just written. The write comes first and the tombstone second, which decides what an interruption leaves: a copy in both vaults, visible and deletable, rather than a tombstone with nothing on the other side. Both are queued rather than sent, so the window is a crash between two local writes; it is still worth being on the survivable side of. Two activity lines rather than one, because that is what the two vaults actually record. A single "moved" line would have to be written to one of them and would be missing from the other's history. **The group and the tags stay behind, and that is the half that makes this honest.** Both are items of the vault the host is leaving: the editor's group picker offers one vault's groups and the chips are drawn from one vault's tags. A host carrying either across would resolve it on the machine that moved it — groups and tags are resolved over every readable vault — and dangle for everybody else in the destination. The mover and their colleagues would be looking at two different hosts. Cleared and reported beats carried and invisible. The key or password binding is kept, and the difference is not inconsistency. Those genuinely resolve across vaults — one key on twenty hosts in three vaults is the arrangement they exist for — so clearing them would take a working host and make one that cannot connect. What the message does instead is name a binding that is now outside the destination, because that is precisely what the other members of it will not be able to resolve. **It is not in the editor**, on either head: the desktop puts it in the detail pane's ⋯ menu above the separator Delete sits below, and the phone beside EDIT. A picker inside the form would move a machine as a side effect of correcting a port, which is the bug the editor's own vault picker was fenced off to prevent in the first place. The panel takes the footer as the deletion question does, and says what will be left behind before the tap rather than after it — on a phone, where the status line afterwards is one line on a screen somebody has already navigated away from, that is the only place it reliably gets read. The phone hides the button where there is nowhere to go rather than offering one that answers with a refusal; the desktop keeps its menu entry either way, because a menu that grew and shrank would be a menu whose items move. One thing found while writing the test and deliberately not changed. The pass that follows every write on this screen reports what it moved and supersedes the confirmation — for a save and a delete as much as for a move — so the move's own sentence is what somebody sees offline. The test asserts it in that state and says why. Making confirmations survive their own sync pass is a question about the whole screen rather than about this. Four places said an item could never be moved, two of them sentences on screen in both heads. All four now say what is true, including the design gaps document, where the chevron beside the vault name stays undrawn for a different reason: a chevron on a subtitle implies an edit, and this is a re-seal, a new id and two references left behind.
This commit is contained in:
@@ -1817,8 +1817,58 @@ internal sealed partial class VaultViewModel(
|
||||
/// while the deletion question is up it would offer to ask it again — which is the rule
|
||||
/// <see cref="ShowsHostActions"/> has always carried for the row of buttons these two replaced. The
|
||||
/// question takes CONNECT's place in the footer for the same reason it took DELETE's.
|
||||
/// <para>
|
||||
/// The move panel is in that list too and for the same reason. It takes the footer as well, so leaving
|
||||
/// CONNECT under it would put two things in one row — and the menu it came from would still be offering
|
||||
/// to open it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool ShowsHostPaneActions => IsShowingHostDetail && !IsConfirmingHostDeletion;
|
||||
internal bool ShowsHostPaneActions =>
|
||||
IsShowingHostDetail && !IsConfirmingHostDeletion && !IsMovingHost;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel asking which vault to move the selected host to is up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The armed-state idiom this window uses everywhere instead of a modal, and here it carries a choice
|
||||
/// rather than a yes: the question is not "are you sure" but "which vault", and the sentence beside it
|
||||
/// says what will be left behind. See <see cref="MoveHost"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||||
private bool isMovingHost;
|
||||
|
||||
/// <summary>Which host the open move panel is about. Null when it is closed.</summary>
|
||||
/// <remarks>
|
||||
/// Held rather than read off the selection, so the panel survives a reload replacing every row object —
|
||||
/// see <see cref="OnSelectedHostChanged"/>, which is the only thing that reads it.
|
||||
/// </remarks>
|
||||
private Guid? movingHostId;
|
||||
|
||||
/// <summary>Where the selected host could be moved: every vault this session can write to but its own.</summary>
|
||||
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedMoveVault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected host has anywhere to move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Asked so the phone can leave the button out rather than offer one that answers with a refusal — it
|
||||
/// has room for two buttons under a host and no room to explain a third that does nothing. The desktop
|
||||
/// keeps its menu entry either way: a menu that grew and shrank would be a menu whose items move.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It counts vaults rather than merely asking whether there are two, because the answer is per host: a
|
||||
/// host already in the only other writable vault has nowhere to go, and a read-only vault is not
|
||||
/// somewhere anything can be moved to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool CanMoveSelectedHost =>
|
||||
SelectedHost is { IsReadOnly: false } row
|
||||
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
|
||||
|
||||
/// <summary>
|
||||
/// What the drawer's header says it is about.
|
||||
@@ -1839,11 +1889,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// The line under it: which keychain this is filed in, or what a group is for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
|
||||
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
|
||||
/// under different keys — so a control offering the move would be offering something no layer below
|
||||
/// this can do. Where a *new* item goes is chosen on the keychain screen's own picker; see
|
||||
/// <see cref="TargetVaults"/>.
|
||||
/// The vault's name and not a picker for it, although the design draws one with a chevron. A host can
|
||||
/// be moved between vaults now — see <see cref="MoveHost"/> — and it is still not a field: the move is
|
||||
/// a re-seal under another key and a tombstone under this one, so binding it to a control that saves
|
||||
/// with the rest of the form would let somebody correcting a port move a machine by leaving a picker
|
||||
/// where they found it. It has its own panel and its own button. Where a *new* item goes is chosen on
|
||||
/// the keychain screen's own picker; see <see cref="TargetVaults"/>.
|
||||
/// </remarks>
|
||||
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
|
||||
{
|
||||
@@ -1961,9 +2012,10 @@ internal sealed partial class VaultViewModel(
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Only while creating, and only where there is more than one vault to choose between. An existing
|
||||
/// host's vault is not editable and the picker is not shown disabled beside it: the two are encrypted
|
||||
/// under different keys, so moving an item is a delete and a retype rather than a save — see the note
|
||||
/// on the drawer's header, which says where the host is filed.
|
||||
/// host's vault is not a field of this form and the picker is not shown disabled beside it: the two
|
||||
/// are encrypted under different keys, so moving one is a re-seal and a tombstone rather than a save.
|
||||
/// That is offered — by <see cref="MoveHost"/>, from the pane's own menu — and it is a separate act
|
||||
/// precisely because it must not happen as a side effect of saving something else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Hidden at one vault rather than shown with a single option, which is the rule
|
||||
@@ -4907,6 +4959,185 @@ internal sealed partial class VaultViewModel(
|
||||
return imported;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the panel that asks which vault the selected host should move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A panel rather than a picker in the host editor, and the reason is what a move is underneath: the
|
||||
/// item is re-sealed under another vault's key and the one it came from gets a tombstone — see
|
||||
/// <c>VaultItemRepository.MoveAsync</c>. That is not a field of the host and must not be saved with
|
||||
/// one, or somebody correcting a port would move a machine into a colleague's vault by leaving a
|
||||
/// picker where they found it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for a host written by a newer client, exactly as editing one is: the move re-encodes the
|
||||
/// payload, so a field this build cannot represent would be dropped on the way across.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void MoveHost()
|
||||
{
|
||||
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This host was written by a newer version of DodoSSH. Moving it would re-encode it "
|
||||
+ "here and lose what this build cannot read. Update first.";
|
||||
return;
|
||||
}
|
||||
|
||||
BuildMoveVaultChoices(row.VaultId);
|
||||
|
||||
if (MoveVaultChoices.Count == 0)
|
||||
{
|
||||
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
|
||||
// somebody in a team whose only other vault is read-only sees.
|
||||
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Disarms a deletion aimed at the same host. Two questions about one machine, one of which
|
||||
// destroys it, is not a pane anybody should have to read carefully.
|
||||
PendingDeletion = null;
|
||||
movingHostId = row.EntityId;
|
||||
IsMovingHost = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the move panel.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelMoveHost()
|
||||
{
|
||||
IsMovingHost = false;
|
||||
movingHostId = null;
|
||||
MoveVaultChoices.Clear();
|
||||
SelectedMoveVault = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the selected host into the chosen vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The group and the tags are left behind, and that is the whole of what makes this honest.</b> Both
|
||||
/// are items of the vault the host is leaving: the group picker in the editor offers one vault's groups
|
||||
/// and the tag chips are drawn from one vault's tags, so a host carrying either across would point at
|
||||
/// something the destination does not contain. On this machine it would still resolve — groups and tags
|
||||
/// are resolved across every readable vault — and for everybody else in the destination it would dangle,
|
||||
/// which means the mover and their colleagues would see two different hosts. Cleared and reported beats
|
||||
/// carried and invisible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The key or password binding is kept</b>, and the difference is not inconsistency. Those genuinely
|
||||
/// resolve across vaults — one key on twenty hosts in three vaults is the arrangement they exist for —
|
||||
/// so clearing them would take a working host and make it one that cannot connect. What it can do is say
|
||||
/// when the binding is now in a different vault from the host, because that is exactly what the other
|
||||
/// members of the destination will not be able to resolve.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The row is re-selected by its new id afterwards. A move that left the pane on a host that no longer
|
||||
/// exists would read as the machine having been deleted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmMoveHostAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedHost is not { } row || SelectedMoveVault is not { } target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var name = target.Name;
|
||||
var dropped = WhatWasLeftBehind(row.Host);
|
||||
var stranded = BindingOutside(row.Host, target.VaultId);
|
||||
|
||||
var moved = row.Host with { GroupId = null, TagIds = TagSet.Empty };
|
||||
|
||||
IsMovingHost = false;
|
||||
movingHostId = null;
|
||||
MoveVaultChoices.Clear();
|
||||
SelectedMoveVault = null;
|
||||
|
||||
await RunAsync(
|
||||
"Moving…",
|
||||
async () =>
|
||||
{
|
||||
var entityId = await session.Hosts
|
||||
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
|
||||
|
||||
Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
|
||||
// them and not the other shows the host twice or not at all until the next pass.
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>What the move left behind, said only when it left something.</summary>
|
||||
private static string WhatWasLeftBehind(HostSecret host) =>
|
||||
(host.GroupId is not null, host.TagIds.Count > 0) switch
|
||||
{
|
||||
(true, true) => " Its group and tags were left behind — both belong to the vault it came from.",
|
||||
(true, false) => " Its group was left behind — a group belongs to the vault it is in.",
|
||||
(false, true) => " Its tags were left behind — a tag belongs to the vault it is in.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The warning about a key or password that is not in the vault the host has moved to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Named rather than counted, because which one it is decides what to do about it — and the answer is
|
||||
/// usually to put a copy of that key in the destination vault, which needs to know which key.
|
||||
/// </remarks>
|
||||
private string BindingOutside(HostSecret host, Guid vaultId)
|
||||
{
|
||||
if (host.SshKeyId is { } keyId
|
||||
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
|
||||
&& key.VaultId != vaultId)
|
||||
{
|
||||
return $" It still authenticates with the key '{key.Label}', which is in another vault — "
|
||||
+ "everybody else in this one will find that binding unresolvable.";
|
||||
}
|
||||
|
||||
if (host.CredentialId is { } credentialId
|
||||
&& Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential
|
||||
&& credential.VaultId != vaultId)
|
||||
{
|
||||
return $" It still authenticates with the password '{credential.Label}', which is in another "
|
||||
+ "vault — everybody else in this one will find that binding unresolvable.";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Fills the move panel's picker with every vault this session can write to but that one.</summary>
|
||||
private void BuildMoveVaultChoices(Guid vaultId)
|
||||
{
|
||||
MoveVaultChoices.Clear();
|
||||
|
||||
foreach (var choice in session.ReadableVaults
|
||||
.Where(vault => vault.CanWrite && vault.VaultId != vaultId)
|
||||
.OrderByDescending(vault => vault.IsPersonal)
|
||||
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
|
||||
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal)))
|
||||
{
|
||||
MoveVaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -7039,10 +7270,10 @@ internal sealed partial class VaultViewModel(
|
||||
/// Moves a half-typed host into the vault just chosen for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only while creating. An existing host's vault is fixed, and this guard is what makes that true
|
||||
/// rather than the view merely not drawing the control: an item cannot be moved between vaults, so a
|
||||
/// path that reassigned this on an edit would write the host into a second vault and leave the
|
||||
/// original behind.
|
||||
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
|
||||
/// the control. An existing host <em>can</em> change vaults — see <see cref="MoveHost"/> — but not this
|
||||
/// way and not as part of a save: reassigning it here on an edit would write the host into a second
|
||||
/// vault and leave the original behind, which is a fork rather than a move.
|
||||
/// </remarks>
|
||||
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||
{
|
||||
@@ -7427,6 +7658,7 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(IsDrawerOpen));
|
||||
OnPropertyChanged(nameof(IsShowingHostDetail));
|
||||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||||
OnPropertyChanged(nameof(CanMoveSelectedHost));
|
||||
|
||||
// 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
|
||||
@@ -7434,6 +7666,17 @@ internal sealed partial class VaultViewModel(
|
||||
SelectedSidebarRow = value;
|
||||
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
|
||||
|
||||
// The move panel goes with the selection, as the deletion question does — and by entity id for the
|
||||
// same reason DisarmIfAimedElsewhere compares them: a background pass replaces every row object in
|
||||
// the list, so a panel closed on row identity would fold up once a minute under somebody who was
|
||||
// still choosing a vault in it. A click onto a different host is the case that needs handling, and
|
||||
// it is cleared rather than re-aimed: which vault to move to is a choice about the host it was
|
||||
// asked for.
|
||||
if (IsMovingHost && movingHostId != value?.EntityId)
|
||||
{
|
||||
CancelMoveHostCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user