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
|
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.
|
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
|
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
|
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
|
||||||
to" picker on the Keychain screen and cannot be moved yet.
|
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
|
**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
|
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`.
|
believing they typed an `l`.
|
||||||
|
|
||||||
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
**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
|
and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
|
||||||
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
|
the session pills, and a `+` raising a sheet with the three connections there are.
|
||||||
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
|
|
||||||
|
*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
|
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
|
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
||||||
collapsing is correct under either answer.
|
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
|
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.
|
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**
|
### 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
|
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.
|
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
|
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.
|
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
|
**Failure means:** the strip is not bound on `IsShowingPages`, `RefreshChrome` is not reading `HasTabs`, or
|
||||||
by a route that leaves `Surface` on `Page`.
|
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**
|
### 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
|
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.
|
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
|
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
|
something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning
|
||||||
colour, with no tab and nothing dialled.
|
colour, with no tab and nothing dialled.
|
||||||
|
|||||||
@@ -712,6 +712,25 @@
|
|||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Classes="body"
|
<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." />
|
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,*">
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||||
Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" />
|
Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
|||||||
@@ -149,8 +149,19 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</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}" />
|
Command="{Binding DeleteSelectedItemCommand}" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -180,6 +191,47 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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 ============ -->
|
<!-- ============ the items ============ -->
|
||||||
<Panel Grid.Row="4">
|
<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
|
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
|
reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
|
||||||
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
|
can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
|
||||||
destination as current is the surface filling the screen.
|
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
|
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
|
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.
|
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">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
||||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
||||||
|
|||||||
@@ -25,8 +25,9 @@
|
|||||||
|
|
||||||
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
── 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
|
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
|
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
|
||||||
surface is either a page or a terminal, and these are the chrome a page has.
|
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
|
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.
|
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 ============ -->
|
<!-- ============ 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
|
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.
|
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
|
◆ 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.
|
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
|
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.
|
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
|
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
|
||||||
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
|
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
|
||||||
would be a rule about a state this control cannot be in. What marks it as current is the surface
|
separates them: false for every page, true for the one screen where this is where you are. With a
|
||||||
filling the screen.
|
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="⌗"
|
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
||||||
|
IsCurrent="{Binding IsTerminalSurface}"
|
||||||
Command="{Binding ShowTerminalCommand}" />
|
Command="{Binding ShowTerminalCommand}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -104,10 +104,19 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
||||||
|
|
||||||
/// <summary>Whether the rail down the left edge is drawn.</summary>
|
/// <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 =
|
public static readonly StyledProperty<bool> ShowsRailProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
||||||
|
|
||||||
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
|
/// <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 =
|
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
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.
|
/// have to be an "or", which a wrapper cannot express at all.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <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
|
/// <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
|
/// 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
|
/// 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 wide = body.Bounds.Width >= WideAt;
|
||||||
var pages = shell?.IsShowingPages == true;
|
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
|
// 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
|
// 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
|
// 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;
|
IsWide = wide;
|
||||||
ShowsRail = wide && pages;
|
ShowsRail = wide && (pages || connectPage);
|
||||||
ShowsBottomBar = !wide && pages;
|
ShowsBottomBar = !wide && (pages || connectPage);
|
||||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,26 @@
|
|||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
|
<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"
|
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
|
||||||
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
VerticalContentAlignment="Top" Padding="14,10" />
|
VerticalContentAlignment="Top" Padding="14,10" />
|
||||||
@@ -125,6 +145,15 @@
|
|||||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding Badge}" />
|
<TextBlock Text="{Binding Badge}" />
|
||||||
</Border>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
|
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
|
||||||
@@ -151,6 +180,7 @@
|
|||||||
IsEnabled="{Binding !IsEditing}">
|
IsEnabled="{Binding !IsEditing}">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
|
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
|
||||||
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
||||||
IsEnabled="{Binding CanInsert}" />
|
IsEnabled="{Binding CanInsert}" />
|
||||||
|
|
||||||
@@ -162,9 +192,54 @@
|
|||||||
<Grid ColumnDefinitions="*,8,*">
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||||
Command="{Binding EditCommand}" />
|
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>
|
</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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,13 @@
|
|||||||
|
|
||||||
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
||||||
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
|
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.
|
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
|
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
|
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
|
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.
|
of them.
|
||||||
|
|
||||||
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
|
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
|
but this surface raises it. The scrim reaching only the screen area is the full display while a shell
|
||||||
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
|
is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
|
||||||
display.
|
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:
|
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
|
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>
|
</ComboBox>
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
<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." />
|
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">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
|
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
|||||||
@@ -297,9 +297,50 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||||
IsVisible="{Binding ShowsItemActions}">
|
IsVisible="{Binding ShowsItemActions}">
|
||||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
<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}" />
|
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||||
</StackPanel>
|
</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
|
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
|
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}}">
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||||
</Border>
|
</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>
|
</StackPanel>
|
||||||
<!--
|
<!--
|
||||||
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
||||||
@@ -89,6 +97,16 @@
|
|||||||
IsEnabled="{Binding HasSelection}" />
|
IsEnabled="{Binding HasSelection}" />
|
||||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
|
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
|
||||||
IsEnabled="{Binding HasSelection}" />
|
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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -102,6 +120,27 @@
|
|||||||
<!-- ============ The editor ============ -->
|
<!-- ============ The editor ============ -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
|
||||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
<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
|
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
|
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"
|
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
|
||||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
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" />
|
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
|
||||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
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
|
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.
|
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"
|
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||||
@@ -163,6 +213,38 @@
|
|||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
|
<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." />
|
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>
|
</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>
|
||||||
|
|
||||||
</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
|
/// 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"/>.
|
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
internal sealed partial class SnippetsViewModel : ObservableObject
|
internal sealed partial class SnippetsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
@@ -103,7 +112,100 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string status = string.Empty;
|
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;
|
internal bool HasVisible => Visible.Count > 0;
|
||||||
|
|
||||||
@@ -136,19 +238,31 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
+ "typing it again.";
|
+ "typing it again.";
|
||||||
|
|
||||||
/// <summary>Starts a new snippet.</summary>
|
/// <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]
|
[RelayCommand]
|
||||||
private void New()
|
private void New()
|
||||||
{
|
{
|
||||||
|
CloseMovePanel();
|
||||||
|
|
||||||
EditingId = null;
|
EditingId = null;
|
||||||
EditorLabel = string.Empty;
|
EditorLabel = string.Empty;
|
||||||
EditorCommand = string.Empty;
|
EditorCommand = string.Empty;
|
||||||
EditorNotes = string.Empty;
|
EditorNotes = string.Empty;
|
||||||
EditorRunsOnInsert = false;
|
EditorRunsOnInsert = false;
|
||||||
|
BuildEditorVaultChoices(vault.TargetVaultId);
|
||||||
IsEditing = true;
|
IsEditing = true;
|
||||||
Status = "Adding a snippet.";
|
Status = "Adding a snippet.";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens the selected snippet for editing.</summary>
|
/// <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]
|
[RelayCommand]
|
||||||
private void Edit()
|
private void Edit()
|
||||||
{
|
{
|
||||||
@@ -163,11 +277,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CloseMovePanel();
|
||||||
|
|
||||||
EditingId = row.EntityId;
|
EditingId = row.EntityId;
|
||||||
EditorLabel = row.Snippet.Label;
|
EditorLabel = row.Snippet.Label;
|
||||||
EditorCommand = row.Snippet.Command;
|
EditorCommand = row.Snippet.Command;
|
||||||
EditorNotes = row.Snippet.Notes ?? string.Empty;
|
EditorNotes = row.Snippet.Notes ?? string.Empty;
|
||||||
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
|
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
|
||||||
|
BuildEditorVaultChoices(row.VaultId);
|
||||||
IsEditing = true;
|
IsEditing = true;
|
||||||
Status = $"Editing {row.Label}.";
|
Status = $"Editing {row.Label}.";
|
||||||
}
|
}
|
||||||
@@ -197,7 +314,8 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
RunsOnInsert = EditorRunsOnInsert,
|
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)
|
if (!saved)
|
||||||
{
|
{
|
||||||
@@ -224,6 +342,105 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
Status = vault.Status;
|
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>
|
/// <summary>
|
||||||
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnFilterChanged(string value) => Rebuild();
|
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)
|
partial void OnSelectedChanged(SnippetRowViewModel? value)
|
||||||
{
|
{
|
||||||
|
if (IsMoving && value?.EntityId != moving?.EntityId)
|
||||||
|
{
|
||||||
|
CloseMovePanel();
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasSelection));
|
OnPropertyChanged(nameof(HasSelection));
|
||||||
OnPropertyChanged(nameof(CanInsert));
|
OnPropertyChanged(nameof(CanInsert));
|
||||||
OnPropertyChanged(nameof(SelectionRuns));
|
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>
|
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
|
||||||
internal bool IsCreating => EditingId is null;
|
internal bool IsCreating => EditingId is null;
|
||||||
@@ -315,14 +596,27 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HasSnippets));
|
OnPropertyChanged(nameof(HasSnippets));
|
||||||
OnPropertyChanged(nameof(HasVisible));
|
OnPropertyChanged(nameof(HasVisible));
|
||||||
OnPropertyChanged(nameof(EmptyMessage));
|
OnPropertyChanged(nameof(EmptyMessage));
|
||||||
|
OnPropertyChanged(nameof(CanMove));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The command is searched as well as the name and the notes, because half of what somebody remembers
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private bool Matches(SnippetRowViewModel row)
|
private bool Matches(SnippetRowViewModel row)
|
||||||
{
|
{
|
||||||
|
if (!vault.IsVaultShown(row.VaultId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var needle = Filter.Trim();
|
var needle = Filter.Trim();
|
||||||
|
|
||||||
if (needle.Length == 0)
|
if (needle.Length == 0)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,16 @@ public sealed class CredentialRepository(
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
credentials.UpdateAsync(vaultId, entityId, credential, 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" />
|
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||||
credentials.DeleteAsync(vaultId, entityId, cancellationToken);
|
credentials.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
snippets.UpdateAsync(vaultId, entityId, snippet, 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" />
|
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||||
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
|
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ public sealed class SshKeyRepository(
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
keys.UpdateAsync(vaultId, entityId, key, 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" />
|
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||||
keys.DeleteAsync(vaultId, entityId, 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);
|
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]
|
[Fact]
|
||||||
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
|
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
|
||||||
{
|
{
|
||||||
@@ -1919,6 +1951,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
private async Task SeedSnippetsAsync()
|
private async Task SeedSnippetsAsync()
|
||||||
{
|
{
|
||||||
await vault.SaveSnippetAsync(
|
await vault.SaveSnippetAsync(
|
||||||
|
vault.TargetVaultId,
|
||||||
null,
|
null,
|
||||||
new SnippetSecret
|
new SnippetSecret
|
||||||
{
|
{
|
||||||
@@ -1929,6 +1962,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
Token);
|
Token);
|
||||||
|
|
||||||
await vault.SaveSnippetAsync(
|
await vault.SaveSnippetAsync(
|
||||||
|
vault.TargetVaultId,
|
||||||
null,
|
null,
|
||||||
new SnippetSecret
|
new SnippetSecret
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -647,6 +647,236 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vault.Status.ShouldContain("only vault you can write to");
|
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>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
|
/// 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 —
|
/// 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.
|
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
|
||||||
/// </remarks>
|
/// </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)
|
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
|
||||||
{
|
{
|
||||||
await vaults.LoadAsync(Token);
|
await vaults.LoadAsync(Token);
|
||||||
@@ -1475,6 +1740,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
|
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>
|
/// <remarks>
|
||||||
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
|
/// 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
|
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
|
||||||
|
|||||||
Reference in New Issue
Block a user