Merge branch 'claude/vault-key-sync-sharing-d098aa'
ci / build and test (push) Successful in 2m0s
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 33s

This commit is contained in:
2026-08-06 07:39:32 +02:00
10 changed files with 1223 additions and 32 deletions
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -2053,6 +2054,41 @@ internal sealed partial class VaultViewModel(
SelectedHost is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
/// <summary>
/// Whether the open move panel has a key or password it could bring with the host.
/// </summary>
/// <remarks>
/// <para>
/// Read against the vault in the picker rather than against the host, so choosing a different
/// destination re-asks the question: a key already sitting in the vault the host is going to has nothing
/// to move, and offering to move it there would be offering to do nothing.
/// </para>
/// <para>
/// The binding is the <em>resolved</em> one, so a key the host only inherits from its group counts. That
/// is the case this question matters most in — the group stays behind, so a host that inherited its key
/// arrives naming nothing at all unless the move writes the binding onto it.
/// </para>
/// </remarks>
internal bool HasABindingToBring => BindingOfTheMovingHost() is not null;
/// <summary>What the tick box beside the move picker says.</summary>
internal string BindingToBringQuestion => BindingOfTheMovingHost() is { } binding
? $"Bring the {binding.Noun} '{binding.Label}' too"
: string.Empty;
/// <summary>
/// What bringing it would do to everything else that uses it, and what leaving it would do to the host.
/// </summary>
/// <remarks>
/// Both halves, because both are decisions. The hosts that also authenticate with it are re-aimed at the
/// key's new vault and go on working for whoever can read both — but for the members of the vault it
/// left, it is gone; and a host that arrives without its key is a host its new colleagues cannot connect
/// with. Neither is the wrong answer, which is why this is a question rather than a rule.
/// </remarks>
internal string BindingToBringNote => BindingOfTheMovingHost() is { } binding
? WhatElseUses(binding.Kind, binding.EntityId, binding.Label, besidesHost: movingHostId)
: string.Empty;
/// <summary>
/// Whether the panel asking which vault to move the group to is up.
/// </summary>
@@ -2098,6 +2134,86 @@ internal sealed partial class VaultViewModel(
// opened and its entries do not move. The one place the question decides anything is MoveGroup, which
// asks it by building the picker and saying so when it comes back empty.
/// <summary>
/// Whether the host's move panel is offering to bring the key or password it authenticates with.
/// </summary>
/// <remarks>
/// <para>
/// <b>Off unless it is ticked</b>, and that is not a default chosen for tidiness. Moving a key into a
/// team's vault hands it to everybody who holds that vault's key — it is a disclosure, and the same rule
/// <see cref="TargetVaultId"/> follows applies: filing something where other people can read it is
/// chosen, never defaulted into. Leaving it off is also the state that was there before this question
/// existed, so somebody pressing MOVE without reading gets what they used to get.
/// </para>
/// <para>
/// The alternative — moving the host and quietly copying the key — was rejected for the reason the
/// keychain has one item per key: two items holding the same private half cannot be told apart
/// afterwards, and rotating the key means finding both.
/// </para>
/// </remarks>
[ObservableProperty]
private bool bringsTheBindingAlong;
/// <summary>
/// Whether the panel asking which vault a keychain item should move to is up.
/// </summary>
/// <remarks>
/// The host's panel — see <see cref="IsMovingHost"/> — over on the keychain, where until now a key was
/// stuck in the vault it was typed into for ever. It takes the place of that pane's EDIT and DELETE
/// while it is open, as the deletion question does, so the pane asks one thing at a time.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsItemActions))]
private bool isMovingItem;
/// <summary>Which keychain item the open move panel is about. Null when it is closed.</summary>
/// <inheritdoc cref="movingHostId" path="/remarks" />
private Guid? movingItemId;
/// <summary>Which kind of item that id belongs to, so the confirmation knows which repository to ask.</summary>
private VaultItemKind movingItemKind;
/// <summary>Where that item lives now. Held for the same reason its id is.</summary>
private Guid movingItemVaultId;
/// <summary>Where the selected keychain item could go: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveItemVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveItemVault;
/// <summary>The item the open move panel is about, by name.</summary>
/// <inheritdoc cref="MovingGroupLabel" path="/remarks" />
[ObservableProperty]
private string movingItemLabel = string.Empty;
/// <summary>
/// What else points at the item about to move, said before the move rather than after it.
/// </summary>
/// <remarks>
/// The count is the whole of what makes this decidable. A key is the one item in this vault that other
/// items name, so moving one is never only about the key: every host bound to it and every group lending
/// it is re-aimed at the new id, and somebody about to move a key twenty machines authenticate with
/// should see the twenty before they press it, not read about them in the sentence afterwards.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMovingItemUsage))]
private string movingItemUsage = string.Empty;
/// <summary>Whether anything at all points at the item the move panel is about.</summary>
internal bool HasMovingItemUsage => MovingItemUsage.Length > 0;
/// <summary>
/// Whether the selected keychain item can be moved to another vault.
/// </summary>
/// <remarks>
/// Keys and passwords only. A tag, a bucket and a pin are read from the active vault alone, so "another
/// vault" is not a question any of them has — and a key is the item this exists for: it is the one thing
/// on this screen that other vaults' hosts genuinely authenticate with.
/// </remarks>
internal bool CanMoveSelectedItem =>
MovableRow() is { IsReadOnly: false } item && CanLeaveItsVault(item.VaultId);
/// <summary>
/// What the drawer's header says it is about.
/// </summary>
@@ -2829,7 +2945,7 @@ internal sealed partial class VaultViewModel(
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
/// <inheritdoc cref="ShowsHostActions" />
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion && !IsMovingItem;
// ---- Connecting ----
@@ -6171,19 +6287,35 @@ internal sealed partial class VaultViewModel(
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 };
// Read before the panel is folded away, because all three of these are answered against it.
var bringing = BringsTheBindingAlong ? BindingOfTheMovingHost() : null;
var stranded = bringing is null ? BindingOutside(row, target.VaultId) : string.Empty;
var moved = Detached(row.Host, row.Resolved.Binding);
IsMovingHost = false;
movingHostId = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
BringsTheBindingAlong = false;
await RunAsync(
"Moving…",
async () =>
{
var carried = string.Empty;
// The binding first, so the host can be written naming the id it landed with. An
// interruption between the two leaves the key in the destination and the host still in the
// vault it started in, pointing at a tombstone — visible, and repaired by moving it again.
if (bringing is { } bring)
{
(moved, carried) = await CarriedAlongAsync(
bring, moved, row.EntityId, target.VaultId, cancellationToken)
.ConfigureAwait(true);
}
var entityId = await session.Hosts
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
.ConfigureAwait(true);
@@ -6192,7 +6324,7 @@ internal sealed partial class VaultViewModel(
SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}";
Status = $"Moved '{row.Label}' to {name}.{dropped}{carried}{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
@@ -6210,25 +6342,134 @@ internal sealed partial class VaultViewModel(
_ => string.Empty,
};
/// <summary>
/// The host as it will be written on the other side: no group, no tags, and its binding spelled out.
/// </summary>
/// <remarks>
/// <para>
/// The group and the tags go for the reason <see cref="ConfirmMoveHostAsync"/> gives. <b>The binding is
/// written onto the host when it came from a group</b>, and that is the half this used to lose: the
/// group stays behind, so a host that inherited its key arrived in the destination naming nothing at all
/// and authenticating with nothing — a machine that connected before the move and refused after it, with
/// no sentence anywhere saying why.
/// </para>
/// <para>
/// Only the inherited case writes anything. A host that names its own key already carries it, and one
/// that types its password says so with <c>AsksForPassword</c>, which is an answer rather than a gap.
/// </para>
/// </remarks>
private static HostSecret Detached(HostSecret host, ResolvedBinding binding)
{
var moved = host with { GroupId = null, TagIds = TagSet.Empty };
if (!binding.IsInherited || binding.EntityId is not { } entityId)
{
return moved;
}
return binding.Kind is ResolvedBindingKind.SshKey
? moved with { SshKeyId = entityId }
: moved with { CredentialId = entityId };
}
/// <summary>
/// Takes the host's key or password across with it, and re-aims everything else that named it.
/// </summary>
/// <returns>The host as it should now be written, and what to say about what came with it.</returns>
/// <remarks>
/// The moving host is left out of the re-aim and given the new id directly, because it is about to be
/// written into another vault anyway: re-aiming it would be a save in the vault it is leaving, followed
/// immediately by a tombstone for the row that save had just amended.
/// </remarks>
private async Task<(HostSecret Host, string Note)> CarriedAlongAsync(
MovableBinding bring,
HostSecret moved,
Guid movingHostId,
Guid vaultId,
CancellationToken cancellationToken)
{
var (hosts, groups) = PointingAt(bring.Kind, bring.EntityId);
if (await MoveTheBindingAsync(bring, vaultId, cancellationToken).ConfigureAwait(true)
is not { } landed)
{
return (moved, string.Empty);
}
var reaimed = await ReAimAtAsync(
bring.Kind,
landed,
hosts.Where(host => host.EntityId != movingHostId),
groups,
cancellationToken)
.ConfigureAwait(true);
return (
bring.Kind is ResolvedBindingKind.SshKey
? moved with { SshKeyId = landed }
: moved with { CredentialId = landed },
$" The {bring.Noun} '{bring.Label}' came with it.{WhatFollowedIt(reaimed)}");
}
/// <summary>Re-seals one key or password into another vault, or null when its row has gone.</summary>
/// <remarks>
/// Null rather than a throw, because the row is read from a list a background sync can replace: the
/// honest outcome is a host that moves and keeps naming the key where it was, which is exactly what
/// leaving the tick box alone would have done.
/// </remarks>
private async Task<Guid?> MoveTheBindingAsync(
MovableBinding binding,
Guid vaultId,
CancellationToken cancellationToken)
{
if (binding.Kind is ResolvedBindingKind.SshKey)
{
return Keys.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } key
? null
: await session.SshKeys
.MoveAsync(binding.VaultId, vaultId, binding.EntityId, key.Key, cancellationToken)
.ConfigureAwait(true);
}
return Credentials.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } credential
? null
: await session.Credentials
.MoveAsync(
binding.VaultId, vaultId, binding.EntityId, credential.Credential, cancellationToken)
.ConfigureAwait(true);
}
/// <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.
/// <para>
/// Named rather than counted, because which one it is decides what to do about it — and the answer is to
/// bring that key across, which is the tick box beside the picker and needs to know which key.
/// </para>
/// <para>
/// Read from the resolved binding, so a key the host only inherits is warned about too. It is written
/// onto the host by <see cref="Detached"/> on the way over, so it is genuinely what the moved host
/// authenticates with — and it is the case where somebody is least likely to know a key is involved.
/// </para>
/// </remarks>
private string BindingOutside(HostSecret host, Guid vaultId)
private string BindingOutside(HostRowViewModel row, Guid vaultId)
{
if (host.SshKeyId is { } keyId
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
&& key.VaultId != vaultId)
if (row.Resolved.Binding is not { EntityId: { } entityId } binding)
{
return $" It still authenticates with the key '{key.Label}', which is in another vault — "
return string.Empty;
}
if (binding.Kind is ResolvedBindingKind.SshKey
&& Keys.FirstOrDefault(key => key.EntityId == entityId) is { } stored
&& stored.VaultId != vaultId)
{
return $" It still authenticates with the key '{stored.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
if (binding.Kind is ResolvedBindingKind.Credential
&& Credentials.FirstOrDefault(stored => stored.EntityId == entityId) is { } credential
&& credential.VaultId != vaultId)
{
return $" It still authenticates with the password '{credential.Label}', which is in another "
@@ -6617,6 +6858,452 @@ internal sealed partial class VaultViewModel(
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
}
/// <summary>A keychain item that could be moved, with what the panel needs to say about it.</summary>
/// <param name="Kind">Which list it came from, so the confirmation knows which repository to ask.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Label">What it is called.</param>
/// <param name="VaultId">The vault it is in now.</param>
/// <param name="IsReadOnly">Whether this build can re-encode it. A move re-encodes.</param>
private sealed record MovableItem(
VaultItemKind Kind,
Guid EntityId,
string Label,
Guid VaultId,
bool IsReadOnly);
/// <summary>A key or password a host's move could carry, resolved to the row that holds it.</summary>
/// <param name="Kind">Key or password.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Label">What it is called.</param>
/// <param name="VaultId">The vault it is in now, which is not the one the host is going to.</param>
private sealed record MovableBinding(
ResolvedBindingKind Kind,
Guid EntityId,
string Label,
Guid VaultId)
{
/// <summary>What to call it in a sentence a person reads.</summary>
internal string Noun => Kind is ResolvedBindingKind.SshKey ? "key" : "password";
}
/// <summary>How many things a move re-aimed, and how many it could not.</summary>
/// <param name="Hosts">Hosts whose own binding now names the item's new id.</param>
/// <param name="Groups">Groups whose default now names it.</param>
/// <param name="Refused">
/// Things left naming the old id, because this build cannot re-encode them or this account cannot
/// write to the vault they are in. Counted rather than swallowed: each one is a host that will refuse
/// to connect, and the sentence afterwards says how many.
/// </param>
[StructLayout(LayoutKind.Auto)]
private readonly record struct ReAimed(int Hosts, int Groups, int Refused);
/// <summary>The selected keychain row, when it is one of the kinds a vault can hand to another.</summary>
/// <inheritdoc cref="CanMoveSelectedItem" path="/remarks" />
private MovableItem? MovableRow() => SelectedVaultItem?.Kind switch
{
VaultItemKind.Key when SelectedKey is { } key =>
new MovableItem(VaultItemKind.Key, key.EntityId, key.Label, key.VaultId, key.IsReadOnly),
VaultItemKind.Credential when SelectedCredential is { } credential => new MovableItem(
VaultItemKind.Credential,
credential.EntityId,
credential.Label,
credential.VaultId,
credential.IsReadOnly),
_ => null,
};
/// <summary>Whether there is a vault to move something out of this one into.</summary>
private bool CanLeaveItsVault(Guid vaultId) =>
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != vaultId);
/// <summary>Whether this account may write to one vault at all.</summary>
/// <remarks>
/// Asked before every re-aim. A viewer of a team vault can read the hosts in it and cannot save one, so
/// a key move that tried would queue an operation the server refuses — and the honest answer is to leave
/// that host naming the old id and say so, rather than to fail the move that had already happened.
/// </remarks>
private bool CanWriteTo(Guid vaultId) =>
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId == vaultId);
/// <summary>The binding kind that goes with a keychain row's kind.</summary>
private static ResolvedBindingKind BindingKindOf(VaultItemKind kind) =>
kind is VaultItemKind.Key ? ResolvedBindingKind.SshKey : ResolvedBindingKind.Credential;
/// <summary>
/// Everything that names one key or password by id: the hosts that bind it and the groups that lend it.
/// </summary>
/// <remarks>
/// The hosts' <em>own</em> ids rather than their resolved bindings, which is the opposite of what
/// <see cref="HostsBoundTo"/> reads and is right for the opposite reason. That one warns a person, so it
/// counts everybody who would stop connecting, inherited or not. This one drives writes: a host that
/// inherits its key names nothing, so rewriting it would put a binding on a host that never had one —
/// the group it inherits from is in this list and is the one thing that has to change.
/// </remarks>
private (List<HostRowViewModel> Hosts, List<HostGroupRowViewModel> Groups) PointingAt(
ResolvedBindingKind kind,
Guid entityId)
{
var hosts = Hosts
.Where(row => OwnBinding(row.Host, kind) == entityId)
.ToList();
var groups = Groups
.Where(row => DefaultBinding(row.Group, kind) == entityId)
.ToList();
return (hosts, groups);
}
private static Guid? OwnBinding(HostSecret host, ResolvedBindingKind kind) =>
kind is ResolvedBindingKind.SshKey ? host.SshKeyId : host.CredentialId;
private static Guid? DefaultBinding(HostGroupSecret group, ResolvedBindingKind kind) =>
kind is ResolvedBindingKind.SshKey ? group.DefaultSshKeyId : group.DefaultCredentialId;
/// <summary>
/// Points everything that named a moved key or password at the id it landed with.
/// </summary>
/// <remarks>
/// <para>
/// <b>Without this a move is a deletion with extra steps.</b> An item re-sealed into another vault takes
/// a new id — see <c>VaultItemRepository.MoveAsync</c> — so every host bound to the old one would be
/// left naming a tombstone and would refuse to connect rather than fall back to a typed password. The
/// bindings themselves cross vaults perfectly well; it is only the id that changes.
/// </para>
/// <para>
/// A host this build cannot re-encode, or one in a vault this account cannot write to, is skipped and
/// counted. Failing the whole move instead would be worse: the item has already landed, and the
/// alternative to a partial re-aim is none at all.
/// </para>
/// </remarks>
private async Task<ReAimed> ReAimAtAsync(
ResolvedBindingKind kind,
Guid landedId,
IEnumerable<HostRowViewModel> hosts,
IEnumerable<HostGroupRowViewModel> groups,
CancellationToken cancellationToken)
{
var rebound = 0;
var relent = 0;
var refused = 0;
foreach (var host in hosts)
{
if (host.IsReadOnly || !CanWriteTo(host.VaultId))
{
refused++;
continue;
}
await session.Hosts
.UpdateAsync(
host.VaultId,
host.EntityId,
kind is ResolvedBindingKind.SshKey
? host.Host with { SshKeyId = landedId }
: host.Host with { CredentialId = landedId },
cancellationToken)
.ConfigureAwait(true);
rebound++;
}
foreach (var group in groups)
{
if (group.IsReadOnly || !CanWriteTo(group.VaultId))
{
refused++;
continue;
}
await session.HostGroups
.UpdateAsync(
group.VaultId,
group.EntityId,
kind is ResolvedBindingKind.SshKey
? group.Group with { DefaultSshKeyId = landedId }
: group.Group with { DefaultCredentialId = landedId },
cancellationToken)
.ConfigureAwait(true);
relent++;
}
return new ReAimed(rebound, relent, refused);
}
/// <summary>
/// The key or password the open host move panel could carry, or null when there is nothing to carry.
/// </summary>
/// <remarks>
/// Null in four cases, and each is a case where the tick box would be a lie: the host authenticates with
/// a typed password, the binding dangles already, the item is in the vault the host is going to, or it is
/// one this build cannot re-encode.
/// </remarks>
private MovableBinding? BindingOfTheMovingHost()
{
if (!IsMovingHost
|| movingHostId is not { } hostId
|| Hosts.FirstOrDefault(row => row.EntityId == hostId) is not { } host
|| SelectedMoveVault is not { } target
|| host.Resolved.Binding is not { EntityId: { } entityId } binding)
{
return null;
}
return binding.Kind switch
{
ResolvedBindingKind.SshKey =>
Keys.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } key
&& key.VaultId != target.VaultId
&& CanWriteTo(key.VaultId)
? new MovableBinding(binding.Kind, entityId, key.Label, key.VaultId)
: null,
ResolvedBindingKind.Credential =>
Credentials.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } stored
&& stored.VaultId != target.VaultId
&& CanWriteTo(stored.VaultId)
? new MovableBinding(binding.Kind, entityId, stored.Label, stored.VaultId)
: null,
_ => null,
};
}
/// <summary>Re-asks the binding question, which is answered against the vault in the picker.</summary>
private void TheBindingQuestionChanged()
{
OnPropertyChanged(nameof(HasABindingToBring));
OnPropertyChanged(nameof(BindingToBringQuestion));
OnPropertyChanged(nameof(BindingToBringNote));
}
partial void OnSelectedMoveVaultChanged(VaultChoiceViewModel? value) => TheBindingQuestionChanged();
partial void OnIsMovingHostChanged(bool value) => TheBindingQuestionChanged();
/// <summary>What else authenticates with one item, for the tick box beside the host's picker.</summary>
private string WhatElseUses(ResolvedBindingKind kind, Guid entityId, string label, Guid? besidesHost)
{
var (hosts, groups) = PointingAt(kind, entityId);
var others = hosts.Count(row => row.EntityId != besidesHost);
return Users(others, groups.Count, "other host") is not { Length: > 0 } phrase
? $"Nothing else authenticates with '{label}', so nothing is left behind by bringing it."
: $"Also used by {phrase}, which will be re-aimed at it in its new vault — and for anybody else "
+ "in the vault it leaves, it is gone.";
}
/// <summary>What uses one item, for the keychain's own move panel.</summary>
private string WhatUses(ResolvedBindingKind kind, Guid entityId)
{
var (hosts, groups) = PointingAt(kind, entityId);
return Users(hosts.Count, groups.Count, "host") is not { Length: > 0 } phrase
? string.Empty
: $"Used by {phrase}, which will be re-aimed at it in the vault it moves to.";
}
/// <summary>The hosts and groups that name something, counted into a phrase.</summary>
/// <remarks>
/// Empty when nothing does, so each caller can say its own sentence about nothing rather than being
/// handed "0 hosts" to put in the middle of one.
/// </remarks>
private static string Users(int hosts, int groups, string hostNoun)
{
var machines = hosts switch
{
0 => string.Empty,
1 => $"one {hostNoun}",
_ => $"{hosts} {hostNoun}s",
};
var shelves = groups switch
{
0 => string.Empty,
1 => "one group",
_ => $"{groups} groups",
};
return (machines, shelves) switch
{
("", "") => string.Empty,
("", _) => shelves,
(_, "") => machines,
_ => $"{machines} and {shelves}",
};
}
/// <summary>
/// Opens the panel that asks which vault the selected key or password should move to.
/// </summary>
/// <remarks>
/// <para>
/// The host's panel again — see <see cref="MoveHost"/> — and the gap it closes is the one the host's
/// move kept running into: moving a machine into a team's vault left the key it authenticates with in
/// the vault it came from, where the team cannot read it. Until now the only remedy was to paste the
/// private half into a second item, which is a private key on a clipboard and two items nobody can tell
/// apart afterwards.
/// </para>
/// <para>
/// Refused for an item 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 MoveSelectedItem()
{
if (MovableRow() is not { } item || AVaultEditorIsInTheWay())
{
return;
}
if (item.IsReadOnly)
{
Status = "This 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;
}
BuildMoveItemVaultChoices(item.VaultId);
if (MoveItemVaultChoices.Count == 0)
{
Status = $"There is nowhere to move '{item.Label}' to: this is the only vault you can write to.";
return;
}
// As the host's panel disarms a deletion aimed at the same host: two questions about one item, one
// of which destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null;
movingItemId = item.EntityId;
movingItemKind = item.Kind;
movingItemVaultId = item.VaultId;
MovingItemLabel = item.Label;
MovingItemUsage = WhatUses(BindingKindOf(item.Kind), item.EntityId);
IsMovingItem = true;
Status = string.Empty;
}
/// <summary>Abandons the keychain's move panel.</summary>
[RelayCommand]
private void CancelMoveItem()
{
if (!IsMovingItem)
{
return;
}
IsMovingItem = false;
movingItemId = null;
MovingItemLabel = string.Empty;
MovingItemUsage = string.Empty;
MoveItemVaultChoices.Clear();
SelectedMoveItemVault = null;
Status = string.Empty;
}
/// <summary>
/// Moves the key or password into the chosen vault, and re-aims everything that named it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The item first, the re-aims after</b>, because each of those has to name the id it landed with.
/// What an interruption between them leaves is a key in its new vault and some hosts still naming the
/// old one, which is visible — those hosts say they cannot resolve their binding — and repaired by
/// binding them again. The other order cannot be written at all.
/// </para>
/// <para>
/// <b>The hosts are re-aimed across every vault they are in, not only the one the key came from.</b> A
/// binding resolves over everything this session can read, which is the arrangement one key on twenty
/// hosts in three vaults exists for — so a re-aim scoped to one vault would quietly break the other two.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveItemAsync(CancellationToken cancellationToken)
{
if (movingItemId is not { } entityId
|| SelectedMoveItemVault is not { } target
|| MovableRow() is not { IsReadOnly: false })
{
return;
}
var kind = movingItemKind;
var from = movingItemVaultId;
var label = MovingItemLabel;
var bindingKind = BindingKindOf(kind);
var (hosts, groups) = PointingAt(bindingKind, entityId);
var name = target.Name;
var key = Keys.FirstOrDefault(row => row.EntityId == entityId);
var credential = Credentials.FirstOrDefault(row => row.EntityId == entityId);
CancelMoveItemCommand.Execute(null);
await RunAsync(
"Moving…",
async () =>
{
var landed = kind is VaultItemKind.Key
? await session.SshKeys
.MoveAsync(from, target.VaultId, entityId, key!.Key, cancellationToken)
.ConfigureAwait(true)
: await session.Credentials
.MoveAsync(from, target.VaultId, entityId, credential!.Credential, cancellationToken)
.ConfigureAwait(true);
var reaimed = await ReAimAtAsync(
bindingKind, landed, hosts, groups, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// By its new id, as a moved host's pane is: leaving the pane on the row it came from would
// read as the item having been deleted rather than moved.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == landed);
Status = $"Moved '{label}' to {name}.{WhatFollowedIt(reaimed)}";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>What the re-aim achieved, said as the counts somebody can check against the cards.</summary>
private static string WhatFollowedIt(ReAimed reaimed)
{
var followed = Users(reaimed.Hosts, reaimed.Groups, "host") is { Length: > 0 } phrase
? $" {phrase} now point at it there."
: string.Empty;
var left = reaimed.Refused switch
{
0 => string.Empty,
1 => " One thing that used it could not be rewritten here and still names the old item; it will "
+ "refuse to connect until it is bound again.",
_ => $" {reaimed.Refused} things that used it could not be rewritten here and still name the old "
+ "item; they will refuse to connect until they are bound again.",
};
return followed + left;
}
/// <summary>Fills the keychain move panel's picker with every vault this session can write to but that one.</summary>
private void BuildMoveItemVaultChoices(Guid vaultId)
{
MoveItemVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveItemVaultChoices.Add(choice);
}
SelectedMoveItemVault = MoveItemVaultChoices.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
@@ -9392,6 +10079,32 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>Adds the bucket rows to the table, when the table is showing them.</summary>
/// <remarks>
/// Out of <see cref="RebuildVaultItems"/> for the reason <see cref="AddTagRows"/> is — length — and this
/// is the arm that left rather than the newest one, because a rebuild that also has to say whether the
/// selected row can be moved has one line more than it can hold.
/// </remarks>
private void AddBucketRows()
{
if (Section is not (VaultSection.All or VaultSection.Buckets))
{
return;
}
foreach (var store in ObjectStores)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.ObjectStore,
store.EntityId,
store.Label,
"BUCKET",
store.Description,
store.Badge,
store.HasUnsyncedChanges));
}
}
/// <summary>
/// Refills the vault table from the typed lists.
/// </summary>
@@ -9443,21 +10156,7 @@ internal sealed partial class VaultViewModel(
}
AddTagRows();
if (Section is VaultSection.All or VaultSection.Buckets)
{
foreach (var store in ObjectStores)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.ObjectStore,
store.EntityId,
store.Label,
"BUCKET",
store.Description,
store.Badge,
store.HasUnsyncedChanges));
}
}
AddBucketRows();
// The selection survives a reload, as every other list's does, and for the same reason: a background
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
@@ -9467,6 +10166,11 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage));
// A reload replaces every row object, and the selection is restored by id — so the setter above may
// not have fired even though the row this answers about is a different instance. Asked again here,
// because the answer decides whether the pane draws MOVE at all.
OnPropertyChanged(nameof(CanMoveSelectedItem));
}
/// <remarks>
@@ -9508,6 +10212,18 @@ internal sealed partial class VaultViewModel(
default:
break;
}
// After the switch, not with the three above it: this one is answered from the typed selection the
// switch has just made, so asking before it would answer about the row that was selected before.
OnPropertyChanged(nameof(CanMoveSelectedItem));
// The move panel names one item and its picker is built from that item's vault, so a selection that
// has gone elsewhere has left it aimed at something nobody is looking at. The deletion question
// above is disarmed the same way and for the same reason.
if (IsMovingItem && movingItemId != value?.EntityId)
{
CancelMoveItemCommand.Execute(null);
}
}
/// <remarks>