Public Access
Compare commits
5
Commits
174ef7c420
...
69858f82d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69858f82d1 | ||
|
|
509a7c34f5 | ||
|
|
185790fb14 | ||
|
|
3d9ed03b09 | ||
|
|
cddfeb1f55 |
@@ -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
|
||||
|
||||
+10
-3
@@ -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.
|
||||
|
||||
+37
-3
@@ -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
|
||||
@@ -1277,12 +1302,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**
|
||||
|
||||
@@ -1348,6 +1374,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.
|
||||
|
||||
@@ -712,6 +712,25 @@
|
||||
</ComboBox>
|
||||
<TextBlock Classes="body"
|
||||
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
|
||||
|
||||
<!--
|
||||
◆ THE KEY. A binding resolves across vaults, so the host keeps working here whichever way this is
|
||||
answered — but the vault it has just joined holds one key, and a host whose own key stayed behind
|
||||
is one its new colleagues cannot connect with.
|
||||
|
||||
Unticked, because moving a key into a team's vault hands it to everybody who holds that vault's
|
||||
key: a disclosure is chosen, never defaulted into. The line under it is the count of what else
|
||||
uses that key, which is the difference between an obvious yes and an obvious no.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding BringsTheBindingAlong}" MinHeight="44"
|
||||
IsVisible="{Binding HasABindingToBring}">
|
||||
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding BindingToBringQuestion}" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasABindingToBring}"
|
||||
Text="{Binding BindingToBringNote}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
@@ -149,8 +149,19 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE"
|
||||
<!--
|
||||
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
|
||||
this head, for the reason a phone has: there is no room to draw a button that answers with a
|
||||
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
|
||||
moving it back.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
|
||||
IsVisible="{Binding CanMoveSelectedItem}"
|
||||
Command="{Binding MoveSelectedItemCommand}" />
|
||||
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
|
||||
Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -180,6 +191,47 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ ◆ moving it to another vault ============ -->
|
||||
<!--
|
||||
The desktop's panel, in the place the deletion question uses and never at the same time as it: MOVE
|
||||
disarms a pending deletion on the way in, and the buttons that ask either question are hidden while
|
||||
one is up.
|
||||
|
||||
Both sentences are here rather than only in the status line afterwards, which on a phone is one line at
|
||||
the bottom of a screen somebody has already navigated away from. The second one is the count of what
|
||||
points at this key — every one of them is re-aimed at the vault it moves to, and that is the part
|
||||
nobody can see from a keychain row.
|
||||
-->
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsMovingItem}" Margin="12,4"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="12" Padding="14,12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding MovingItemLabel}" />
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||
ItemsSource="{Binding MoveItemVaultChoices}"
|
||||
SelectedItem="{Binding SelectedMoveItemVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasMovingItemUsage}"
|
||||
Text="{Binding MovingItemUsage}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveItemCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the items ============ -->
|
||||
<Panel Grid.Row="4">
|
||||
|
||||
|
||||
@@ -134,15 +134,17 @@
|
||||
|
||||
<!--
|
||||
The terminal is a surface rather than a page, so it goes through its own command — the same
|
||||
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is
|
||||
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
|
||||
destination as current is the surface filling the screen.
|
||||
reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
|
||||
can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
|
||||
this surface is Connections with nothing running. That screen is a page in everything but which
|
||||
enum it is in, and a rail sitting beside it with no entry lit would be pointing nowhere.
|
||||
|
||||
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
|
||||
surface and is that count, drawn as the sessions themselves — a number beside this word would be
|
||||
the same fact said twice, three inches apart.
|
||||
-->
|
||||
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}">
|
||||
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
|
||||
Command="{Binding ShowTerminalCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
||||
|
||||
@@ -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.
|
||||
@@ -308,10 +309,15 @@
|
||||
|
||||
<!-- ============ navigation ============ -->
|
||||
<!--
|
||||
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's three
|
||||
Gone while a shell is showing, which is the whole of that surface's arrangement: the bar's three
|
||||
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
|
||||
own bar. See TerminalScreen.axaml.
|
||||
|
||||
◆ A shell rather than the surface, which are not the same question — Connections with nothing running
|
||||
is that surface drawing a page, and the bar stays under it. The screen it stands down for is the one
|
||||
that pays for the room; a box asking which machine to connect to does not, and taking the nav away
|
||||
from somebody who has just closed their last tab left them with only the back gesture.
|
||||
|
||||
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
|
||||
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
|
||||
|
||||
@@ -340,12 +346,15 @@
|
||||
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
|
||||
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
|
||||
|
||||
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
|
||||
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
|
||||
would be a rule about a state this control cannot be in. What marks it as current is the surface
|
||||
filling the screen.
|
||||
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
|
||||
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
|
||||
separates them: false for every page, true for the one screen where this is where you are. With a
|
||||
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
|
||||
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
|
||||
bar can now sit under and not point at is the entry looking broken instead.
|
||||
-->
|
||||
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
||||
IsCurrent="{Binding IsTerminalSurface}"
|
||||
Command="{Binding ShowTerminalCommand}" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -104,10 +104,19 @@ internal sealed partial class PhoneShell : UserControl
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
||||
|
||||
/// <summary>Whether the rail down the left edge is drawn.</summary>
|
||||
/// <remarks>
|
||||
/// The wide surface's answer to <see cref="ShowsBottomBarProperty"/> and asks the same question about
|
||||
/// which screen is up, so the two move together — including over Connections with nothing running. See
|
||||
/// <see cref="RefreshChrome"/>.
|
||||
/// </remarks>
|
||||
public static readonly StyledProperty<bool> ShowsRailProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
||||
|
||||
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
|
||||
/// <remarks>
|
||||
/// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal
|
||||
/// surface drawing a page. See <see cref="RefreshChrome"/>.
|
||||
/// </remarks>
|
||||
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
||||
|
||||
@@ -154,6 +163,15 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// have to be an "or", which a wrapper cannot express at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The nav stands down for a shell rather than for the terminal surface, and those parted company
|
||||
/// when that surface gained a page.</b> Connections with nothing running is a box, a CONNECT button and
|
||||
/// the machines connected to before — see TerminalScreen.axaml — and none of that is worth the screen a
|
||||
/// shell is worth it for. It is also the one screen somebody can arrive at by closing their last tab,
|
||||
/// which made the collapsed bar a way to end up with no route to Hosts or Settings but the system back
|
||||
/// gesture. The header is deliberately not part of this: the surface draws its own bar with the back
|
||||
/// arrow and the +, and the vault header above that is the second row this head exists to avoid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
|
||||
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
|
||||
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
|
||||
@@ -172,6 +190,14 @@ internal sealed partial class PhoneShell : UserControl
|
||||
var wide = body.Bounds.Width >= WideAt;
|
||||
var pages = shell?.IsShowingPages == true;
|
||||
|
||||
// ◆ Connections with nothing running, which is the terminal surface drawing a page: a box, a CONNECT
|
||||
// button and the machines connected to before. The nav stands down for a shell — the whole of the
|
||||
// arrangement below — and there is no shell here to stand down for, so it stays. Taking it away on
|
||||
// this one screen was worst where it was least affordable: somebody who has just closed their last
|
||||
// tab, or who pressed Connections to see what was open and found nothing, was left on a screen whose
|
||||
// only way to Hosts or Settings was the system back gesture.
|
||||
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
|
||||
|
||||
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
|
||||
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
|
||||
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
|
||||
@@ -183,8 +209,8 @@ internal sealed partial class PhoneShell : UserControl
|
||||
}
|
||||
|
||||
IsWide = wide;
|
||||
ShowsRail = wide && pages;
|
||||
ShowsBottomBar = !wide && pages;
|
||||
ShowsRail = wide && (pages || connectPage);
|
||||
ShowsBottomBar = !wide && (pages || connectPage);
|
||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,26 @@
|
||||
|
||||
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
|
||||
<!--
|
||||
◆ Which vault a *new* snippet is filed into. Hidden for an existing one — its vault is not a
|
||||
field of this form, and changing it is MOVE below — and hidden entirely where there is only one
|
||||
vault to choose between, which is where most people stay.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
|
||||
<TextBlock Classes="label" Text="VAULT" />
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||
ItemsSource="{Binding EditorVaultChoices}"
|
||||
SelectedItem="{Binding EditorSelectedVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="body"
|
||||
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
|
||||
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
VerticalContentAlignment="Top" Padding="14,10" />
|
||||
@@ -125,6 +145,15 @@
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ Which vault this one is in, drawn only where there is more than one to be in. A shared
|
||||
snippet is a command the rest of a team can read and insert into their own terminals,
|
||||
and this card is the only place that fact appears before somebody taps EDIT.
|
||||
-->
|
||||
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
|
||||
@@ -151,6 +180,7 @@
|
||||
IsEnabled="{Binding !IsEditing}">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
|
||||
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
||||
IsEnabled="{Binding CanInsert}" />
|
||||
|
||||
@@ -162,9 +192,54 @@
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||
Command="{Binding EditCommand}" />
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteCommand}" />
|
||||
<!--
|
||||
◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
|
||||
draws too: the two vaults are encrypted under different keys, so this is a re-seal into one and
|
||||
a tombstone in the other — nothing a SAVE could do. It shows only where there is somewhere to
|
||||
move to; see SnippetsViewModel.CanMove.
|
||||
-->
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||
IsVisible="{Binding CanMove}" Command="{Binding MoveCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ A row of its own, under the pair rather than beside them, as the hosts screen puts it. A phone
|
||||
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary
|
||||
one is where a thumb lands.
|
||||
-->
|
||||
<Button Classes="danger" Height="44" Content="DELETE" Command="{Binding DeleteCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ MOVING THE SNIPPET TO ANOTHER VAULT, in the place the insert controls were. A picker and two
|
||||
buttons rather than a question with a yes: what is being asked is which vault, and a move is undone
|
||||
by moving it back.
|
||||
|
||||
The sentence is not decoration. Unlike a host, a snippet crosses whole — there is no group and no
|
||||
tag to leave behind — so what there is to say is who can read it afterwards; and on a phone, where
|
||||
the status line is one line at the bottom of a screen somebody has already navigated away from,
|
||||
before the tap is the only place that reliably gets read.
|
||||
-->
|
||||
<StackPanel Spacing="10" IsVisible="{Binding IsMoving}">
|
||||
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||
ItemsSource="{Binding MoveVaultChoices}"
|
||||
SelectedItem="{Binding SelectedMoveVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="body"
|
||||
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -689,6 +689,26 @@
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
|
||||
|
||||
<!--
|
||||
◆ THE KEY, WHICH IS THE HALF THE SENTENCE ABOVE CANNOT PROMISE. A binding resolves across
|
||||
vaults, so the host goes on working here either way — but the colleagues it has just joined
|
||||
hold one vault's key, and a host whose key stayed behind is one they cannot connect with.
|
||||
|
||||
Unticked, and it has to be: moving a key into a team's vault hands it to everybody who holds
|
||||
that key. The note under it is the count, because a key twenty machines authenticate with is a
|
||||
different decision from one nothing else uses, and neither is visible from here otherwise.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding BringsTheBindingAlong}"
|
||||
IsVisible="{Binding HasABindingToBring}"
|
||||
ToolTip.Tip="Moves the key or password itself into the same vault, and re-aims every host and group that used it at where it has gone.">
|
||||
<TextBlock Text="{Binding BindingToBringQuestion}" Classes="hint" FontSize="12"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasABindingToBring}"
|
||||
Text="{Binding BindingToBringNote}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
@@ -297,9 +297,50 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||
IsVisible="{Binding ShowsItemActions}">
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
||||
<!--
|
||||
Only where there is somewhere to move to, unlike EDIT beside it, which is the same rule the
|
||||
host's MOVE follows on the phone: a button that answers with "this is the only vault you can
|
||||
write to" is a button that should not have been drawn. Keys and passwords only — a tag and a
|
||||
bucket are read from the active vault alone, so "another vault" is not a question they have.
|
||||
-->
|
||||
<Button Classes="ghost" Content="MOVE" Command="{Binding MoveSelectedItemCommand}"
|
||||
IsVisible="{Binding CanMoveSelectedItem}"
|
||||
ToolTip.Tip="Re-encrypts this under another vault's key, and re-aims every host and group that used it at where it has gone." />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ MOVING THE ITEM TO ANOTHER VAULT, in the place those buttons were. The host's panel, over
|
||||
here — see HostDrawer.axaml — and what it is for is the thing a shared vault could not do until
|
||||
now: a key typed into a personal vault before the team existed was stuck there, and the only
|
||||
way across was to paste the private half into a second item and delete the first.
|
||||
|
||||
The two sentences under the picker are the whole of the decision. The first says what a move
|
||||
is; the second says what points at this key, because everything that does is re-aimed at it in
|
||||
its new vault and somebody moving a key twenty machines use should see the twenty first.
|
||||
-->
|
||||
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMovingItem}">
|
||||
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveItemVaultChoices}"
|
||||
SelectedItem="{Binding SelectedMoveItemVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasMovingItemUsage}"
|
||||
Text="{Binding MovingItemUsage}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveItemCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveItemCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The public half only, and there is no button for the other one. Installing a key means pasting
|
||||
this line into a host's authorized_keys; a private key on the clipboard is a private key in
|
||||
|
||||
@@ -63,6 +63,14 @@
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||
</Border>
|
||||
<!--
|
||||
Which vault this one is in, drawn only where there is more than one to be in. A shared
|
||||
snippet is a command the rest of a team can read and insert; the row is where somebody
|
||||
decides whether the thing they are about to edit is theirs alone.
|
||||
-->
|
||||
<Border Classes="chip" Padding="4,0" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
||||
@@ -89,6 +97,16 @@
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<!--
|
||||
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
|
||||
inside it: the two vaults are encrypted under different keys, so this is a re-seal into one and
|
||||
a tombstone in the other — nothing a SAVE could do — and a picker inside the form would let
|
||||
somebody correcting a typo hand a command to a team by leaving it where they found it. The
|
||||
picker itself opens beside the snippet, in the pane on the right.
|
||||
-->
|
||||
<Button Classes="ghost" Content="MOVE TO VAULT…" Command="{Binding MoveCommand}"
|
||||
IsEnabled="{Binding CanMove}"
|
||||
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -102,6 +120,27 @@
|
||||
<!-- ============ The editor ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
|
||||
<!--
|
||||
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
|
||||
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault is
|
||||
not a field of this form, and changing it is MOVE — and not drawn at all where there is only
|
||||
one vault to choose between, because a control offering one option is a question nobody asked.
|
||||
-->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
|
||||
<TextBlock Classes="label" Text="VAULT" />
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
|
||||
SelectedItem="{Binding EditorSelectedVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
|
||||
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
|
||||
@@ -133,6 +172,16 @@
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
Named here as well as on the row, because this pane is where somebody decides to put a
|
||||
command into a production terminal, and who else holds a key to it is part of that decision.
|
||||
The row's badge is off the screen by the time this is being read.
|
||||
-->
|
||||
<Border Classes="chip" Padding="4,0" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding HasSelectionVaultBadge}">
|
||||
<TextBlock Text="{Binding SelectionVaultBadge}" FontSize="9.5" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
@@ -150,6 +199,7 @@
|
||||
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.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
|
||||
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||
@@ -163,6 +213,38 @@
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
|
||||
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker and
|
||||
two buttons, not a question with a yes: what is being asked is which vault, and a move is
|
||||
undone by moving it back rather than by being careful — so this is not drawn in the danger
|
||||
colours the deletion question uses.
|
||||
|
||||
It takes the insert controls' place while it is up, for the reason the host pane hides
|
||||
CONNECT: the button that opened this is still on screen otherwise, offering to open it again.
|
||||
|
||||
The sentence is the part worth keeping. Unlike a host, a snippet crosses whole — it has no
|
||||
group and no tags to leave behind — so what there is to say is who can read it afterwards,
|
||||
and for a command that may carry a hostname or a path that is the whole of the decision.
|
||||
-->
|
||||
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMoving}">
|
||||
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
|
||||
SelectedItem="{Binding SelectedMoveVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -35,6 +35,15 @@ internal sealed record InsertTarget(uint? SessionId, string Label)
|
||||
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
|
||||
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A snippet can be shared, which is why this screen has two vault controls rather than none.</b> The
|
||||
/// editor asks which vault a <em>new</em> 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
|
||||
/// <see cref="VaultViewModel"/> because this screen owns its editor, unlike the host pane; the writing they
|
||||
/// ask for is still the vault's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
/// <summary>
|
||||
/// The vault the open editor will write to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>VaultViewModel.editingHostVaultId</c>
|
||||
/// exists.
|
||||
/// </remarks>
|
||||
private Guid editorVaultId;
|
||||
|
||||
/// <summary>Which vault the open move panel would send the snippet to.</summary>
|
||||
private SnippetRowViewModel? moving;
|
||||
|
||||
/// <summary>
|
||||
/// The vaults a new snippet may be filed into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Filled from <c>VaultViewModel.TargetVaults</c>, 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.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? editorSelectedVault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the editor should be asking which vault this snippet goes into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="Move"/> — and a control offering one option is a question nobody was
|
||||
/// asked.
|
||||
/// </remarks>
|
||||
internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;
|
||||
|
||||
/// <summary>Whether the panel asking which vault to move the selected snippet to is up.</summary>
|
||||
/// <remarks>
|
||||
/// 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".
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
|
||||
private bool isMoving;
|
||||
|
||||
/// <summary>Where the selected snippet could be moved: every vault this session can write to but its own.</summary>
|
||||
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedMoveVault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected snippet has anywhere to move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal bool CanMove =>
|
||||
Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;
|
||||
|
||||
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
|
||||
/// <remarks>
|
||||
/// Off while the move panel is up, which takes their place — the same rule the host pane's
|
||||
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
|
||||
/// otherwise still be there offering to open it again.
|
||||
/// </remarks>
|
||||
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
|
||||
|
||||
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;
|
||||
|
||||
/// <summary>Whether there is a vault to name beside the selected snippet.</summary>
|
||||
internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;
|
||||
|
||||
/// <summary>Whether this keychain holds any snippet the screen would draw.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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.";
|
||||
|
||||
/// <summary>Starts a new snippet.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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.";
|
||||
}
|
||||
|
||||
/// <summary>Opens the selected snippet for editing.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the panel that asks which vault the selected snippet should move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <c>VaultItemRepository.MoveAsync</c> — and that must not happen as a
|
||||
/// side effect of saving a corrected typo.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the move panel.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelMove()
|
||||
{
|
||||
CloseMovePanel();
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the selected snippet into the chosen vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The snippet crosses whole.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
||||
/// </summary>
|
||||
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a half-typed snippet into the vault just chosen for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
|
||||
/// the control. An existing snippet <em>can</em> change vaults — see <see cref="Move"/> — 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.
|
||||
/// </remarks>
|
||||
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||
{
|
||||
if (value is null || EditingId is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
editorVaultId = value.VaultId;
|
||||
}
|
||||
|
||||
/// <summary>Refills the editor's vault picker, landing on the vault the editor will write to.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A hidden vault's snippets come off here rather than out of <c>VaultViewModel.Snippets</c>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool Matches(SnippetRowViewModel row)
|
||||
{
|
||||
if (!vault.IsVaultShown(row.VaultId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var needle = Filter.Trim();
|
||||
|
||||
if (needle.Length == 0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,16 @@ public sealed class CredentialRepository(
|
||||
CancellationToken cancellationToken) =>
|
||||
credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
|
||||
/// <inheritdoc cref="SshKeyRepository.MoveAsync" path="/remarks" />
|
||||
public Task<Guid> MoveAsync(
|
||||
Guid fromVaultId,
|
||||
Guid toVaultId,
|
||||
Guid entityId,
|
||||
CredentialSecret credential,
|
||||
CancellationToken cancellationToken) =>
|
||||
credentials.MoveAsync(fromVaultId, toVaultId, entityId, credential, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
credentials.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
|
||||
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
|
||||
CancellationToken cancellationToken) =>
|
||||
snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
|
||||
/// <remarks>
|
||||
/// What sharing a snippet is, underneath. A snippet has no group, no tags and no key binding — see
|
||||
/// <see cref="SnippetSecret"/> — 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.
|
||||
/// </remarks>
|
||||
public Task<Guid> MoveAsync(
|
||||
Guid fromVaultId,
|
||||
Guid toVaultId,
|
||||
Guid entityId,
|
||||
SnippetSecret snippet,
|
||||
CancellationToken cancellationToken) =>
|
||||
snippets.MoveAsync(fromVaultId, toVaultId, entityId, snippet, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
|
||||
@@ -47,6 +47,29 @@ public sealed class SshKeyRepository(
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It lands with a new id</b>, 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 <c>VaultViewModel.ReAimAtAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<Guid> MoveAsync(
|
||||
Guid fromVaultId,
|
||||
Guid toVaultId,
|
||||
Guid entityId,
|
||||
SshKeySecret key,
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.MoveAsync(fromVaultId, toVaultId, entityId, key, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
keys.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
|
||||
@@ -883,6 +883,38 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The state is set here rather than through <c>MoveCommand</c>, 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 <c>DodoSSH.Client.App.Tests</c>. The same arrangement, and the same
|
||||
/// reason, as <see cref="TheHostDrawerFitsWithTheMovePanelOpen"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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
|
||||
{
|
||||
|
||||
@@ -647,6 +647,236 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("only vault you can write to");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The re-aim is the half worth the test.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
|
||||
@@ -1461,6 +1691,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.
|
||||
/// </remarks>
|
||||
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
|
||||
private static string PrivateKey(string body) =>
|
||||
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
/// <summary>Puts one key in whatever vault the keychain is filing into, and hands back its row.</summary>
|
||||
private static async Task<SshKeyRowViewModel> 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));
|
||||
}
|
||||
|
||||
/// <summary>Creates a host that authenticates with one key, by choosing it in the editor.</summary>
|
||||
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);
|
||||
@@ -1475,6 +1740,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <em>nothing</em> was lost, the flag that decides whether it
|
||||
/// presses Enter for you least of all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The count is the assertion. One snippet with that label, in the vault it started in.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>The snippet with a given name, re-found because every row is replaced on every reload.</summary>
|
||||
private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) =>
|
||||
snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>The snippets screen over a vault, with no terminal to insert into.</summary>
|
||||
/// <remarks>
|
||||
/// Insert is not what this suite is about — see <c>ShellFlowTests</c> for that — so the target is empty
|
||||
/// and the delivery is a stub that would report success if anything asked it to.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user