diff --git a/README.md b/README.md
index a4be75d..5477b6d 100644
--- a/README.md
+++ b/README.md
@@ -424,8 +424,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
What a move cannot do is reach a machine that has already synced the host, which is the same limit
-everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
-to" picker on the Keychain screen and cannot be moved yet.
+everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
+Keychain screen and cannot be moved yet.
+
+**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
+own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
+way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
+personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
+way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
+is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
+key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
+nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
+destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
+way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
+
+**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
+on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
+the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
+every group lending it as a default, across every vault you can write to. Without that the move would be a
+deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
+rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
+client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
+afterwards. The panel says what points at the key before you press it, not after.
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
diff --git a/docs/android-port.md b/docs/android-port.md
index 4ae7fc9..6a137b4 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -568,9 +568,16 @@ go at 360dp:
believing they typed an `l`.
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
- and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
- draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
- connections there are. That sheet is the head's first control that could be drawn over the renderer, so
+ and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
+ the session pills, and a `+` raising a sheet with the three connections there are.
+
+ *A shell rather than the surface, and the two parted company once that surface gained a connect page.*
+ With nothing running, Connections is a box, a CONNECT button and the machines connected to before — a
+ page in everything but which enum it is in — so `RefreshChrome` keeps the bar (and, wide, the rail) under
+ it and the Connections entry lights for the first time. It is the one screen reachable by closing your
+ last tab, and collapsing the nav there left the system back gesture as the only route to Hosts or
+ Settings. The header is not part of that: the surface draws its own bar, and a vault header above it is
+ the second row of chrome this head exists to avoid. That sheet is the head's first control that could be drawn over the renderer, so
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
WebView actually composites above Avalonia content is still the unverified question recorded below, and
collapsing is correct under either answer.
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index cec0a58..06dd701 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -446,6 +446,31 @@ target lands. Anything still in the source vault is a partial move, which is sur
not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves
hosts behind and never a shelf with nothing on it.
+### 3.3b Moving a key, and moving a host with its key · **needs a second vault**
+
+In your personal vault: add an SSH key, then two hosts that both authenticate with it. On the Keychain
+screen select the key and press **MOVE**, and read the panel before choosing the shared vault.
+
+**Pass:** the panel says what uses the key — "Used by 2 hosts…" — before anything happens. Afterwards the key
+carries the destination's badge under an id it did not have a moment ago, *and both hosts still say `key`
+under their names* and still connect. The sentence names the vault and the two hosts that followed it.
+
+Then the other direction: with a key back in your personal vault and a host bound to it, choose **Move to
+another vault…** on the host and pick the shared vault. The tick box under the picker offers to bring the
+key, unticked, with the count of what else uses it underneath. Leave it and press MOVE; then move the host
+back, tick it, and press MOVE again.
+
+**Pass:** unticked, the host lands in the shared vault and the status line says the key it authenticates with
+is in another vault and will not resolve for anybody else there. Ticked, the key lands in the shared vault
+too and the host still says `key`. Choosing a *different* vault in the picker re-asks the question, and the
+box disappears when the destination is the vault the key is already in.
+
+**Failure means:** a host that says `password` after either move is the re-aim not having happened — the item
+takes a new id in the destination, so every host bound to it and every group lending it has to be rewritten
+as it lands, and a host bound to something its vault no longer holds refuses to connect rather than falling
+back to a typed password. A tick box that is ticked when the panel opens is worse than a bug: it moves a
+private key into a shared vault on the strength of a decision nobody made.
+
### 3.4 A group deleted on another machine · **needs two machines**
Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync
@@ -1114,6 +1139,29 @@ with both hosts and both ticks still there.
"delete prod-db?" is not a confirmation anybody reads. A question that cleared the ticks on KEEP is a
selection thrown away for declining to destroy it.
+### 8.14a The key question is asked for one host and for a move · **needs two writable vaults**
+
+With a host bound to a key that is in the vault it is leaving, tick that host alone and choose **Move to
+vault**.
+
+**Pass:** under the picker, an unticked box offering to bring the key, with the count of what else uses it
+underneath. Choosing a different destination re-asks it, and it disappears when the destination is the vault
+the key is already in — the same behaviour as the desktop's, which 3.3 measures.
+
+Now choose **Copy to vault** on the same host.
+
+**Pass:** no box at all. A copy that took the key away would leave the original — still sitting in the vault
+it started in — unable to connect, which is the one thing "copy" promises not to do.
+
+Tick a second host and open **Move to vault** again.
+
+**Pass:** still no box. Which key to carry is a fact about one machine, and a selection of six has six
+answers.
+
+**Failure means:** a box under a copy is `ChosenBindingToBring` no longer reading
+`ChosenHostsAreBeingCopied`, and it moves a private key out from under a host that is still using it. A box
+that is ticked when the panel opens is worse than a bug for the reason 3.3 gives.
+
### 8.15 A tap connects, and a long press chooses · **the one worth the most care**
On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once.
@@ -1320,12 +1368,13 @@ headless surface has a native view to answer with. `docs/android-port.md` still
Open a shell from HOSTS.
-**Pass:** the vault header, the session strip and the four-entry bottom bar are all gone. What is left is
+**Pass:** the vault header, the session strip and the three-entry bottom bar are all gone. What is left is
one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
back: all three come back, the tab is still in the strip and its dot is still green.
-**Failure means:** one of the three rows is not bound on `IsShowingPages`, or the terminal is being reached
-by a route that leaves `Surface` on `Page`.
+**Failure means:** the strip is not bound on `IsShowingPages`, `RefreshChrome` is not reading `HasTabs`, or
+the terminal is being reached by a route that leaves `Surface` on `Page`. A bar still there **with a shell
+showing** is the one that matters: that is the third of the screen this arrangement exists to give back.
### 11.2 The connect menu is not drawn over the renderer · **the important one**
@@ -1391,6 +1440,14 @@ Close every tab, then press Connections in the bottom bar.
keychain has ever connected to anything — a RECENT list underneath. The whole thing scrolls with the
keyboard up, and the password box shows dots with **no suggestion strip** above the keyboard.
+**And the bottom bar is still there, with Connections lit.** Press Hosts: it goes straight there, without
+the back gesture. Then open a shell — the bar goes, which is 11.1. Wide, the same holds for the rail.
+
+**Failure means:** for the bar, `RefreshChrome` is back to collapsing the nav for the whole terminal
+surface rather than for a shell — which strands anybody who reaches this screen by closing their last tab.
+For an unlit Connections entry, `IsCurrent` lost its binding: on this screen it is the only true thing the
+bar could say about where you are.
+
Type a machine you can reach and press CONNECT: a tab appears in the bar and the shell opens. Type
something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning
colour, with no tab and nothing dialled.
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
index 9ae395b..622cc11 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
@@ -158,6 +158,28 @@
+
+
+
+
+
+
@@ -180,6 +191,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneRail.axaml b/src/DodoSSH.Client.Android/Views/PhoneRail.axaml
index 8a5bc13..fc6d7be 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneRail.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneRail.axaml
@@ -134,15 +134,17 @@
-
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
index b437645..b37e47c 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
@@ -25,8 +25,9 @@
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
- bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
- surface is either a page or a terminal, and these are the chrome a page has.
+ bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
+ computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
+ is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
@@ -316,10 +317,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -125,6 +145,15 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
+
+
+
+
+
@@ -151,20 +180,66 @@
IsEnabled="{Binding !IsEditing}">
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
index 89e6e68..527227a 100644
--- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
@@ -19,9 +19,13 @@
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
- four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
+ three-entry bottom bar are all collapsed by PhoneShell while a shell is showing, and what replaces them
is the row below: back, the sessions, and the way to open another one.
+ With nothing running it is only the header and the strip. This surface is then the connect page below —
+ a box and the machines connected to before — which is not what the screen was being cleared for, and is
+ the one screen reachable by closing your last tab, so the nav stays. See PhoneShell.RefreshChrome.
+
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
@@ -389,9 +393,10 @@
of them.
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
- but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
- it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
- display.
+ but this surface raises it. The scrim reaching only the screen area is the full display while a shell
+ is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
+ that lands on the add sheet's own answer: the three destinations under the scrim stay tappable, and for
+ a menu of places to connect from, navigating away is a perfectly good way to decide not to.
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
each of the three destinations is a picker, and the shell they open lands back on this surface as a
diff --git a/src/DodoSSH.Client.App/Views/HostDrawer.axaml b/src/DodoSSH.Client.App/Views/HostDrawer.axaml
index 5dc6afd..6842665 100644
--- a/src/DodoSSH.Client.App/Views/HostDrawer.axaml
+++ b/src/DodoSSH.Client.App/Views/HostDrawer.axaml
@@ -689,6 +689,26 @@
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/KeychainScreen.axaml b/src/DodoSSH.Client.App/Views/KeychainScreen.axaml
index 5573b28..f8a86c9 100644
--- a/src/DodoSSH.Client.App/Views/KeychainScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/KeychainScreen.axaml
@@ -297,9 +297,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -102,6 +120,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -150,18 +199,51 @@
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
about to receive a command, at the moment that is worst to be wrong about.
-->
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
index 77621ad..7b5d3a2 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
@@ -35,6 +35,15 @@ internal sealed record InsertTarget(uint? SessionId, string Label)
/// there", which is what says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see .
///
+///
+/// A snippet can be shared, which is why this screen has two vault controls rather than none. The
+/// editor asks which vault a new snippet goes into, and the move panel re-seals an existing one into
+/// another — two controls because they are two different acts. A save writes a payload; a move re-encrypts it
+/// under a second key and tombstones the first, so putting that in the editor would let somebody fixing a
+/// typo hand a command to a team by leaving a picker where they found it. Both live here rather than on
+/// because this screen owns its editor, unlike the host pane; the writing they
+/// ask for is still the vault's.
+///
///
internal sealed partial class SnippetsViewModel : ObservableObject
{
@@ -103,7 +112,100 @@ internal sealed partial class SnippetsViewModel : ObservableObject
[ObservableProperty]
private string status = string.Empty;
- internal bool HasSnippets => vault.Snippets.Count > 0;
+ ///
+ /// The vault the open editor will write to.
+ ///
+ ///
+ /// Latched when the editor opens — the chosen vault for a new snippet, the row's own vault for an
+ /// existing one — rather than read back off the selection at save time. The list this screen shows spans
+ /// every readable vault now, so a save that reached for the active vault instead would fork a colleague's
+ /// snippet into a private copy; and a selection that moved under a half-typed form would send the text to
+ /// whichever row happened to be highlighted. The same reason VaultViewModel.editingHostVaultId
+ /// exists.
+ ///
+ private Guid editorVaultId;
+
+ /// Which vault the open move panel would send the snippet to.
+ private SnippetRowViewModel? moving;
+
+ ///
+ /// The vaults a new snippet may be filed into.
+ ///
+ ///
+ /// Filled from VaultViewModel.TargetVaults, which is already the readable-and-writable set: a
+ /// vault this session cannot read has no key to encrypt with, and one it can read but not write is a
+ /// team vault this account is a viewer of. The options are the shared objects rather than copies, so
+ /// this picker and the keychain screen's show the same names without either being able to move the
+ /// other — what they do not share is the selection.
+ ///
+ internal ObservableCollection EditorVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? editorSelectedVault;
+
+ ///
+ /// Whether the editor should be asking which vault this snippet goes into.
+ ///
+ ///
+ /// Only while creating, and only where there is more than one vault to choose between. An existing
+ /// snippet's vault is not a field of this form — moving it is a re-seal and a tombstone rather than a
+ /// save, offered by — and a control offering one option is a question nobody was
+ /// asked.
+ ///
+ internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;
+
+ /// Whether the panel asking which vault to move the selected snippet to is up.
+ ///
+ /// The armed-state idiom this application uses instead of a modal, carrying a choice rather than a yes:
+ /// the question is not "are you sure" but "which vault".
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
+ private bool isMoving;
+
+ /// Where the selected snippet could be moved: every vault this session can write to but its own.
+ internal ObservableCollection MoveVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? selectedMoveVault;
+
+ ///
+ /// Whether the selected snippet has anywhere to move to.
+ ///
+ ///
+ /// Asked so the phone can leave the button out rather than draw one that answers with a refusal, as the
+ /// hosts screen does. It counts vaults rather than merely asking whether there are two, because the
+ /// answer is per snippet: one already in the only other writable vault has nowhere to go.
+ ///
+ internal bool CanMove =>
+ Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;
+
+ /// Whether the buttons under the selected snippet are showing.
+ ///
+ /// Off while the move panel is up, which takes their place — the same rule the host pane's
+ /// ShowsHostPaneActions carries, and for the same reason: the button that opened the panel would
+ /// otherwise still be there offering to open it again.
+ ///
+ internal bool ShowsSelectionActions => HasSelection && !IsMoving;
+
+ /// The vault the selected snippet lives in, named, or empty when there is only one.
+ ///
+ /// For the detail pane, which is where somebody decides whether to insert a command into a production
+ /// terminal. Who else can read it is part of that, and the badge on the row is gone by the time the pane
+ /// is being read.
+ ///
+ internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;
+
+ /// Whether there is a vault to name beside the selected snippet.
+ internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;
+
+ /// Whether this keychain holds any snippet the screen would draw.
+ ///
+ /// Counts what a hidden vault leaves behind rather than the whole list, so that switching a team's vault
+ /// off and emptying the screen produces the "nothing saved yet" copy rather than a filter box over
+ /// nothing.
+ ///
+ internal bool HasSnippets => vault.Snippets.Any(row => vault.IsVaultShown(row.VaultId));
internal bool HasVisible => Visible.Count > 0;
@@ -136,19 +238,31 @@ internal sealed partial class SnippetsViewModel : ObservableObject
+ "typing it again.";
/// Starts a new snippet.
+ ///
+ /// The vault picker lands on wherever the keychain screen is filing new items — the personal vault
+ /// unless that has been changed — because a snippet put in a team's vault is a command everybody in
+ /// that team can read, and that has to be chosen rather than defaulted into.
+ ///
[RelayCommand]
private void New()
{
+ CloseMovePanel();
+
EditingId = null;
EditorLabel = string.Empty;
EditorCommand = string.Empty;
EditorNotes = string.Empty;
EditorRunsOnInsert = false;
+ BuildEditorVaultChoices(vault.TargetVaultId);
IsEditing = true;
Status = "Adding a snippet.";
}
/// Opens the selected snippet for editing.
+ ///
+ /// The editor writes back to the vault this row came out of, which is what the latch is for. The picker
+ /// is not drawn for an existing snippet: its vault is not a field of this form.
+ ///
[RelayCommand]
private void Edit()
{
@@ -163,11 +277,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
return;
}
+ CloseMovePanel();
+
EditingId = row.EntityId;
EditorLabel = row.Snippet.Label;
EditorCommand = row.Snippet.Command;
EditorNotes = row.Snippet.Notes ?? string.Empty;
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
+ BuildEditorVaultChoices(row.VaultId);
IsEditing = true;
Status = $"Editing {row.Label}.";
}
@@ -197,7 +314,8 @@ internal sealed partial class SnippetsViewModel : ObservableObject
RunsOnInsert = EditorRunsOnInsert,
};
- var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
+ var saved = await vault.SaveSnippetAsync(editorVaultId, EditingId, snippet, cancellationToken)
+ .ConfigureAwait(true);
if (!saved)
{
@@ -224,6 +342,105 @@ internal sealed partial class SnippetsViewModel : ObservableObject
Status = vault.Status;
}
+ ///
+ /// Opens the panel that asks which vault the selected snippet should move to.
+ ///
+ ///
+ ///
+ /// How a snippet gets shared: a command one person keeps becomes one the team holds a key to. A panel
+ /// rather than a picker in the editor, because a move is a re-seal under the destination's key and a
+ /// tombstone in the source — see VaultItemRepository.MoveAsync — and that must not happen as a
+ /// side effect of saving a corrected typo.
+ ///
+ ///
+ /// Refused for a snippet a newer client wrote, as editing one is, and refused with the editor open: two
+ /// forms about the same snippet, one of which moves it, is not something anybody should have to read
+ /// carefully.
+ ///
+ ///
+ [RelayCommand]
+ private void Move()
+ {
+ if (Selected is not { } row || IsEditing)
+ {
+ return;
+ }
+
+ if (row.IsReadOnly)
+ {
+ Status = "This snippet 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;
+ }
+
+ var choices = vault.MoveTargetsBesides(row.VaultId);
+
+ if (choices.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;
+ }
+
+ MoveVaultChoices.Clear();
+
+ foreach (var choice in choices)
+ {
+ MoveVaultChoices.Add(choice);
+ }
+
+ SelectedMoveVault = MoveVaultChoices[0];
+ moving = row;
+ IsMoving = true;
+ Status = string.Empty;
+ }
+
+ /// Abandons the move panel.
+ [RelayCommand]
+ private void CancelMove()
+ {
+ CloseMovePanel();
+ Status = string.Empty;
+ }
+
+ ///
+ /// Moves the selected snippet into the chosen vault.
+ ///
+ ///
+ ///
+ /// The snippet crosses whole. Nothing on it points at an item of the vault it is leaving — a
+ /// snippet is a label, a command and a note — so unlike a host there is no group and no tag to strip,
+ /// and nothing to report as left behind.
+ ///
+ ///
+ /// The row is re-selected by its new id afterwards. A move carries the item into the destination under a
+ /// fresh id, so a screen that went on looking for the old one would leave the pane empty and read as the
+ /// snippet having been deleted.
+ ///
+ ///
+ [RelayCommand]
+ private async Task ConfirmMoveAsync(CancellationToken cancellationToken)
+ {
+ if (moving is not { } row || SelectedMoveVault is not { } target)
+ {
+ return;
+ }
+
+ CloseMovePanel();
+
+ var moved = await vault.MoveSnippetAsync(row, target, cancellationToken).ConfigureAwait(true);
+
+ // The reload inside the move refilled the list, which rebuilt this one and dropped a selection
+ // keyed on an id that no longer exists.
+ if (moved is { } entityId)
+ {
+ Selected = Visible.FirstOrDefault(candidate => candidate.EntityId == entityId);
+ }
+
+ Status = vault.Status;
+ }
+
///
/// Types the selected snippet into the selected terminal, without pressing Enter.
///
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
partial void OnFilterChanged(string value) => Rebuild();
+ ///
+ /// The move panel folds away with the selection it was opened about. Without that, a filter that stopped
+ /// matching the snippet would leave a picker on screen aimed at a row nobody can see.
+ ///
partial void OnSelectedChanged(SnippetRowViewModel? value)
{
+ if (IsMoving && value?.EntityId != moving?.EntityId)
+ {
+ CloseMovePanel();
+ }
+
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns));
+ OnPropertyChanged(nameof(CanMove));
+ OnPropertyChanged(nameof(ShowsSelectionActions));
+ OnPropertyChanged(nameof(SelectionVaultBadge));
+ OnPropertyChanged(nameof(HasSelectionVaultBadge));
}
- partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
+ partial void OnEditingIdChanged(Guid? value)
+ {
+ OnPropertyChanged(nameof(IsCreating));
+ OnPropertyChanged(nameof(ShowsEditorVaultChoice));
+ }
+
+ ///
+ /// Moves a half-typed snippet into the vault just chosen for it.
+ ///
+ ///
+ /// Only while creating, and this guard is what makes that true rather than the view merely not drawing
+ /// the control. An existing snippet can change vaults — see — but not this
+ /// way and not as part of a save: reassigning it here on an edit would write the snippet into a second
+ /// vault and leave the original behind, which is a fork rather than a move.
+ ///
+ partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
+ {
+ if (value is null || EditingId is not null)
+ {
+ return;
+ }
+
+ editorVaultId = value.VaultId;
+ }
+
+ /// Refills the editor's vault picker, landing on the vault the editor will write to.
+ private void BuildEditorVaultChoices(Guid vaultId)
+ {
+ editorVaultId = vaultId;
+
+ EditorVaultChoices.Clear();
+
+ foreach (var choice in vault.TargetVaults)
+ {
+ EditorVaultChoices.Add(choice);
+ }
+
+ // Null where the snippet's vault is one this session cannot write — a team vault this account is a
+ // viewer of. The picker is hidden for an existing snippet anyway, and an empty box is a better
+ // answer than an option that would move the snippet if it were touched.
+ EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
+
+ OnPropertyChanged(nameof(ShowsEditorVaultChoice));
+ }
+
+ private void CloseMovePanel()
+ {
+ IsMoving = false;
+ moving = null;
+ MoveVaultChoices.Clear();
+ SelectedMoveVault = null;
+ }
/// Whether the editor would create a snippet rather than replace one.
internal bool IsCreating => EditingId is null;
@@ -315,14 +596,27 @@ internal sealed partial class SnippetsViewModel : ObservableObject
OnPropertyChanged(nameof(HasSnippets));
OnPropertyChanged(nameof(HasVisible));
OnPropertyChanged(nameof(EmptyMessage));
+ OnPropertyChanged(nameof(CanMove));
}
///
+ ///
/// The command is searched as well as the name and the notes, because half of what somebody remembers
/// about a saved command is a word that was in it.
+ ///
+ ///
+ /// A hidden vault's snippets come off here rather than out of VaultViewModel.Snippets, which is
+ /// the rule that list follows for keys and passwords too: the projection is filtered and the list stays
+ /// whole. Hiding a vault is a preference about what is drawn, not about what the keychain contains.
+ ///
///
private bool Matches(SnippetRowViewModel row)
{
+ if (!vault.IsVaultShown(row.VaultId))
+ {
+ return false;
+ }
+
var needle = Filter.Trim();
if (needle.Length == 0)
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index af9963c..4bdedbc 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -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;
@@ -151,10 +152,31 @@ internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
/// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
/// per click.
///
-internal sealed class SnippetRowViewModel(VaultItem snippet)
+internal sealed class SnippetRowViewModel(VaultItem snippet, Guid vaultId, string vaultName)
{
internal Guid EntityId => snippet.EntityId;
+ /// Which vault this snippet lives in. See .
+ ///
+ /// What makes a snippet shareable rather than private. The list spans every readable vault now, so an
+ /// edit and a deletion both have to return to the vault the snippet came out of — saving a team's
+ /// snippet into the active vault instead would leave the original untouched and put a second copy
+ /// somewhere only the person editing it can see.
+ ///
+ internal Guid VaultId => vaultId;
+
+ /// The vault's display name.
+ internal string VaultName => vaultName;
+
+ ///
+ /// The vault name to print on this row, or empty when there is only one vault to be in.
+ ///
+ ///
+ internal string VaultBadge { get; init; } = string.Empty;
+
+ /// Whether this row has a vault to name.
+ internal bool HasVaultBadge => VaultBadge.Length > 0;
+
internal SnippetSecret Snippet => snippet.Secret;
internal string Label => snippet.Secret.Label;
@@ -2079,6 +2101,41 @@ internal sealed partial class VaultViewModel(
SelectedHost is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
+ ///
+ /// Whether the open move panel has a key or password it could bring with the host.
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// The binding is the resolved 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.
+ ///
+ ///
+ internal bool HasABindingToBring => BindingOfTheMovingHost() is not null;
+
+ /// What the tick box beside the move picker says.
+ internal string BindingToBringQuestion => BindingOfTheMovingHost() is { } binding
+ ? $"Bring the {binding.Noun} '{binding.Label}' too"
+ : string.Empty;
+
+ ///
+ /// What bringing it would do to everything else that uses it, and what leaving it would do to the host.
+ ///
+ ///
+ /// 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.
+ ///
+ internal string BindingToBringNote => BindingOfTheMovingHost() is { } binding
+ ? WhatElseUses(binding.Kind, binding.EntityId, binding.Label, besidesHost: movingHostId)
+ : string.Empty;
+
///
/// Whether the panel asking which vault to move the group to is up.
///
@@ -2124,6 +2181,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.
+ ///
+ /// Whether the host's move panel is offering to bring the key or password it authenticates with.
+ ///
+ ///
+ ///
+ /// Off unless it is ticked, 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
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [ObservableProperty]
+ private bool bringsTheBindingAlong;
+
+ ///
+ /// Whether the panel asking which vault a keychain item should move to is up.
+ ///
+ ///
+ /// The host's panel — see — 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.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowsItemActions))]
+ private bool isMovingItem;
+
+ /// Which keychain item the open move panel is about. Null when it is closed.
+ ///
+ private Guid? movingItemId;
+
+ /// Which kind of item that id belongs to, so the confirmation knows which repository to ask.
+ private VaultItemKind movingItemKind;
+
+ /// Where that item lives now. Held for the same reason its id is.
+ private Guid movingItemVaultId;
+
+ /// Where the selected keychain item could go: every vault this session can write to but its own.
+ internal ObservableCollection MoveItemVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? selectedMoveItemVault;
+
+ /// The item the open move panel is about, by name.
+ ///
+ [ObservableProperty]
+ private string movingItemLabel = string.Empty;
+
+ ///
+ /// What else points at the item about to move, said before the move rather than after it.
+ ///
+ ///
+ /// 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.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasMovingItemUsage))]
+ private string movingItemUsage = string.Empty;
+
+ /// Whether anything at all points at the item the move panel is about.
+ internal bool HasMovingItemUsage => MovingItemUsage.Length > 0;
+
+ ///
+ /// Whether the selected keychain item can be moved to another vault.
+ ///
+ ///
+ /// 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.
+ ///
+ internal bool CanMoveSelectedItem =>
+ MovableRow() is { IsReadOnly: false } item && CanLeaveItsVault(item.VaultId);
+
///
/// What the drawer's header says it is about.
///
@@ -2397,6 +2534,37 @@ internal sealed partial class VaultViewModel(
: "Each host is re-encrypted with the other vault's key, so everybody who holds that key can read it "
+ "and nobody else can. Groups and tags stay behind — both belong to the vault being left.";
+ ///
+ /// Whether the action bar's move panel is offering to bring the key or password along.
+ ///
+ ///
+ ///
+ /// ◆ Asked only where it can be answered: one host, and a move rather than a copy. Which key to
+ /// carry is a fact about one machine, so a selection of six has six answers and no tick can carry them.
+ /// And a copy must never move it: taking the key out of the vault the original is still sitting in would
+ /// leave that original unable to connect, which is the one thing "copy" promises not to do.
+ ///
+ ///
+ /// Off unless it is ticked, on the same terms as the drawer's — see
+ /// , whose reasoning is the whole of this one's.
+ ///
+ ///
+ [ObservableProperty]
+ private bool bringsTheChosenBindingAlong;
+
+ ///
+ internal bool HasAChosenBindingToBring => ChosenBindingToBring() is not null;
+
+ ///
+ internal string ChosenBindingToBringQuestion => ChosenBindingToBring() is { } binding
+ ? $"Bring the {binding.Noun} '{binding.Label}' too"
+ : string.Empty;
+
+ ///
+ internal string ChosenBindingToBringNote => ChosenBindingToBring() is { } binding
+ ? WhatElseUses(binding.Kind, binding.EntityId, binding.Label, besidesHost: TheChosenHost?.EntityId)
+ : string.Empty;
+
/// Where the chosen hosts could go: every vault this session can write to.
///
/// Every one of them rather than "all but their own", which is what the single-host picker offers. A set
@@ -3019,7 +3187,7 @@ internal sealed partial class VaultViewModel(
/// Whether the vault screen's Edit and Delete are showing.
///
- internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
+ internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion && !IsMovingItem;
// ---- Connecting ----
@@ -3527,37 +3695,79 @@ internal sealed partial class VaultViewModel(
/// How many snippets would not decrypt.
///
+ ///
/// No selection to preserve: what a snippet screen selects is its own, and it restores it around this
/// list changing the way every other screen does.
+ ///
+ ///
+ /// Every readable vault, not the active one, which is what makes a snippet shareable. The read is
+ /// the half that has to come first: a snippet moved into a team's vault by the machine that owns it would
+ /// otherwise vanish from the list that moved it, and one a colleague wrote there would never appear at
+ /// all — sharing would look like losing. The same shape as and
+ /// , down to the ordering: the vault new items go into first, then by vault
+ /// name, then by label, because two vaults may hold a snippet called the same thing and which vault it
+ /// is in is the only thing that tells them apart.
+ ///
///
private async Task ReloadSnippetsAsync(CancellationToken cancellationToken)
{
- var listing = await session.Snippets
- .ListAsync(session.ActiveVaultId, cancellationToken)
- .ConfigureAwait(true);
+ var unreadable = 0;
+ var rows = new List();
+
+ var readable = session.ReadableVaults.ToList();
+ var several = readable.Count > 1;
+
+ foreach (var vault in readable)
+ {
+ var listing = await session.Snippets
+ .ListAsync(vault.VaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ unreadable += listing.Unreadable;
+
+ rows.AddRange(listing.Items.Select(
+ item => new SnippetRowViewModel(item, vault.VaultId, vault.Name)
+ {
+ // Only when there is something to tell apart, as the host grid's badge is.
+ VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
+ }));
+ }
Snippets.Clear();
- foreach (var snippet in listing.Items
- .OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture))
+ foreach (var snippet in rows
+ .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
+ .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
+ .ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
- Snippets.Add(new SnippetRowViewModel(snippet));
+ Snippets.Add(snippet);
}
- return listing.Unreadable;
+ return unreadable;
}
/// Stores one snippet, encrypted, and queues it for the server.
+ /// The vault to write it into.
/// The snippet to replace, or null to create one.
/// What to store.
/// Cancellation.
/// Whether it was stored; means the reason is in .
///
+ ///
/// Here rather than on the screen, so the write goes through the same repository, the same outbox and the
/// same immediate push as every other save. The screen decides what a snippet is and nothing
/// else.
+ ///
+ ///
+ /// The vault is a parameter rather than the active one, and that is not tidiness: the screen
+ /// latches it when the editor opens — the chosen vault for a new snippet, the row's own for an existing
+ /// one — because an update sent to the active vault would write a second copy there and leave the team's
+ /// original untouched, which is a fork nobody would see until a colleague asked why the change never
+ /// arrived. The same rule editingHostVaultId carries for hosts.
+ ///
///
internal async Task SaveSnippetAsync(
+ Guid vaultId,
Guid? entityId,
SnippetSecret snippet,
CancellationToken cancellationToken)
@@ -3577,13 +3787,13 @@ internal sealed partial class VaultViewModel(
if (entityId is { } existing)
{
await session.Snippets
- .UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken)
+ .UpdateAsync(vaultId, existing, snippet, cancellationToken)
.ConfigureAwait(true);
}
else
{
await session.Snippets
- .CreateAsync(session.ActiveVaultId, snippet, cancellationToken)
+ .CreateAsync(vaultId, snippet, cancellationToken)
.ConfigureAwait(true);
}
@@ -3600,6 +3810,10 @@ internal sealed partial class VaultViewModel(
}
/// Queues a tombstone for one snippet.
+ ///
+ /// The tombstone goes to the vault the row came out of, which the row carries. Deleting out of the
+ /// active vault instead would tombstone nothing and leave the snippet on screen.
+ ///
internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
{
if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
@@ -3613,7 +3827,7 @@ internal sealed partial class VaultViewModel(
async () =>
{
await session.Snippets
- .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
+ .DeleteAsync(row.VaultId, entityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -3623,6 +3837,76 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
+ ///
+ /// Re-seals one snippet under another vault's key and tombstones the original.
+ ///
+ /// The snippet to move.
+ /// The vault it should end up in.
+ /// Cancellation.
+ /// Its id in the destination, or null when nothing was moved.
+ ///
+ ///
+ /// The write half of sharing a snippet; the panel that asks which vault belongs to the screen, as the
+ /// snippet editor does. See SnippetsViewModel.Move.
+ ///
+ ///
+ /// A snippet crosses whole, which is the one way this is simpler than .
+ /// A host leaves its group and its tags behind because both are items of the vault it came from; a
+ /// snippet is a label, a command and a note, and none of them points at anything — so there is nothing
+ /// to strip and nothing to warn about. What the caller still has to say is that the command is now
+ /// readable by everybody holding the destination's key.
+ ///
+ ///
+ /// Refused for a snippet a newer client wrote, exactly as editing one is: the move re-encodes the
+ /// payload here, so a field this build cannot represent would be dropped on the way across.
+ ///
+ ///
+ internal async Task MoveSnippetAsync(
+ SnippetRowViewModel row,
+ VaultChoiceViewModel target,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ ArgumentNullException.ThrowIfNull(target);
+
+ if (row.IsReadOnly)
+ {
+ Status = "This snippet 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 null;
+ }
+
+ Guid? moved = null;
+
+ await RunAsync(
+ "Moving…",
+ async () =>
+ {
+ moved = await session.Snippets
+ .MoveAsync(row.VaultId, target.VaultId, row.EntityId, row.Snippet, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = $"Moved '{row.Label}' to {target.Name}.";
+ }).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 snippet twice or not at all until the next pass.
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+
+ return moved;
+ }
+
+ /// Every vault this session can write to except one, for a screen that owns its own picker.
+ ///
+ /// The snippets screen's move panel lives on SnippetsViewModel — its editor does too — so it
+ /// needs the same list fills the host's panel from, in the same
+ /// order. Shared rather than written twice, for the reason gives.
+ ///
+ internal IReadOnlyList MoveTargetsBesides(Guid vaultId) =>
+ [.. WritableVaultsBesides(vaultId)];
+
/// How many groups would not decrypt.
///
///
@@ -5174,6 +5458,7 @@ internal sealed partial class VaultViewModel(
IsSendingChosenHostsToAVault = false;
ChosenHostVaultChoices.Clear();
SelectedChosenHostVault = null;
+ BringsTheChosenBindingAlong = false;
Status = string.Empty;
}
@@ -5196,6 +5481,12 @@ internal sealed partial class VaultViewModel(
/// about the wrong ten.
///
///
+ /// The key or password comes too where the tick says so, which is only ever the one-host move —
+ /// see . It is carried before the host is written, so the host
+ /// lands naming the id the key arrived with; that is 's own ordering
+ /// and the reason for it is written there.
+ ///
+ ///
/// One reload and one sync at the end rather than per host. Both are the expensive half, and a run over
/// eleven machines that reloaded eleven times would replace every row in the list under a user watching
/// it.
@@ -5212,30 +5503,26 @@ internal sealed partial class VaultViewModel(
var copying = ChosenHostsAreBeingCopied;
var rows = ChosenHosts;
+ // Read before the panel is folded away, because both are answered against it.
+ var bringing = BringsTheChosenBindingAlong ? ChosenBindingToBring() : null;
+ var only = TheChosenHost;
+
IsSendingChosenHostsToAVault = false;
ChosenHostVaultChoices.Clear();
SelectedChosenHostVault = null;
-
- var done = 0;
- var skipped = 0;
+ BringsTheChosenBindingAlong = false;
await RunAsync(
copying ? "Copying…" : "Moving…",
async () =>
{
- foreach (var row in rows)
- {
- if (row.IsReadOnly || row.VaultId == target.VaultId)
- {
- skipped++;
- continue;
- }
+ var (rewritten, carried) = await CarryTheChosenBindingAsync(
+ bringing, only, target.VaultId, cancellationToken)
+ .ConfigureAwait(true);
- _ = await SendOneHostToAVaultAsync(row, target.VaultId, copying, cancellationToken)
- .ConfigureAwait(true);
-
- done++;
- }
+ var (done, skipped) = await SendEachChosenHostAsync(
+ rows, target.VaultId, (only?.EntityId, rewritten), copying, cancellationToken)
+ .ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
ClearHostChoice();
@@ -5245,7 +5532,7 @@ internal sealed partial class VaultViewModel(
Status = WhatTheRunDid(
done,
skipped,
- $"{verb} {done} host(s) to {target.Name}.",
+ $"{verb} {done} host(s) to {target.Name}.{carried}",
copying ? "nothing was copied" : "nothing was moved");
}).ConfigureAwait(true);
@@ -5254,21 +5541,127 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
+ ///
+ /// Moves the one chosen host's key or password ahead of it, where the tick asked for that.
+ ///
+ ///
+ /// The host as it must now be written — naming the id the key landed with — and what to add to the
+ /// sentence afterwards. Null and empty where nothing was carried.
+ ///
+ ///
+ ///
+ /// Before the hosts rather than after them, which is 's own ordering:
+ /// an interruption between the two leaves the key in the destination and the host still where it started,
+ /// pointing at a tombstone — visible, and repaired by moving it again. The other way round leaves a host
+ /// in a vault whose members cannot read the key it names, which looks like nothing at all until somebody
+ /// tries to connect.
+ ///
+ ///
+ /// The rewritten host is the return value and not a side effect, because the key takes a new id in
+ /// the destination: a caller that went on writing the row's own secret would send the machine across
+ /// still naming the id the key had before it moved, which is a tombstone. That is the one thing this
+ /// whole path exists to avoid.
+ ///
+ ///
+ private async Task<(HostSecret? Payload, string Note)> CarryTheChosenBindingAsync(
+ MovableBinding? bringing,
+ HostRowViewModel? only,
+ Guid target,
+ CancellationToken cancellationToken)
+ {
+ if (bringing is not { } bring || only is null)
+ {
+ return (null, string.Empty);
+ }
+
+ var (payload, carried) = await CarriedAlongAsync(
+ bring,
+ Detached(only.Host, only.Resolved.Binding),
+ only.EntityId,
+ target,
+ cancellationToken)
+ .ConfigureAwait(true);
+
+ return (payload, carried);
+ }
+
+ /// Writes every chosen host into the destination, counting what it passed over.
+ /// The ticked hosts, as the list is holding them.
+ /// The vault they are going to.
+ ///
+ /// The one host a key was carried for and the secret that carry left, or nulls where no key moved.
+ ///
+ /// Whether the originals stay behind.
+ /// The run's own.
+ /// How many hosts were written, and how many were left alone.
+ ///
+ ///
+ /// Everything that decides what crosses is here. The group and the tags are dropped in both
+ /// directions — a host arriving with either would point at an item the destination does not contain —
+ /// and that goes through rather than being done inline, so that a host which only
+ /// inherited its key from the group arrives naming that key rather than naming nothing. The
+ /// group stays behind, so without it the machine would connect before the move and refuse after it, with
+ /// nothing on screen saying why.
+ ///
+ ///
+ /// The one exception is the host a key was carried for: it is written as the carry left it, naming the
+ /// id the key landed with. Detaching it again here would send it across still naming a tombstone, which
+ /// is the one thing that whole path exists to avoid.
+ ///
+ ///
+ /// Read-only rows and hosts already in the destination are skipped rather than refusing the whole run,
+ /// for the reason gives.
+ ///
+ ///
+ private async Task<(int Done, int Skipped)> SendEachChosenHostAsync(
+ IReadOnlyList rows,
+ Guid target,
+ (Guid? HostId, HostSecret? Payload) carried,
+ bool copying,
+ CancellationToken cancellationToken)
+ {
+ var done = 0;
+ var skipped = 0;
+
+ foreach (var row in rows)
+ {
+ if (row.IsReadOnly || row.VaultId == target)
+ {
+ skipped++;
+ continue;
+ }
+
+ var payload = carried is { HostId: { } id, Payload: { } written } && id == row.EntityId
+ ? written
+ : Detached(row.Host, row.Resolved.Binding);
+
+ _ = await SendOneHostToAVaultAsync(row, target, payload, copying, cancellationToken)
+ .ConfigureAwait(true);
+
+ done++;
+ }
+
+ return (done, skipped);
+ }
+
/// Writes one host into another vault, leaving the original behind or not.
///
- /// The group and the tags are dropped on the way across in both directions, which is the one rule the two
- /// verbs share and the reason they share a method: a host arriving with either would point at an item the
- /// destination does not contain.
+ /// One write and no policy: what crosses is settled by . This is a
+ /// method of its own because the two verbs differ in one call and in nothing else.
///
+ /// The host, for the vault and the id it is leaving.
+ /// The vault it is going to.
+ /// What to write over there, already detached from its group and tags.
+ /// Whether the original stays behind.
+ /// The run's own.
/// The entity id the host has in the destination, which nothing here needs.
private Task SendOneHostToAVaultAsync(
HostRowViewModel row,
Guid target,
+ HostSecret payload,
bool copying,
CancellationToken cancellationToken)
{
- var payload = row.Host with { GroupId = null, TagIds = TagSet.Empty };
-
return copying
? session.Hosts.CreateAsync(target, payload, cancellationToken)
: session.Hosts.MoveAsync(row.VaultId, target, row.EntityId, payload, cancellationToken);
@@ -5621,6 +6014,8 @@ internal sealed partial class VaultViewModel(
ChosenHostVaultChoices.Clear();
SelectedChosenHostVault = null;
+ BringsTheChosenBindingAlong = false;
+
IsRegroupingChosenHosts = false;
ChosenHostGroupChoices.Clear();
SelectedChosenHostGroup = null;
@@ -7024,19 +7419,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);
@@ -7045,7 +7456,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
@@ -7063,25 +7474,134 @@ internal sealed partial class VaultViewModel(
_ => string.Empty,
};
+ ///
+ /// The host as it will be written on the other side: no group, no tags, and its binding spelled out.
+ ///
+ ///
+ ///
+ /// The group and the tags go for the reason gives. The binding is
+ /// written onto the host when it came from a group, 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.
+ ///
+ ///
+ /// 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 AsksForPassword, which is an answer rather than a gap.
+ ///
+ ///
+ 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 };
+ }
+
+ ///
+ /// Takes the host's key or password across with it, and re-aims everything else that named it.
+ ///
+ /// The host as it should now be written, and what to say about what came with it.
+ ///
+ /// 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.
+ ///
+ 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)}");
+ }
+
+ /// Re-seals one key or password into another vault, or null when its row has gone.
+ ///
+ /// 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.
+ ///
+ private async Task 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);
+ }
+
///
/// The warning about a key or password that is not in the vault the host has moved to.
///
///
- /// 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.
+ ///
+ /// 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.
+ ///
+ ///
+ /// Read from the resolved binding, so a key the host only inherits is warned about too. It is written
+ /// onto the host by 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.
+ ///
///
- 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 "
@@ -7470,6 +7990,497 @@ internal sealed partial class VaultViewModel(
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
}
+ /// A keychain item that could be moved, with what the panel needs to say about it.
+ /// Which list it came from, so the confirmation knows which repository to ask.
+ /// The item.
+ /// What it is called.
+ /// The vault it is in now.
+ /// Whether this build can re-encode it. A move re-encodes.
+ private sealed record MovableItem(
+ VaultItemKind Kind,
+ Guid EntityId,
+ string Label,
+ Guid VaultId,
+ bool IsReadOnly);
+
+ /// A key or password a host's move could carry, resolved to the row that holds it.
+ /// Key or password.
+ /// The item.
+ /// What it is called.
+ /// The vault it is in now, which is not the one the host is going to.
+ private sealed record MovableBinding(
+ ResolvedBindingKind Kind,
+ Guid EntityId,
+ string Label,
+ Guid VaultId)
+ {
+ /// What to call it in a sentence a person reads.
+ internal string Noun => Kind is ResolvedBindingKind.SshKey ? "key" : "password";
+ }
+
+ /// How many things a move re-aimed, and how many it could not.
+ /// Hosts whose own binding now names the item's new id.
+ /// Groups whose default now names it.
+ ///
+ /// 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.
+ ///
+ [StructLayout(LayoutKind.Auto)]
+ private readonly record struct ReAimed(int Hosts, int Groups, int Refused);
+
+ /// The selected keychain row, when it is one of the kinds a vault can hand to another.
+ ///
+ 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,
+ };
+
+ /// Whether there is a vault to move something out of this one into.
+ private bool CanLeaveItsVault(Guid vaultId) =>
+ session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != vaultId);
+
+ /// Whether this account may write to one vault at all.
+ ///
+ /// 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.
+ ///
+ private bool CanWriteTo(Guid vaultId) =>
+ session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId == vaultId);
+
+ /// The binding kind that goes with a keychain row's kind.
+ private static ResolvedBindingKind BindingKindOf(VaultItemKind kind) =>
+ kind is VaultItemKind.Key ? ResolvedBindingKind.SshKey : ResolvedBindingKind.Credential;
+
+ ///
+ /// Everything that names one key or password by id: the hosts that bind it and the groups that lend it.
+ ///
+ ///
+ /// The hosts' own ids rather than their resolved bindings, which is the opposite of what
+ /// 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.
+ ///
+ private (List Hosts, List 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;
+
+ ///
+ /// Points everything that named a moved key or password at the id it landed with.
+ ///
+ ///
+ ///
+ /// Without this a move is a deletion with extra steps. An item re-sealed into another vault takes
+ /// a new id — see VaultItemRepository.MoveAsync — 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ private async Task ReAimAtAsync(
+ ResolvedBindingKind kind,
+ Guid landedId,
+ IEnumerable hosts,
+ IEnumerable 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);
+ }
+
+ ///
+ /// The key or password the open host move panel could carry, or null when there is nothing to carry.
+ ///
+ ///
+ /// 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.
+ ///
+ private MovableBinding? BindingOfTheMovingHost()
+ {
+ if (!IsMovingHost
+ || movingHostId is not { } hostId
+ || Hosts.FirstOrDefault(row => row.EntityId == hostId) is not { } host
+ || SelectedMoveVault is not { } target)
+ {
+ return null;
+ }
+
+ return MovableBindingOf(host, target.VaultId);
+ }
+
+ ///
+ /// The key or password one host authenticates with, when a move to one vault could carry it.
+ ///
+ /// The machine being moved.
+ /// Where it is going.
+ ///
+ /// ◆ Split out of so the phone's action bar can ask the same
+ /// question its own way. That one reads the desktop drawer's panel — a flag, an id and a picker — and
+ /// the phone's move is over a set with a picker of its own; sharing the panel's state between the two
+ /// is how the heads would come to disagree about which host "the host" is. What they must not disagree
+ /// about is the answer, which is this.
+ ///
+ private MovableBinding? MovableBindingOf(HostRowViewModel host, Guid targetVaultId)
+ {
+ if (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 != targetVaultId
+ && 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 != targetVaultId
+ && CanWriteTo(stored.VaultId)
+ ? new MovableBinding(binding.Kind, entityId, stored.Label, stored.VaultId)
+ : null,
+
+ _ => null,
+ };
+ }
+
+ /// The action bar's answer to the same question, for the one host it can be asked about.
+ ///
+ private MovableBinding? ChosenBindingToBring()
+ {
+ if (!IsSendingChosenHostsToAVault
+ || ChosenHostsAreBeingCopied
+ || TheChosenHost is not { } host
+ || SelectedChosenHostVault is not { } target)
+ {
+ return null;
+ }
+
+ return MovableBindingOf(host, target.VaultId);
+ }
+
+ /// Re-asks the binding question, which is answered against the vault in the picker.
+ private void TheBindingQuestionChanged()
+ {
+ OnPropertyChanged(nameof(HasABindingToBring));
+ OnPropertyChanged(nameof(BindingToBringQuestion));
+ OnPropertyChanged(nameof(BindingToBringNote));
+
+ OnPropertyChanged(nameof(HasAChosenBindingToBring));
+ OnPropertyChanged(nameof(ChosenBindingToBringQuestion));
+ OnPropertyChanged(nameof(ChosenBindingToBringNote));
+ }
+
+ partial void OnSelectedMoveVaultChanged(VaultChoiceViewModel? value) => TheBindingQuestionChanged();
+
+ partial void OnIsMovingHostChanged(bool value) => TheBindingQuestionChanged();
+
+ partial void OnSelectedChosenHostVaultChanged(VaultChoiceViewModel? value) =>
+ TheBindingQuestionChanged();
+
+ partial void OnIsSendingChosenHostsToAVaultChanged(bool value) => TheBindingQuestionChanged();
+
+ /// What else authenticates with one item, for the tick box beside the host's picker.
+ 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.";
+ }
+
+ /// What uses one item, for the keychain's own move panel.
+ 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.";
+ }
+
+ /// The hosts and groups that name something, counted into a phrase.
+ ///
+ /// 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.
+ ///
+ 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}",
+ };
+ }
+
+ ///
+ /// Opens the panel that asks which vault the selected key or password should move to.
+ ///
+ ///
+ ///
+ /// The host's panel again — see — 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [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;
+ }
+
+ /// Abandons the keychain's move panel.
+ [RelayCommand]
+ private void CancelMoveItem()
+ {
+ if (!IsMovingItem)
+ {
+ return;
+ }
+
+ IsMovingItem = false;
+ movingItemId = null;
+ MovingItemLabel = string.Empty;
+ MovingItemUsage = string.Empty;
+ MoveItemVaultChoices.Clear();
+ SelectedMoveItemVault = null;
+ Status = string.Empty;
+ }
+
+ ///
+ /// Moves the key or password into the chosen vault, and re-aims everything that named it.
+ ///
+ ///
+ ///
+ /// The item first, the re-aims after, 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.
+ ///
+ ///
+ /// The hosts are re-aimed across every vault they are in, not only the one the key came from. 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.
+ ///
+ ///
+ [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);
+ }
+
+ /// What the re-aim achieved, said as the counts somebody can check against the cards.
+ 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;
+ }
+
+ /// Fills the keychain move panel's picker with every vault this session can write to but that one.
+ private void BuildMoveItemVaultChoices(Guid vaultId)
+ {
+ MoveItemVaultChoices.Clear();
+
+ foreach (var choice in WritableVaultsBesides(vaultId))
+ {
+ MoveItemVaultChoices.Add(choice);
+ }
+
+ SelectedMoveItemVault = MoveItemVaultChoices.FirstOrDefault();
+ }
+
/// Asks whether the selected host should go.
///
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
@@ -10272,6 +11283,32 @@ internal sealed partial class VaultViewModel(
}
}
+ /// Adds the bucket rows to the table, when the table is showing them.
+ ///
+ /// Out of for the reason 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.
+ ///
+ 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));
+ }
+ }
+
///
/// Refills the vault table from the typed lists.
///
@@ -10323,21 +11360,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.
@@ -10347,6 +11370,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));
}
///
@@ -10388,6 +11416,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);
+ }
}
///
diff --git a/src/DodoSSH.Client.Sync/CredentialRepository.cs b/src/DodoSSH.Client.Sync/CredentialRepository.cs
index ac864bc..8dc1ac8 100644
--- a/src/DodoSSH.Client.Sync/CredentialRepository.cs
+++ b/src/DodoSSH.Client.Sync/CredentialRepository.cs
@@ -48,6 +48,16 @@ public sealed class CredentialRepository(
CancellationToken cancellationToken) =>
credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken);
+ ///
+ ///
+ public Task MoveAsync(
+ Guid fromVaultId,
+ Guid toVaultId,
+ Guid entityId,
+ CredentialSecret credential,
+ CancellationToken cancellationToken) =>
+ credentials.MoveAsync(fromVaultId, toVaultId, entityId, credential, cancellationToken);
+
///
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
credentials.DeleteAsync(vaultId, entityId, cancellationToken);
diff --git a/src/DodoSSH.Client.Sync/SnippetRepository.cs b/src/DodoSSH.Client.Sync/SnippetRepository.cs
index 1f8a2c9..cf802ad 100644
--- a/src/DodoSSH.Client.Sync/SnippetRepository.cs
+++ b/src/DodoSSH.Client.Sync/SnippetRepository.cs
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
CancellationToken cancellationToken) =>
snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
+ ///
+ ///
+ /// What sharing a snippet is, underneath. A snippet has no group, no tags and no key binding — see
+ /// — so unlike a host it crosses whole: nothing about it points at an item
+ /// of the vault it is leaving, and there is consequently nothing to strip on the way across.
+ ///
+ public Task MoveAsync(
+ Guid fromVaultId,
+ Guid toVaultId,
+ Guid entityId,
+ SnippetSecret snippet,
+ CancellationToken cancellationToken) =>
+ snippets.MoveAsync(fromVaultId, toVaultId, entityId, snippet, cancellationToken);
+
///
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
diff --git a/src/DodoSSH.Client.Sync/SshKeyRepository.cs b/src/DodoSSH.Client.Sync/SshKeyRepository.cs
index bcc5ccc..cf70837 100644
--- a/src/DodoSSH.Client.Sync/SshKeyRepository.cs
+++ b/src/DodoSSH.Client.Sync/SshKeyRepository.cs
@@ -47,6 +47,29 @@ public sealed class SshKeyRepository(
CancellationToken cancellationToken) =>
keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
+ ///
+ ///
+ ///
+ /// The same two writes a host's move is, and the reason a key needs one at all is what a vault is for:
+ /// a key created in a personal vault before a team existed is the key the team's hosts authenticate
+ /// with, and until this existed the only way to get it across was to paste the private half into a
+ /// second item and delete the first — which is a private key on a clipboard, and two items nobody can
+ /// tell apart afterwards.
+ ///
+ ///
+ /// It lands with a new id, as everything moved does, so every host and group default naming the
+ /// old one is left pointing at a tombstone. Re-aiming them is the caller's, because only the caller
+ /// knows which of them it is allowed to rewrite — see VaultViewModel.ReAimAtAsync.
+ ///
+ ///
+ public Task MoveAsync(
+ Guid fromVaultId,
+ Guid toVaultId,
+ Guid entityId,
+ SshKeySecret key,
+ CancellationToken cancellationToken) =>
+ keys.MoveAsync(fromVaultId, toVaultId, entityId, key, cancellationToken);
+
///
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
keys.DeleteAsync(vaultId, entityId, cancellationToken);
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 3d7569f..f5e30d7 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -883,6 +883,38 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
}
+ ///
+ ///
+ /// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is
+ /// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the
+ /// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards.
+ ///
+ ///
+ /// The state is set here rather than through MoveCommand, which would refuse: this fixture's
+ /// account holds one vault, and the command declines rather than open a picker with nothing in it. The
+ /// flow that fills it is covered in DodoSSH.Client.App.Tests. The same arrangement, and the same
+ /// reason, as .
+ ///
+ ///
+ [Fact]
+ public async Task TheSnippetsScreenFitsWithTheMovePanelOpen()
+ {
+ await SeedSnippetsAsync();
+
+ var snippets = NewSnippetsScreen();
+ snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
+
+ snippets.MoveVaultChoices.Add(
+ new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
+
+ snippets.SelectedMoveVault = snippets.MoveVaultChoices[0];
+ snippets.IsMoving = true;
+
+ snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it");
+
+ await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel open"), snippets);
+ }
+
[Fact]
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
{
@@ -1919,6 +1951,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private async Task SeedSnippetsAsync()
{
await vault.SaveSnippetAsync(
+ vault.TargetVaultId,
null,
new SnippetSecret
{
@@ -1929,6 +1962,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Token);
await vault.SaveSnippetAsync(
+ vault.TargetVaultId,
null,
new SnippetSecret
{
diff --git a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
index 411d98e..aefdf73 100644
--- a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
@@ -785,6 +785,351 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("only vault you can write to");
}
+ ///
+ ///
+ /// The gap the host's move kept running into. A key typed into a personal vault before the team existed
+ /// is the key the team's machines authenticate with, and until this existed there was no way to get it
+ /// across: the keychain could create and delete, so "moving" a key meant pasting the private half into a
+ /// second item and deleting the first.
+ ///
+ ///
+ /// The re-aim is the half worth the test. An item re-sealed into another vault lands with a new
+ /// id, so without it every host bound to the key would be left naming a tombstone — and a host bound to
+ /// something its vault no longer holds refuses to connect rather than falling back to a typed password.
+ /// A move that did only the first half would look like a success and break two machines.
+ ///
+ ///
+ [Fact]
+ public async Task MovingAKeyToAnotherVault_ReSealsItThereAndReAimsTheHostsThatUsedIt()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+ await vault.LoadAsync(Token);
+
+ var key = await AddKeyAsync(vault, "deploy");
+ key.VaultId.ShouldNotBe(sharedVaultId, "it was typed into the personal vault");
+
+ await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
+ await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
+
+ vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == key.EntityId);
+ vault.CanMoveSelectedItem.ShouldBeTrue("there is a second vault this session can write to");
+
+ vault.MoveSelectedItemCommand.Execute(null);
+
+ vault.IsMovingItem.ShouldBeTrue(vault.Status);
+ vault.ShowsItemActions.ShouldBeFalse("the panel takes the place of EDIT and DELETE");
+ vault.MoveItemVaultChoices.ShouldNotContain(choice => choice.VaultId == key.VaultId);
+
+ // The count, before the move rather than after it. Two machines stop connecting if this is wrong.
+ vault.MovingItemUsage.ShouldContain("2 hosts");
+
+ vault.SelectedMoveItemVault =
+ vault.MoveItemVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ // As the host's move does: the pass that follows every write is made to fail, so the sentence the
+ // move itself wrote is still on the status line to be read.
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmMoveItemCommand.ExecuteAsync(null);
+
+ var moved = vault.Keys.ShouldHaveSingleItem();
+
+ moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ moved.EntityId.ShouldNotBe(key.EntityId, "an id belongs to one vault");
+ moved.Key.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"), "the material crossed intact");
+
+ vault.Hosts.Count.ShouldBe(2);
+ vault.Hosts.ShouldAllBe(host => host.Host.SshKeyId == moved.EntityId);
+
+ vault.Status.ShouldContain("Platform secrets");
+ vault.Status.ShouldContain("2 hosts");
+ }
+
+ ///
+ /// The question this whole panel exists to ask. A binding resolves across vaults, so the moved host goes
+ /// on working for the person who moved it either way — and for the colleagues it has just joined, a host
+ /// whose key stayed behind is one they cannot connect with. Ticked, the key goes too and the host lands
+ /// naming it by the id it landed with.
+ ///
+ [Fact]
+ public async Task MovingAHostWithItsKeyBrought_TakesTheKeyAcrossAndKeepsTheBinding()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var key = await AddKeyAsync(vault, "deploy");
+
+ await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
+
+ vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
+ vault.MoveHostCommand.Execute(null);
+
+ vault.SelectedMoveVault =
+ vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.HasABindingToBring.ShouldBeTrue(vault.Status);
+ vault.BindingToBringQuestion.ShouldContain("deploy");
+ vault.BindingToBringNote.ShouldContain("Nothing else", Case.Insensitive);
+ vault.BringsTheBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
+
+ vault.BringsTheBindingAlong = true;
+
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
+
+ var movedKey = vault.Keys.ShouldHaveSingleItem();
+ var movedHost = vault.Hosts.ShouldHaveSingleItem();
+
+ movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
+
+ vault.Status.ShouldContain("came with it");
+ vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
+ }
+
+ ///
+ ///
+ /// ◆ The same question from the phone's action bar, which is that head's only route to it since the
+ /// connect card went. It is asked in two shapes fewer than the desktop's: one host, because which key
+ /// to carry is a fact about one machine and a selection of six has six answers; and a move rather than a
+ /// copy, because taking the key out from under an original that is staying put would leave that original
+ /// unable to connect.
+ ///
+ ///
+ /// The three shapes are asserted in one test on purpose. What is being pinned is not that the box appears
+ /// but that it appears in exactly one of them — a rule that only reads as a rule when the other two are
+ /// beside it.
+ ///
+ ///
+ [Fact]
+ public async Task TheActionBarAsksAboutTheKey_ForOneHostAndForAMoveOnly()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var key = await AddKeyAsync(vault, "deploy");
+
+ await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
+ await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
+
+ var one = vault.Hosts.Single(
+ row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
+
+ vault.ChooseHostCommand.Execute(one);
+ vault.MoveChosenHostsToVaultCommand.Execute(null);
+
+ vault.SelectedChosenHostVault =
+ vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.HasAChosenBindingToBring.ShouldBeTrue(vault.Status);
+ vault.ChosenBindingToBringQuestion.ShouldContain("deploy");
+ vault.ChosenBindingToBringNote.ShouldContain("one other host", Case.Insensitive);
+ vault.BringsTheChosenBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
+
+ // A copy, which must never take the key: the original stays where it is and would be left bound to
+ // something its own vault no longer holds.
+ vault.CopyChosenHostsToVaultCommand.Execute(null);
+
+ vault.SelectedChosenHostVault =
+ vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.HasAChosenBindingToBring.ShouldBeFalse("a copy that moved the key would break the original");
+
+ // And two hosts, where the question has two answers and no tick can carry them.
+ vault.ToggleHostChoiceCommand.Execute(
+ vault.Hosts.Single(row => string.Equals(row.Label, "prod-web", StringComparison.Ordinal)));
+
+ vault.MoveChosenHostsToVaultCommand.Execute(null);
+
+ vault.SelectedChosenHostVault =
+ vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.HasAChosenBindingToBring.ShouldBeFalse("which key to carry is a fact about one machine");
+ }
+
+ ///
+ /// Ticked, from the phone. The desktop's own path is measured above; what this adds is that the batch
+ /// command carries the key before it writes the host, so the host lands naming the id the key arrived
+ /// with rather than a tombstone.
+ ///
+ [Fact]
+ public async Task MovingTheOneChosenHostWithItsKey_TakesTheKeyAcrossAndKeepsTheBinding()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var key = await AddKeyAsync(vault, "deploy");
+
+ await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
+
+ vault.ChooseHostCommand.Execute(vault.Hosts.ShouldHaveSingleItem());
+ vault.MoveChosenHostsToVaultCommand.Execute(null);
+
+ vault.SelectedChosenHostVault =
+ vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.BringsTheChosenBindingAlong = true;
+
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
+
+ var movedKey = vault.Keys.ShouldHaveSingleItem();
+ var movedHost = vault.Hosts.ShouldHaveSingleItem();
+
+ movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
+
+ vault.Status.ShouldContain("came with it");
+ vault.BringsTheChosenBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
+ vault.IsChoosingHosts.ShouldBeFalse();
+ }
+
+ ///
+ /// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
+ /// and the sentence afterwards says what that means for everybody else in the destination. It is also
+ /// what happens to anybody who presses MOVE without reading, which is why it is the unticked state.
+ ///
+ [Fact]
+ public async Task MovingAHostWithoutItsKey_LeavesTheKeyBehindAndSaysWhatThatCosts()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var key = await AddKeyAsync(vault, "deploy");
+
+ await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
+
+ vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
+ vault.MoveHostCommand.Execute(null);
+
+ vault.SelectedMoveVault =
+ vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
+
+ vault.Keys.ShouldHaveSingleItem().VaultId.ShouldBe(key.VaultId, "the key was not asked for");
+
+ var moved = vault.Hosts.ShouldHaveSingleItem();
+
+ moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ moved.Host.SshKeyId.ShouldBe(key.EntityId, "the binding is kept — it resolves across vaults");
+
+ vault.Status.ShouldContain("another vault");
+ }
+
+ ///
+ ///
+ /// The case where a host stops connecting without naming anything. A group lends its default key to
+ /// everything filed under it, and a group belongs to the vault it is in — so the group stays behind, and
+ /// a host that only inherited its key used to arrive naming nothing at all.
+ ///
+ ///
+ /// The binding is written onto the host on the way across instead, which is the same key it
+ /// authenticated with before the move. The move is also asked about it: the tick box reads the resolved
+ /// binding, so an inherited key can be brought too.
+ ///
+ ///
+ [Fact]
+ public async Task MovingAHostThatInheritsItsGroupsKey_WritesThatBindingOntoIt()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var key = await AddKeyAsync(vault, "deploy");
+
+ vault.NewGroupCommand.Execute(null);
+ vault.GroupEditorLabel = "Production";
+ vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices
+ .Single(choice => choice.EntityId == key.EntityId);
+
+ await vault.SaveGroupCommand.ExecuteAsync(null);
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+ vault.EditorUsername = "deploy";
+ vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
+ choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ var before = vault.Hosts.ShouldHaveSingleItem();
+
+ before.Host.SshKeyId.ShouldBeNull("the host names nothing; the group lends it");
+ before.Authentication.ShouldBe("key");
+
+ vault.SelectedHost = before;
+ vault.MoveHostCommand.Execute(null);
+
+ vault.SelectedMoveVault =
+ vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ vault.HasABindingToBring.ShouldBeTrue("an inherited key is still a key that can come along");
+
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
+
+ var moved = vault.Hosts.ShouldHaveSingleItem();
+
+ moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ moved.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
+ moved.Host.SshKeyId.ShouldBe(key.EntityId, "what it inherited is written onto it");
+ moved.Authentication.ShouldBe("key", "it authenticates with what it did before the move");
+ }
+
///
///
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
@@ -1599,6 +1944,41 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// Through the form rather than straight at the command, because the name is what the form is for —
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
///
+ /// The armour a key is stored in, which this suite never parses and only round-trips.
+ private static string PrivateKey(string body) =>
+ $"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
+
+ /// Puts one key in whatever vault the keychain is filing into, and hands back its row.
+ private static async Task AddKeyAsync(VaultViewModel vault, string label)
+ {
+ vault.NewKeyCommand.Execute(null);
+
+ vault.KeyEditorLabel = label;
+ vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
+
+ await vault.SaveKeyCommand.ExecuteAsync(null);
+
+ vault.IsEditingKey.ShouldBeFalse(vault.Status);
+
+ return vault.Keys.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
+ }
+
+ /// Creates a host that authenticates with one key, by choosing it in the editor.
+ private static async Task AddHostBoundToKeyAsync(VaultViewModel vault, string label, Guid keyId)
+ {
+ vault.NewHostCommand.Execute(null);
+
+ vault.EditorLabel = label;
+ vault.EditorHostname = $"{label}.internal";
+ vault.EditorUsername = "deploy";
+ vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
+ .Single(choice => choice.EntityId == keyId);
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.IsEditing.ShouldBeFalse(vault.Status);
+ }
+
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{
await vaults.LoadAsync(Token);
@@ -1613,6 +1993,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
}
+ ///
+ ///
+ /// Sharing a snippet, which is a move like a host's and simpler in exactly one way: a snippet crosses
+ /// whole. It has no group, no tags and no key binding — nothing on it points at an item of the vault it
+ /// came from — so the assertion the host's move makes about what was left behind has no analogue, and
+ /// the one worth making instead is that nothing was lost, the flag that decides whether it
+ /// presses Enter for you least of all.
+ ///
+ ///
+ /// The new id is asserted for the reason the host's test gives: one entity id in two vaults would make
+ /// the destination's row and the source's tombstone the same row.
+ ///
+ ///
+ [Fact]
+ public async Task MovingASnippetToAnotherVault_ReSealsItThereAndCarriesItWhole()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: true);
+
+ var before = Snippet(snippets, "restart the api");
+
+ before.VaultId.ShouldNotBe(sharedVaultId);
+
+ snippets.Selected = before;
+ snippets.CanMove.ShouldBeTrue("there is a second vault this session can write to");
+
+ snippets.MoveCommand.Execute(null);
+
+ snippets.IsMoving.ShouldBeTrue(snippets.Status);
+ snippets.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);
+
+ snippets.SelectedMoveVault =
+ snippets.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ // The pass that follows every write is made to fail, so the move's own sentence is still on the
+ // status line to be read. See the host's move test, which does this for the same reason.
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await snippets.ConfirmMoveCommand.ExecuteAsync(null);
+
+ var after = Snippet(snippets, "restart the api");
+
+ after.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
+ after.Snippet.Command.ShouldBe("sudo systemctl restart dodossh-api");
+ after.Snippet.RunsOnInsert.ShouldBeTrue("the flag that decides whether it presses Enter came too");
+
+ snippets.Selected?.EntityId.ShouldBe(after.EntityId, "the pane follows the snippet it moved");
+ snippets.Status.ShouldContain("Platform secrets");
+ }
+
+ ///
+ /// Refused by the command rather than by an empty picker, and the phone reads the same question to
+ /// decide whether to draw the button at all.
+ ///
+ [Fact]
+ public async Task MovingASnippetWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
+ {
+ await UnlockedAsync();
+
+ var vault = shell.Vault!;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
+
+ snippets.Selected = Snippet(snippets, "uptime");
+
+ snippets.CanMove.ShouldBeFalse("the personal vault is the only one there is");
+
+ snippets.MoveCommand.Execute(null);
+
+ snippets.IsMoving.ShouldBeFalse();
+ snippets.MoveVaultChoices.ShouldBeEmpty();
+ snippets.Status.ShouldContain("only vault you can write to");
+ }
+
+ ///
+ /// The picker the snippet editor grew, and the thing it is for: choosing at the moment a snippet is
+ /// written, on the form it is being typed into. A command is worth sharing precisely when somebody else
+ /// would otherwise be retyping it, so filing it into the team's vault at that moment is the ordinary
+ /// case rather than an afterthought.
+ ///
+ [Fact]
+ public async Task TheSnippetEditorFilesANewSnippetIntoTheVaultChosenOnIt()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ snippets.NewCommand.Execute(null);
+
+ snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
+
+ snippets.EditorSelectedVault =
+ snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ snippets.EditorLabel = "rotate the certs";
+ snippets.EditorCommand = "sudo certbot renew";
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+
+ Snippet(snippets, "rotate the certs").VaultId.ShouldBe(sharedVaultId, snippets.Status);
+ }
+
+ ///
+ ///
+ /// The bug the per-editor latch exists to prevent, and the reason the screen could not simply keep
+ /// writing to the active vault once its list spanned several. An update sent to the active vault would
+ /// create a second snippet there and leave the team's original untouched: a fork that shows up only
+ /// when a colleague asks why the correction never arrived.
+ ///
+ ///
+ /// The count is the assertion. One snippet with that label, in the vault it started in.
+ ///
+ ///
+ [Fact]
+ public async Task EditingASharedSnippet_WritesBackToItsOwnVaultRatherThanForkingACopy()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ snippets.NewCommand.Execute(null);
+ snippets.EditorSelectedVault =
+ snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+ snippets.EditorLabel = "drain the node";
+ snippets.EditorCommand = "kubectl drain node-1";
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+
+ snippets.Selected = Snippet(snippets, "drain the node");
+ snippets.EditCommand.Execute(null);
+
+ snippets.ShowsEditorVaultChoice.ShouldBeFalse("an existing snippet's vault is not a field of the form");
+
+ snippets.EditorCommand = "kubectl drain node-1 --ignore-daemonsets";
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+
+ var edited = Snippet(snippets, "drain the node");
+
+ edited.VaultId.ShouldBe(sharedVaultId, "the edit went back to the vault it came from");
+ edited.Snippet.Command.ShouldBe("kubectl drain node-1 --ignore-daemonsets");
+ }
+
+ /// The snippet with a given name, re-found because every row is replaced on every reload.
+ private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) =>
+ snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
+
+ /// The snippets screen over a vault, with no terminal to insert into.
+ ///
+ /// Insert is not what this suite is about — see ShellFlowTests for that — so the target is empty
+ /// and the delivery is a stub that would report success if anything asked it to.
+ ///
+ private static SnippetsViewModel SnippetsOver(VaultViewModel vault) =>
+ new(vault, () => InsertTarget.None, (_, _, _, _) => Task.FromResult(true));
+
+ private static async Task AddSnippetAsync(
+ SnippetsViewModel snippets,
+ string label,
+ string command,
+ bool runs)
+ {
+ snippets.NewCommand.Execute(null);
+ snippets.EditorLabel = label;
+ snippets.EditorCommand = command;
+ snippets.EditorRunsOnInsert = runs;
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+ }
+
///
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and