Public Access
Share the shelf as well as what is on it, and ask a group which vault
A group is where hosts are filed and what lends them a port, a username and a key, and until now it could only ever be made in the vault this machine files new items into. So sharing a vault shared the machines and not the arrangement: a colleague opened four hosts filed under a group they could read the name of and nothing else, and the group a teammate made had no card, no heading and no way to be corrected from the screen looking straight at the hosts inside it. Recorded as half shipped in docs/design-import-gaps.md, and this is the other half. The list stopped being the active vault's. It was narrow for two stated reasons — a row shown across vaults has to carry which vault it lives in, because rename and delete both need it, and two vaults may hold a "production" each, which a layout with one heading per group cannot tell apart — and both are now paid for rather than avoided. Every row carries its vault, the badge beside the name says which, and the two cards sit side by side saying what they are. The three shapes of the group read are now deliberately different sizes. The list is what a person looks at, so a hidden vault's groups leave it: a card that cannot be opened onto anything is worse than no card. The per-vault lists are what a picker offers, because a picker is always asking about one vault. The map is what a host's GroupId resolves through, and it stays widest of all — including over hidden vaults, since a group lends a port and hiding a vault must never change what one of its hosts dials. RebuildGroups is the one place hiding is applied, which is what keeps those answers apart. The editor asks which vault on the terms the host editor's picker set: while adding only, hidden where there is one writable vault, and never offered afterwards, because the two are encrypted under different keys and moving an item is a delete and a retype. Its parent picker is that vault's alone, for the reason the host editor's group picker is one level down — a parent in another vault is a level half the key holders cannot resolve, and their hosts would inherit from nothing. + NEW GROUP inside an open group departs from NewHost and takes that group's vault rather than the standing preference: a group made inside another is in its parent's vault by construction, and answering "inside PLATFORM" with a group elsewhere and no parent would drop the one thing the button said. Two smaller things follow from the cards spanning vaults. Dragging a host onto a group card in another vault is refused with both names, because the write it would make is exactly the id-nobody-can-resolve the host editor's picker was fixed to prevent, and treating it as "no group" would unfile a host somebody was plainly filing. And a group being renamed says its vault in the drawer's header, since the picker is not drawn for an existing one and renaming a colleague's shelf without being told whose it is is the edit most worth naming. The save target is a nullable field behind a property that falls back to the standing preference. The group name box is bound whether or not anything raised an editor over it — that is what the desktop's group bar was, and typing a name into it and pressing ADD is still a way to make a group, which would otherwise have written to no vault at all. 1575 tests pass, five more than before: a group filed into a shared vault is listed and renamed there, the editor's picker does not move the keychain screen's, the parent picker offers only its own vault, a cross-vault drop is refused, and hiding a vault takes the cards without changing what its hosts dial.
This commit is contained in:
@@ -307,7 +307,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
|||||||
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **Shipped.** `Tag = 5` is a live item kind and `HostSecret.TagIds` names them, merged per tag so two people tagging one host both keep theirs — which is what `HostTag = 6` was going to buy, so it stays reserved and unused. Chips are drawn on host rows on both heads and toggled in the host editor, which also creates one inline; renaming and deleting are a TAGS category on the keychain screen. The filter box still searches name, address and notes only — a chip is read rather than typed. |
|
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **Shipped.** `Tag = 5` is a live item kind and `HostSecret.TagIds` names them, merged per tag so two people tagging one host both keep theirs — which is what `HostTag = 6` was going to buy, so it stays reserved and unused. Chips are drawn on host rows on both heads and toggled in the host editor, which also creates one inline; renaming and deleting are a TAGS category on the keychain screen. The filter box still searches name, address and notes only — a chip is read rather than typed. |
|
||||||
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. A group carries a `ParentId` and the defaults its hosts inherit, and the two heads draw the nesting differently on purpose. The desktop is a grid of cards holding one level at a time, the way a directory pane holds one directory; the phone has no room for a row of cards, so it flattens the whole tree into one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before on both — one flat list, no headings and no cards. |
|
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. A group carries a `ParentId` and the defaults its hosts inherit, and the two heads draw the nesting differently on purpose. The desktop is a grid of cards holding one level at a time, the way a directory pane holds one directory; the phone has no room for a row of cards, so it flattens the whole tree into one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before on both — one flat list, no headings and no cards. |
|
||||||
| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
|
| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
|
||||||
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a group a teammate made has no card and no heading and cannot be renamed from here. A host filed into one is drawn at the outermost level of the desktop's grid and under UNGROUPED on the phone — reachable either way, which is the point — with the chip on its card still naming the group, because the label is resolved through the wide map while the level is decided from the narrow list. |
|
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Shipped, and it is what makes a shared vault an arrangement rather than a heap.** Both halves are paid for: the row carries the vault it came out of, so rename and delete go back to it, and the vault's name is drawn beside the group's on the desktop's cards and the phone's headings wherever the session holds more than one — which is what lets two `production` groups sit side by side. The group editor asks which vault a new group goes into, on the terms the host editor's picker set: while adding only, hidden at one writable vault, and never offered afterwards, because the two are encrypted under different keys. The parent picker is that vault's alone, for the reason the host editor's group picker is — a parent in another vault is a level half the key holders cannot resolve. Dragging a host card onto a group card in another vault is refused with the reason, rather than filing it under an id nobody in its own vault can read. The resolution map stays wider than the list and still spans hidden vaults: a group lends a port, a username and a binding, so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. |
|
||||||
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
|
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
|
||||||
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
|
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
|
||||||
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
|
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
|
||||||
|
|||||||
@@ -1279,6 +1279,31 @@ rather than a broken role.
|
|||||||
under the people who share it is an administrative act reached without the role for it. The server refuses
|
under the people who share it is an administrative act reached without the role for it. The server refuses
|
||||||
it too — this is the interface not offering what the server would turn down.
|
it too — this is the interface not offering what the server would turn down.
|
||||||
|
|
||||||
|
### 12.10 A group made in a shared vault arrives as a group, not as a heap · **needs two accounts**
|
||||||
|
|
||||||
|
1. As Alice, on HOSTS, press + NEW GROUP, choose the shared vault in the editor's VAULT picker, name it
|
||||||
|
`production`, and give it a default port and username.
|
||||||
|
2. Add two hosts to the same shared vault and file them under it.
|
||||||
|
3. Make a second group called `production` in the **personal** vault.
|
||||||
|
4. Sync, then look at Bob's machine after his own sync.
|
||||||
|
|
||||||
|
**Pass on Alice's:** the two cards are told apart by the vault name printed under each — same name, two
|
||||||
|
folders — and on the phone the two headings carry the same badge. Opening either shows only its own hosts.
|
||||||
|
Dragging one of the shared vault's host cards onto the personal `production` card is **refused with a
|
||||||
|
sentence naming both vaults**, and the host stays where it was.
|
||||||
|
|
||||||
|
**Pass on Bob's:** the group is a card and a heading on his machine too, with the hosts inside it, and the
|
||||||
|
port and username they dial are the ones Alice typed into the group rather than 22 and his own account. He
|
||||||
|
can rename it, and the rename comes back to Alice rather than arriving as a second group in his personal
|
||||||
|
vault.
|
||||||
|
|
||||||
|
**Failure means:** a group that reaches Bob as UNGROUPED hosts is the resolution map having gone narrow
|
||||||
|
again — cosmetic on its own, except that the port and the username go with it, so his terminal dials the
|
||||||
|
wrong place. A rename of his that turns up as a new group in his own vault is the editor writing to the
|
||||||
|
active vault rather than to the row's, which forks the shelf and leaves Alice's untouched. Two identical
|
||||||
|
cards with no vault under them means one of them is a folder somebody outside the team can read, and
|
||||||
|
nothing on screen says which.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 13 — Unlocking the phone with a fingerprint
|
## Phase 13 — Unlocking the phone with a fingerprint
|
||||||
|
|||||||
@@ -125,7 +125,21 @@
|
|||||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5">
|
||||||
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
|
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="1" Classes="section" Text="{Binding Label}" Margin="7,0,0,0" />
|
|
||||||
|
<!--
|
||||||
|
The name, and the vault it is in where this session holds more than one. The badge is what
|
||||||
|
makes one heading per group survive a shared vault: two vaults may each hold a "production",
|
||||||
|
and this list has no nesting to tell the two apart with. Drawn as the same outline tag a host
|
||||||
|
row wears, so "which vault" looks the same wherever it is answered.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" Margin="7,0,0,0">
|
||||||
|
<TextBlock Classes="section" Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<Border Classes="tag outline" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding HasVaultBadge}">
|
||||||
|
<TextBlock Text="{Binding VaultBadge}" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9"
|
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
@@ -367,6 +381,27 @@
|
|||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
|
<TextBox Classes="field" Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ WHICH VAULT THIS GROUP WILL LIVE IN, on the same terms as the host editor's picker above and
|
||||||
|
for the same reason: it is what makes the group shared, and it cannot be changed afterwards.
|
||||||
|
Above INSIDE because it decides what INSIDE can offer — a parent belongs to one vault, and one
|
||||||
|
from another is a level half the readers cannot resolve.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding ShowsGroupEditorVaultChoice}">
|
||||||
|
<TextBlock Classes="label" Text="VAULT" Margin="0,4,0,0" />
|
||||||
|
<ComboBox ItemsSource="{Binding GroupEditorVaultChoices}"
|
||||||
|
SelectedItem="{Binding GroupEditorSelectedVault}"
|
||||||
|
HorizontalAlignment="Stretch" MinHeight="44">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="A group in a shared vault is visible to everybody holding that vault's key, and only hosts in the same vault can be filed under it." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="INSIDE" Margin="0,4,0,0" />
|
<TextBlock Classes="label" Text="INSIDE" Margin="0,4,0,0" />
|
||||||
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
|
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
|
||||||
SelectedItem="{Binding GroupEditorSelectedParent}"
|
SelectedItem="{Binding GroupEditorSelectedParent}"
|
||||||
|
|||||||
@@ -554,6 +554,34 @@
|
|||||||
|
|
||||||
<TextBox Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
|
<TextBox Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ WHICH VAULT THIS GROUP WILL LIVE IN, on the same terms as the host editor's picker
|
||||||
|
above: asked while adding, hidden where there is only one vault to write to, and never
|
||||||
|
offered for an existing group, because the vaults are encrypted under different keys and
|
||||||
|
moving an item between them is a delete and a retype.
|
||||||
|
|
||||||
|
A group in a shared vault is what gives a team an arrangement rather than a heap: the
|
||||||
|
people holding that vault's key see the folder, and the hosts inside it inherit its port,
|
||||||
|
its username and its key.
|
||||||
|
|
||||||
|
The parent picker below follows it, for the reason the host's group picker follows the
|
||||||
|
host's vault — a parent in another vault would be a level half the readers cannot resolve.
|
||||||
|
See VaultViewModel.ShowsGroupEditorVaultChoice.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="4" IsVisible="{Binding ShowsGroupEditorVaultChoice}">
|
||||||
|
<ComboBox ItemsSource="{Binding GroupEditorVaultChoices}"
|
||||||
|
SelectedItem="{Binding GroupEditorSelectedVault}"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Text="A group in a shared vault is visible to everybody holding that vault's key, and only hosts in the same vault can be filed under it." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
|
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
|
||||||
SelectedItem="{Binding GroupEditorSelectedParent}"
|
SelectedItem="{Binding GroupEditorSelectedParent}"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch">
|
||||||
|
|||||||
@@ -304,6 +304,17 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="{Binding Description}" FontSize="11"
|
<TextBlock Text="{Binding Description}" FontSize="11"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
<!--
|
||||||
|
Which vault this group is in, on the same rule and in the same place as the host
|
||||||
|
card's: only where there is more than one vault to be in. It matters more on a
|
||||||
|
folder than on a machine — two vaults may each hold a "production", and without
|
||||||
|
this the cards are two identical folders side by side, one of which a colleague
|
||||||
|
can read. It is also what says a group is shared at all.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
IsVisible="{Binding HasVaultBadge}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -39,29 +39,70 @@ internal interface ISidebarRow;
|
|||||||
internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded)
|
internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded)
|
||||||
: ISidebarRow
|
: ISidebarRow
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The vault this heading's group lives in, or empty where there is only one vault to be in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one thing a heading could not say while the headings were one vault's. Two vaults may each hold
|
||||||
|
/// a group called "production" — they are separate folders under separate keys — and a list with one
|
||||||
|
/// heading per group has nothing else to tell them apart with. Decided by the list rather than the row,
|
||||||
|
/// for the reason <see cref="HostRowViewModel.VaultBadge"/> is.
|
||||||
|
/// </remarks>
|
||||||
|
internal string VaultBadge { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this heading has a vault to name.</summary>
|
||||||
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||||
|
|
||||||
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
||||||
internal string Chevron => IsExpanded ? "▾" : "▸";
|
internal string Chevron => IsExpanded ? "▾" : "▸";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One group as it came out of a vault, with the vault it came out of.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The pair the group reload hands the group rebuild, and the vault half of it is what makes a group a
|
||||||
|
/// shared thing rather than a private one: a rename and a delete both have to go back to the vault the group
|
||||||
|
/// is in, and the row that offers them is drawn from a list that now spans every readable vault.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Item">The group, decrypted.</param>
|
||||||
|
/// <param name="VaultId">The vault it lives in.</param>
|
||||||
|
/// <param name="VaultName">That vault's display name.</param>
|
||||||
|
internal sealed record VaultGroupItem(VaultItem<HostGroupSecret> Item, Guid VaultId, string VaultName);
|
||||||
|
|
||||||
/// <summary>One group, as a row in the group list.</summary>
|
/// <summary>One group, as a row in the group list.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Thinner than the other row types because a group is thinner: a name, and how many hosts name it. The
|
/// Thinner than the other row types because a group is thinner: a name, and how many hosts name it. The
|
||||||
/// count is computed from the host list rather than stored on the group — see <see cref="HostGroupSecret"/>
|
/// count is computed from the host list rather than stored on the group — see <see cref="HostGroupSecret"/>
|
||||||
/// for why membership lives on the host — so it is passed in rather than read off the item.
|
/// for why membership lives on the host — so it is passed in rather than read off the item.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class HostGroupRowViewModel(VaultItem<HostGroupSecret> group, int hostCount)
|
internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
|
||||||
{
|
{
|
||||||
internal Guid EntityId => group.EntityId;
|
internal Guid EntityId => group.Item.EntityId;
|
||||||
|
|
||||||
internal HostGroupSecret Group => group.Secret;
|
/// <summary>Which vault this group lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||||
|
internal Guid VaultId => group.VaultId;
|
||||||
|
|
||||||
internal string Label => group.Secret.Label;
|
/// <summary>The vault's display name.</summary>
|
||||||
|
internal string VaultName => group.VaultName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The vault name to print on this card, or empty when there is only one vault to be in.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
|
||||||
|
internal string VaultBadge { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this row has a vault to name.</summary>
|
||||||
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||||
|
|
||||||
|
internal HostGroupSecret Group => group.Item.Secret;
|
||||||
|
|
||||||
|
internal string Label => group.Item.Secret.Label;
|
||||||
|
|
||||||
internal int HostCount => hostCount;
|
internal int HostCount => hostCount;
|
||||||
|
|
||||||
internal bool IsReadOnly => group.IsReadOnly;
|
internal bool IsReadOnly => group.Item.IsReadOnly;
|
||||||
|
|
||||||
internal string Badge => ItemBadge.For(group.IsBlocked, group.IsReadOnly, group.HasUnsyncedChanges);
|
internal string Badge =>
|
||||||
|
ItemBadge.For(group.Item.IsBlocked, group.Item.IsReadOnly, group.Item.HasUnsyncedChanges);
|
||||||
|
|
||||||
/// <summary>What the row says under the name.</summary>
|
/// <summary>What the row says under the name.</summary>
|
||||||
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
||||||
@@ -1047,13 +1088,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
private readonly SemaphoreSlim syncGate = new(1, 1);
|
private readonly SemaphoreSlim syncGate = new(1, 1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The groups as they came out of the vault, before the host counts are attached.
|
/// The groups as they came out of the vaults, before the host counts are attached.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Held between the group reload and the host reload, which are two passes because a group row says how
|
/// Held between the group reload and the host reload, which are two passes because a group row says how
|
||||||
/// many hosts name it and the hosts are read second. See <see cref="RebuildGroups"/>.
|
/// many hosts name it and the hosts are read second. See <see cref="RebuildGroups"/>. Every readable
|
||||||
|
/// vault's, each entry carrying which one it came from — a group is shared by being in a shared vault,
|
||||||
|
/// so the vault has to travel with it as far as the row that renames and deletes it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private IReadOnlyList<VaultItem<HostGroupSecret>> groupItems = [];
|
private IReadOnlyList<VaultGroupItem> groupItems = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The same groups by id, which is the shape the inheritance walk takes.
|
/// The same groups by id, which is the shape the inheritance walk takes.
|
||||||
@@ -1467,11 +1510,43 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// What the group's parent picker offers: "no parent", then every group that may legally be one.
|
/// What the group's parent picker offers: "no parent", then every group that may legally be one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||||||
|
/// <remarks>
|
||||||
|
/// One vault's, and the one this group is going into — see <see cref="BuildGroupParentChoices"/>. A
|
||||||
|
/// parent in another vault would be a group half the people holding this one's key cannot resolve, and
|
||||||
|
/// the tree they see would be missing a level nobody can point at.
|
||||||
|
/// </remarks>
|
||||||
internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];
|
internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private GroupChoice? groupEditorSelectedParent;
|
private GroupChoice? groupEditorSelectedParent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which vault a group being created will be filed into.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The same picker the host editor has, on the form beside it, and for the same reason: this is the
|
||||||
|
/// decision that makes the thing shared, it cannot be changed afterwards, and the only other control
|
||||||
|
/// that could have answered it is a standing preference on a different screen. A group is where hosts
|
||||||
|
/// are filed and what lends them a port, a username and a key — so putting one in a shared vault is how
|
||||||
|
/// a team gets an arrangement rather than twenty machines in a heap, which is most of what sharing is
|
||||||
|
/// for.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Filled from <see cref="TargetVaults"/>, so it offers what every other "file this into" control does:
|
||||||
|
/// vaults this session can both read and write.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal ObservableCollection<VaultChoiceViewModel> GroupEditorVaultChoices { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private VaultChoiceViewModel? groupEditorSelectedVault;
|
||||||
|
|
||||||
|
/// <summary>Whether the editor should be asking which vault this group goes into.</summary>
|
||||||
|
/// <inheritdoc cref="ShowsEditorVaultChoice" path="/remarks" />
|
||||||
|
internal bool ShowsGroupEditorVaultChoice =>
|
||||||
|
EditingGroupId is null && GroupEditorVaultChoices.Count > 1;
|
||||||
|
|
||||||
/// <summary>What the group's authentication picker offers, for the hosts beneath it.</summary>
|
/// <summary>What the group's authentication picker offers, for the hosts beneath it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The host picker's list without its first entry. A group cannot default to "ask for a password each
|
/// The host picker's list without its first entry. A group cannot default to "ask for a password each
|
||||||
@@ -1486,8 +1561,33 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
/// <summary>The group being renamed, or null when the box would create one.</summary>
|
/// <summary>The group being renamed, or null when the box would create one.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowsGroupEditorVaultChoice))]
|
||||||
private Guid? editingGroupId;
|
private Guid? editingGroupId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which vault the group editor will write to, or null until an editor has been opened.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Nullable where <see cref="editingHostVaultId"/> is not, because the group name box is bound whether
|
||||||
|
/// or not anything raised an editor over it — that is what the desktop's group bar was, and typing a
|
||||||
|
/// name into it and pressing ADD is still a way to make a group. <see cref="GroupEditorVaultId"/> is
|
||||||
|
/// what answers for that case, and it answers with the standing preference: a group made without
|
||||||
|
/// choosing a vault is a new item like any other.
|
||||||
|
/// </remarks>
|
||||||
|
private Guid? editingGroupVaultId;
|
||||||
|
|
||||||
|
/// <summary>The vault the group editor writes to, whether or not one was ever chosen for it.</summary>
|
||||||
|
private Guid GroupEditorVaultId => editingGroupVaultId ?? TargetVaultId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the vault a group being renamed is in, for the drawer's header, or empty.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Kept rather than looked up per redraw, and empty wherever there is only one vault to be in — the
|
||||||
|
/// same rule the badges follow, because a header naming the only vault there is says nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private string editingGroupVaultName = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SshKeyRowViewModel? selectedKey;
|
private SshKeyRowViewModel? selectedKey;
|
||||||
|
|
||||||
@@ -1839,14 +1939,23 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// The line under it: which keychain this is filed in, or what a group is for.
|
/// The line under it: which keychain this is filed in, or what a group is for.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
|
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
|
||||||
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
|
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
|
||||||
/// under different keys — so a control offering the move would be offering something no layer below
|
/// under different keys — so a control offering the move would be offering something no layer below
|
||||||
/// this can do. Where a *new* item goes is chosen on the keychain screen's own picker; see
|
/// this can do. Where a *new* item goes is chosen in the editor's own picker.
|
||||||
/// <see cref="TargetVaults"/>.
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A group being renamed says its vault here for the same reason a host being edited does, and it is
|
||||||
|
/// the only line that says it: the picker is hidden for an existing group, and renaming a colleague's
|
||||||
|
/// shelf without being told whose it is is exactly the edit worth naming. A group being *made* says
|
||||||
|
/// what a group is for instead, because the picker under it is already answering "which vault".
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
|
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
|
||||||
{
|
{
|
||||||
|
(_, true) when EditingGroupId is not null && editingGroupVaultName.Length > 0 =>
|
||||||
|
editingGroupVaultName,
|
||||||
(_, true) => "A heading, and what its hosts inherit",
|
(_, true) => "A heading, and what its hosts inherit",
|
||||||
(true, _) when editingEntityId is null => SelectedTargetVault?.Name ?? string.Empty,
|
(true, _) when editingEntityId is null => SelectedTargetVault?.Name ?? string.Empty,
|
||||||
_ => SelectedHost?.VaultName ?? string.Empty,
|
_ => SelectedHost?.VaultName ?? string.Empty,
|
||||||
@@ -3007,42 +3116,35 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <see cref="RebuildGroups"/>, which is where the two meet.
|
/// <see cref="RebuildGroups"/>, which is where the two meet.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Two reads, and they cover different vaults on purpose.</b> The editable list — the rows the
|
/// <b>Three shapes of the same read, and every one of them spans every readable vault.</b> The editable
|
||||||
/// sidebar draws headings from and the group editor renames — is the active vault's alone. The
|
/// list is what the cards and the headings are drawn from and what the editor renames; the per-vault
|
||||||
/// resolution map is every readable vault's.
|
/// lists are what a picker offers, because a picker is always asking about one vault; the map is what a
|
||||||
|
/// host's <c>GroupId</c> resolves through.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which
|
/// <b>The list stopped being the active vault's, which is what makes a group shareable.</b> It was
|
||||||
/// vault it lives in, because rename and delete both need it, and two vaults may hold groups with the
|
/// narrow because a row shown across vaults has to carry the vault it lives in — a rename and a delete
|
||||||
/// same name, which the one-heading-per-group layout cannot tell apart. Both are worth doing and neither
|
/// both need it — and because two vaults may hold groups with the same name, which a list with one
|
||||||
/// is a merge's business. Recorded in <c>docs/design-import-gaps.md</c>.
|
/// heading per group cannot tell apart. Both are now paid for rather than avoided: the row carries the
|
||||||
|
/// vault, and the badge beside the name says which. Until it did, a group a colleague made in a shared
|
||||||
|
/// vault had no card, no heading and no way to be corrected from the machine looking straight at the
|
||||||
|
/// hosts filed under it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The map could not stay narrow, and that changed with inheritance.</b> While a group was only a
|
/// <b>The map was widened first, and it had to be, which is why it is separate.</b> While a group was
|
||||||
/// name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
|
/// only a name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
|
||||||
/// nothing else — the same thing the sidebar shows for a group that has been deleted. Since a group
|
/// nothing else. Since a group began lending a port, a username and a binding, the same omission
|
||||||
/// began lending a port, a username and a binding, the same omission silently drops all three: that host
|
/// silently drops all three: that host would dial 22 as nobody, while the machine it names is on 2222 as
|
||||||
/// would dial 22 as nobody, while the machine it names is on 2222 as <c>deploy</c>, and nothing on
|
/// <c>deploy</c>. So the map answers "what does this id say" for every vault, including the ones
|
||||||
/// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong
|
/// <see cref="IsVaultShown"/> is keeping off the screen — hiding a vault must never change what a host
|
||||||
/// place.
|
/// dials, and the list is where hiding is applied. See <see cref="RebuildGroups"/>.
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across
|
|
||||||
/// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever
|
|
||||||
/// asked "what does this id say", which is exactly the question a host's <c>GroupId</c> poses.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var unreadable = 0;
|
var unreadable = 0;
|
||||||
var resolvable = new Dictionary<Guid, HostGroupSecret>();
|
var resolvable = new Dictionary<Guid, HostGroupSecret>();
|
||||||
|
var items = new List<VaultGroupItem>();
|
||||||
// Emptied before the loop rather than assigned inside it, because the active vault may not be in
|
|
||||||
// the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only
|
|
||||||
// ever writes on a match would leave the last readable vault's groups on screen as though they were
|
|
||||||
// still this one's.
|
|
||||||
groupItems = [];
|
|
||||||
|
|
||||||
var perVault = new Dictionary<Guid, List<GroupChoice>>();
|
var perVault = new Dictionary<Guid, List<GroupChoice>>();
|
||||||
|
|
||||||
foreach (var vault in session.ReadableVaults)
|
foreach (var vault in session.ReadableVaults)
|
||||||
@@ -3056,6 +3158,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
foreach (var group in listing.Items)
|
foreach (var group in listing.Items)
|
||||||
{
|
{
|
||||||
resolvable[group.EntityId] = group.Secret;
|
resolvable[group.EntityId] = group.Secret;
|
||||||
|
|
||||||
|
items.Add(new VaultGroupItem(group, vault.VaultId, vault.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
perVault[vault.VaultId] =
|
perVault[vault.VaultId] =
|
||||||
@@ -3064,13 +3168,18 @@ internal sealed partial class VaultViewModel(
|
|||||||
.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)
|
.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)
|
||||||
.Select(group => new GroupChoice(group.EntityId, group.Secret.Label)),
|
.Select(group => new GroupChoice(group.EntityId, group.Secret.Label)),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (vault.VaultId == session.ActiveVaultId)
|
// Ordered here rather than in the rebuild, and by the same three keys the host and key lists use:
|
||||||
{
|
// the vault new items go into first, then by vault name, then by label inside each. Two vaults may
|
||||||
|
// hold a group with the same name and both are drawn; which vault it is in is what tells them apart.
|
||||||
groupItems =
|
groupItems =
|
||||||
[.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
[
|
||||||
}
|
.. items
|
||||||
}
|
.OrderByDescending(entry => entry.VaultId == session.ActiveVaultId)
|
||||||
|
.ThenBy(entry => entry.VaultName, StringComparer.CurrentCulture)
|
||||||
|
.ThenBy(entry => entry.Item.Secret.Label, StringComparer.CurrentCulture),
|
||||||
|
];
|
||||||
|
|
||||||
groupsById = resolvable;
|
groupsById = resolvable;
|
||||||
groupsByVault = perVault;
|
groupsByVault = perVault;
|
||||||
@@ -3188,21 +3297,40 @@ internal sealed partial class VaultViewModel(
|
|||||||
groupId is { } id && groupsById.TryGetValue(id, out var group) ? group.Label : string.Empty;
|
groupId is { } id && groupsById.TryGetValue(id, out var group) ? group.Label : string.Empty;
|
||||||
|
|
||||||
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
|
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Where a hidden vault's groups are dropped, and the only place they are.</b> The reload above keeps
|
||||||
|
/// every readable vault's, because the map built beside them decides what a host dials; this is the list
|
||||||
|
/// a person looks at, and a card for a vault whose forty hosts have been switched off is a folder that
|
||||||
|
/// cannot be opened onto anything. Dropping them here rather than at the read is what keeps the two
|
||||||
|
/// answers apart. See <see cref="IsVaultShown"/>.
|
||||||
|
/// </remarks>
|
||||||
private void RebuildGroups()
|
private void RebuildGroups()
|
||||||
{
|
{
|
||||||
var selectedId = SelectedGroup?.EntityId;
|
var selectedId = SelectedGroup?.EntityId;
|
||||||
var filteredId = GroupFilter?.EntityId;
|
var filteredId = GroupFilter?.EntityId;
|
||||||
|
|
||||||
|
// The same test the host rows are badged by, and it counts the vaults this session can read rather
|
||||||
|
// than the ones with a group in them: a badge that appeared the moment a colleague made their first
|
||||||
|
// group would be a column arriving on its own.
|
||||||
|
var several = session.ReadableVaults.Take(2).Count() > 1;
|
||||||
|
|
||||||
Groups.Clear();
|
Groups.Clear();
|
||||||
|
|
||||||
foreach (var group in groupItems)
|
foreach (var group in groupItems.Where(entry => IsVaultShown(entry.VaultId)))
|
||||||
{
|
{
|
||||||
// Counted over the shown vaults rather than over every host, so a card cannot claim members the
|
// Counted over the shown vaults rather than over every host, so a card cannot claim members the
|
||||||
// grid beside it is not drawing. Not counted over VisibleHosts, which would be both too early —
|
// grid beside it is not drawing. Not counted over VisibleHosts, which would be both too early —
|
||||||
// that list is rebuilt after this — and wrong: a card must not lose members to the search box.
|
// that list is rebuilt after this — and wrong: a card must not lose members to the search box.
|
||||||
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId && IsVaultShown(row.VaultId));
|
var count = Hosts.Count(
|
||||||
|
row => row.Host.GroupId == group.Item.EntityId && IsVaultShown(row.VaultId));
|
||||||
|
|
||||||
Groups.Add(new HostGroupRowViewModel(group, count));
|
Groups.Add(new HostGroupRowViewModel(group, count)
|
||||||
|
{
|
||||||
|
// Only when there is something to tell apart, as on a host card — and it matters more here,
|
||||||
|
// because two vaults may each hold a "production" and the cards would otherwise be two
|
||||||
|
// identical folders side by side.
|
||||||
|
VaultBadge = several ? group.VaultName.ToUpperInvariant() : string.Empty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
|
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
|
||||||
@@ -3436,18 +3564,28 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
foreach (var group in Groups)
|
foreach (var group in Groups)
|
||||||
{
|
{
|
||||||
AddSection(group.EntityId, group.Label, host => host.Host.GroupId == group.EntityId);
|
AddSidebarSection(shown, group, host => host.Host.GroupId == group.EntityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
AddSection(
|
AddSidebarSection(
|
||||||
|
shown,
|
||||||
null,
|
null,
|
||||||
"UNGROUPED",
|
|
||||||
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
||||||
onlyWhenOccupied: true);
|
onlyWhenOccupied: true);
|
||||||
|
}
|
||||||
|
|
||||||
void AddSection(
|
/// <summary>Adds one heading to the sidebar, and the hosts under it when it is not folded away.</summary>
|
||||||
Guid? groupId,
|
/// <param name="shown">The hosts that survived the filters, which every section draws its members from.</param>
|
||||||
string label,
|
/// <param name="group">
|
||||||
|
/// The group this heading is for, or null for the ungrouped one. The row rather than its id and label,
|
||||||
|
/// because a heading now says which vault the group is in as well — and null has no vault to name, since
|
||||||
|
/// it is every vault's unfiled hosts at once.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="belongs">Which of the shown hosts fall under it.</param>
|
||||||
|
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
|
||||||
|
private void AddSidebarSection(
|
||||||
|
IReadOnlyList<HostRowViewModel> shown,
|
||||||
|
HostGroupRowViewModel? group,
|
||||||
Func<HostRowViewModel, bool> belongs,
|
Func<HostRowViewModel, bool> belongs,
|
||||||
bool onlyWhenOccupied = false)
|
bool onlyWhenOccupied = false)
|
||||||
{
|
{
|
||||||
@@ -3458,9 +3596,16 @@ internal sealed partial class VaultViewModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var expanded = !collapsedGroups.Contains(groupId ?? Guid.Empty);
|
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
|
||||||
|
|
||||||
SidebarRows.Add(new SidebarGroupHeader(groupId, label, members.Length, expanded));
|
SidebarRows.Add(new SidebarGroupHeader(
|
||||||
|
group?.EntityId,
|
||||||
|
group?.Label ?? "UNGROUPED",
|
||||||
|
members.Length,
|
||||||
|
expanded)
|
||||||
|
{
|
||||||
|
VaultBadge = group?.VaultBadge ?? string.Empty,
|
||||||
|
});
|
||||||
|
|
||||||
if (!expanded)
|
if (!expanded)
|
||||||
{
|
{
|
||||||
@@ -3472,7 +3617,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
SidebarRows.Add(member);
|
SidebarRows.Add(member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Folds one group's hosts away, or brings them back.</summary>
|
/// <summary>Folds one group's hosts away, or brings them back.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -3516,8 +3660,16 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// host would be a save the user never asked for, and one they would then be unable to cancel.
|
/// host would be a save the user never asked for, and one they would then be unable to cancel.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A group id that is not in this vault is not refused — it is treated as no group at all, which is what
|
/// A group id that is in no readable vault is not refused — it is treated as no group at all, which is
|
||||||
/// the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
|
/// what the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A group in a different vault to the host is refused, and said so.</b> The cards are every readable
|
||||||
|
/// vault's since a group became a thing that can be shared, so this gesture can now be aimed across a
|
||||||
|
/// boundary that a save cannot cross: the host would keep an id only the other vault's holders can
|
||||||
|
/// resolve, and everybody in this one would see it filed under nothing. Refusing beats the two
|
||||||
|
/// alternatives — filing it anyway is the quiet wrong, and treating it as "no group" would unfile a host
|
||||||
|
/// somebody was plainly trying to file.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
|
/// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
|
||||||
@@ -3535,33 +3687,24 @@ internal sealed partial class VaultViewModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.IsReadOnly)
|
var card = request.GroupId is { } wanted
|
||||||
{
|
? Groups.FirstOrDefault(group => group.EntityId == wanted)
|
||||||
// The same refusal editing makes, and for the same reason: re-encoding an item a newer client
|
|
||||||
// wrote would drop the fields this build has no concept of.
|
|
||||||
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsEditing)
|
|
||||||
{
|
|
||||||
Status = "Finish or cancel the host you are editing first.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted)
|
|
||||||
? wanted
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (RefusesTheDrop(row, card))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? target = card?.EntityId;
|
||||||
|
|
||||||
if (row.Host.GroupId == target)
|
if (row.Host.GroupId == target)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var moved = row.Host with { GroupId = target };
|
var moved = row.Host with { GroupId = target };
|
||||||
var name = target is null
|
var name = card?.Label ?? "no group";
|
||||||
? "no group"
|
|
||||||
: Groups.First(group => group.EntityId == target).Label;
|
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
$"Filing {row.Label} under {name}…",
|
$"Filing {row.Label} under {name}…",
|
||||||
@@ -3587,6 +3730,40 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a drop has to be turned down, saying why on the status line when it does.</summary>
|
||||||
|
/// <param name="row">The host that was dragged.</param>
|
||||||
|
/// <param name="card">The group card it was dropped on, or null for the drop that unfiles a host.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Three refusals rather than one, and separated from the write so that the reason reaches the status
|
||||||
|
/// line before anything is encrypted. Every one of them is a thing the layer below would either refuse
|
||||||
|
/// or, worse, accept: a newer client's item re-encoded loses fields, a write under an open editor is a
|
||||||
|
/// save nobody asked for, and a group in another vault is an id half the readers cannot resolve.
|
||||||
|
/// </remarks>
|
||||||
|
private bool RefusesTheDrop(HostRowViewModel row, HostGroupRowViewModel? card)
|
||||||
|
{
|
||||||
|
if (row.IsReadOnly)
|
||||||
|
{
|
||||||
|
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsEditing)
|
||||||
|
{
|
||||||
|
Status = "Finish or cancel the host you are editing first.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card is not null && card.VaultId != row.VaultId)
|
||||||
|
{
|
||||||
|
Status =
|
||||||
|
$"'{card.Label}' is in {card.VaultName} and '{row.Label}' is in {row.VaultName}. "
|
||||||
|
+ "A host can only be filed under a group in its own vault.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Whether one host belongs on the grid at the level it is currently showing.</summary>
|
/// <summary>Whether one host belongs on the grid at the level it is currently showing.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -4421,7 +4598,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
|
/// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
|
||||||
/// <em>wrong</em> — hosts point at ids, so the two are genuinely separate folders — and refusing the
|
/// <em>wrong</em> — hosts point at ids, so the two are genuinely separate folders — and refusing the
|
||||||
/// second one would mean a name somebody chose on another machine could block one they choose here, at
|
/// second one would mean a name somebody chose on another machine could block one they choose here, at
|
||||||
/// the next sync, with the rename already saved.
|
/// the next sync, with the rename already saved. Two vaults holding one each is not even confusing: the
|
||||||
|
/// card and the heading both say which vault, and they are as separate as two vaults can make them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Writes to <see cref="editingGroupVaultId"/>, which is the group's own vault on a rename and whatever
|
||||||
|
/// the picker said when the form opened on a create. Never the active vault, which is what it was while
|
||||||
|
/// the list held one vault's groups: a rename typed into a colleague's group would have created a second
|
||||||
|
/// group of that name in the personal vault and left theirs untouched.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -4454,13 +4638,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
if (renaming is { } entityId)
|
if (renaming is { } entityId)
|
||||||
{
|
{
|
||||||
await session.HostGroups
|
await session.HostGroups
|
||||||
.UpdateAsync(session.ActiveVaultId, entityId, group, cancellationToken)
|
.UpdateAsync(GroupEditorVaultId, entityId, group, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await session.HostGroups
|
await session.HostGroups
|
||||||
.CreateAsync(session.ActiveVaultId, group, cancellationToken)
|
.CreateAsync(GroupEditorVaultId, group, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4491,10 +4675,19 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
EditingGroupId = row.EntityId;
|
EditingGroupId = row.EntityId;
|
||||||
|
|
||||||
|
// The group's own vault, and it does not move — the same rule an existing host's follows, and the
|
||||||
|
// same reason: the two are encrypted under different keys, so saving anywhere else would leave a
|
||||||
|
// copy behind rather than move anything. The picker is not drawn for an existing group at all.
|
||||||
|
editingGroupVaultId = row.VaultId;
|
||||||
|
editingGroupVaultName = row.HasVaultBadge ? row.VaultName : string.Empty;
|
||||||
|
|
||||||
GroupEditorLabel = row.Label;
|
GroupEditorLabel = row.Label;
|
||||||
GroupEditorDefaultPort = row.Group.DefaultPort;
|
GroupEditorDefaultPort = row.Group.DefaultPort;
|
||||||
GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;
|
GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;
|
||||||
|
|
||||||
|
// Before the parent picker, because that picker is one vault's and this is which one.
|
||||||
|
BuildGroupEditorVaultChoices(row.VaultId);
|
||||||
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
|
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
|
||||||
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
|
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
|
||||||
|
|
||||||
@@ -4561,6 +4754,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// as "and the next group goes inside it" would nest one because somebody had highlighted something,
|
/// as "and the next group goes inside it" would nest one because somebody had highlighted something,
|
||||||
/// while the open group is the screen everybody can see they are on.
|
/// while the open group is the screen everybody can see they are on.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>And in the open group's vault, which is where this differs from <see cref="NewHost"/> a second
|
||||||
|
/// time.</b> A host opens on the standing "new items go to" preference and takes the open group only if
|
||||||
|
/// that group happens to be in the same vault; a group made inside another group is in that group's
|
||||||
|
/// vault by construction, because a parent in a second vault is a level half the readers cannot resolve.
|
||||||
|
/// Defaulting to the preference instead would answer "+ NEW GROUP inside PLATFORM" with a group
|
||||||
|
/// somewhere else and no parent — a form that silently dropped the one thing the button said.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void NewGroup()
|
private void NewGroup()
|
||||||
@@ -4574,6 +4775,17 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
ClearGroupEditor();
|
ClearGroupEditor();
|
||||||
|
|
||||||
|
// The open group's vault, where there is one and this session can write to it. A viewer of a shared
|
||||||
|
// vault gets the standing preference instead — and, with it, no parent, because the group they were
|
||||||
|
// looking inside belongs to a vault they cannot add to.
|
||||||
|
if (GroupFilter is { } open
|
||||||
|
&& GroupEditorVaultChoices.FirstOrDefault(choice => choice.VaultId == open.VaultId) is { } vault)
|
||||||
|
{
|
||||||
|
// Assigned rather than written to the field, so the parent picker is refilled for it: the
|
||||||
|
// handler below is what keeps the two in step, here and when the user moves the picker by hand.
|
||||||
|
GroupEditorSelectedVault = vault;
|
||||||
|
}
|
||||||
|
|
||||||
// Falls back to no parent, which is both what the picker's first entry says and what the phone always
|
// Falls back to no parent, which is both what the picker's first entry says and what the phone always
|
||||||
// gets: it has no group cards and no way to go inside one, so nothing there is ever open.
|
// gets: it has no group cards and no way to go inside one, so nothing there is ever open.
|
||||||
GroupEditorSelectedParent =
|
GroupEditorSelectedParent =
|
||||||
@@ -4624,21 +4836,31 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// terminates on a cycle. Walking down would need a child index this view model does not keep, and
|
/// terminates on a cycle. Walking down would need a child index this view model does not keep, and
|
||||||
/// building one that has to survive a cycle is the same problem twice.
|
/// building one that has to survive a cycle is the same problem twice.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One vault's candidates, and it is the vault this group is going into rather than the one it is
|
||||||
|
/// on screen beside.</b> The list this picker used to be built from held one vault's groups, so the
|
||||||
|
/// restriction came free; it spans every readable vault now, and offering all of them would let somebody
|
||||||
|
/// file a shared group under a personal one — a parent nobody else can resolve, whose port and username
|
||||||
|
/// would then be lent to their hosts and to nobody else's. Which is the same failure the host editor's
|
||||||
|
/// group picker was fixed for, one level up.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
|
private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
|
||||||
{
|
{
|
||||||
GroupEditorParentChoices.Clear();
|
GroupEditorParentChoices.Clear();
|
||||||
GroupEditorParentChoices.Add(GroupChoice.None);
|
GroupEditorParentChoices.Add(GroupChoice.None);
|
||||||
|
|
||||||
foreach (var candidate in groupItems.Where(item => item.EntityId != groupId))
|
foreach (var candidate in groupItems.Where(
|
||||||
|
entry => entry.VaultId == GroupEditorVaultId && entry.Item.EntityId != groupId))
|
||||||
{
|
{
|
||||||
var descends = HostInheritance
|
var descends = HostInheritance
|
||||||
.Chain(candidate.EntityId, groupsById)
|
.Chain(candidate.Item.EntityId, groupsById)
|
||||||
.Any(entry => entry.Id == groupId);
|
.Any(entry => entry.Id == groupId);
|
||||||
|
|
||||||
if (!descends)
|
if (!descends)
|
||||||
{
|
{
|
||||||
GroupEditorParentChoices.Add(new GroupChoice(candidate.EntityId, candidate.Secret.Label));
|
GroupEditorParentChoices.Add(
|
||||||
|
new GroupChoice(candidate.Item.EntityId, candidate.Item.Secret.Label));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4654,6 +4876,53 @@ internal sealed partial class VaultViewModel(
|
|||||||
?? GroupChoice.None;
|
?? GroupChoice.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Refills the group editor's vault picker, landing on the vault the editor will write to.</summary>
|
||||||
|
/// <inheritdoc cref="BuildEditorVaultChoices" path="/remarks" />
|
||||||
|
private void BuildGroupEditorVaultChoices(Guid vaultId)
|
||||||
|
{
|
||||||
|
GroupEditorVaultChoices.Clear();
|
||||||
|
|
||||||
|
foreach (var choice in TargetVaults)
|
||||||
|
{
|
||||||
|
GroupEditorVaultChoices.Add(choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null where the group's vault is one this session cannot write — a shared vault this account is a
|
||||||
|
// viewer of. The picker is not drawn for an existing group anyway, and an empty box is a better
|
||||||
|
// answer than an option that would move the group if it were touched.
|
||||||
|
GroupEditorSelectedVault =
|
||||||
|
GroupEditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(ShowsGroupEditorVaultChoice));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a half-typed group into the vault just chosen for it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only while creating, for the reason <see cref="OnEditorSelectedVaultChanged"/> gives: a group cannot
|
||||||
|
/// be moved between vaults, so a path that reassigned this on a rename would write a second group into
|
||||||
|
/// the other vault and leave the original standing with the old name.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnGroupEditorSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||||
|
{
|
||||||
|
if (value is null || EditingGroupId is not null || editingGroupVaultId == value.VaultId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editingGroupVaultId = value.VaultId;
|
||||||
|
|
||||||
|
// The parent picker is the vault's, so it has to be rebuilt — and whatever was chosen in it belongs
|
||||||
|
// to the vault just left, so it is dropped rather than carried: a group is one item in one vault,
|
||||||
|
// and there is nothing in the new one it could mean instead. The defaults below it are not touched,
|
||||||
|
// because a key or a credential may legitimately come from another vault, exactly as a host's may.
|
||||||
|
//
|
||||||
|
// Guid.Empty for the group being edited, because the guard above means there is not one: this only
|
||||||
|
// ever runs while creating, and a group that does not exist yet cannot be its own parent.
|
||||||
|
BuildGroupParentChoices(Guid.Empty, parentId: null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Fills the group's binding picker, keeping whatever it currently defaults to selectable.</summary>
|
/// <summary>Fills the group's binding picker, keeping whatever it currently defaults to selectable.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <see cref="BuildAuthenticationChoices"/> without the typed-password entry and without the inherited
|
/// <see cref="BuildAuthenticationChoices"/> without the typed-password entry and without the inherited
|
||||||
@@ -4717,6 +4986,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
GroupEditorDefaultPort = null;
|
GroupEditorDefaultPort = null;
|
||||||
GroupEditorDefaultUsername = string.Empty;
|
GroupEditorDefaultUsername = string.Empty;
|
||||||
|
|
||||||
|
// Back to the standing preference rather than to whatever the last group edited was in, which is
|
||||||
|
// the same clearing every other field here gets and matters more than any of them: a vault carried
|
||||||
|
// over from a colleague's group is where the next one would silently go.
|
||||||
|
editingGroupVaultId = TargetVaultId;
|
||||||
|
editingGroupVaultName = string.Empty;
|
||||||
|
|
||||||
|
// Before the parent choices, which are that vault's.
|
||||||
|
BuildGroupEditorVaultChoices(GroupEditorVaultId);
|
||||||
BuildGroupParentChoices(Guid.Empty, parentId: null);
|
BuildGroupParentChoices(Guid.Empty, parentId: null);
|
||||||
BuildGroupAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
|
BuildGroupAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
|
||||||
}
|
}
|
||||||
@@ -4751,7 +5028,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
PendingDeletion = new DeletionRequest(
|
PendingDeletion = new DeletionRequest(
|
||||||
DeletionTarget.Group,
|
DeletionTarget.Group,
|
||||||
row.EntityId,
|
row.EntityId,
|
||||||
$"Delete the group '{row.Label}'?",
|
|
||||||
|
// Named with its vault where there is more than one, because two of them may hold a group of
|
||||||
|
// this name and the question is about exactly one of the two. The badge is already the answer
|
||||||
|
// the card gives; this is the same answer at the moment it decides something.
|
||||||
|
row.HasVaultBadge
|
||||||
|
? $"Delete the group '{row.Label}' in {row.VaultName}?"
|
||||||
|
: $"Delete the group '{row.Label}'?",
|
||||||
HowFarADeletionGoes("The group"),
|
HowFarADeletionGoes("The group"),
|
||||||
row.HostCount switch
|
row.HostCount switch
|
||||||
{
|
{
|
||||||
@@ -4774,8 +5057,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
"Deleting…",
|
"Deleting…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
|
// The row's own vault, which is why the row is re-found above rather than the id being
|
||||||
|
// enough: a tombstone written to the active vault would delete nothing and leave a
|
||||||
|
// colleague's group standing while this machine reported it gone.
|
||||||
await session.HostGroups
|
await session.HostGroups
|
||||||
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
|
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
if (EditingGroupId == entityId)
|
if (EditingGroupId == entityId)
|
||||||
@@ -7515,8 +7801,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(GroupSaveLabel));
|
OnPropertyChanged(nameof(GroupSaveLabel));
|
||||||
|
|
||||||
// Which of the two things the group editor is doing, which its header says as well as its button.
|
// Which of the two things the group editor is doing, which its header says as well as its button —
|
||||||
|
// and, under it, the vault a group being renamed is in, which only a rename has an answer for.
|
||||||
OnPropertyChanged(nameof(DrawerTitle));
|
OnPropertyChanged(nameof(DrawerTitle));
|
||||||
|
OnPropertyChanged(nameof(DrawerSubtitle));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -612,12 +612,216 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
|
vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A group is a shelf, and a shared vault is what makes it everybody's shelf. The assertions are the
|
||||||
|
/// three things that were missing while the group list was the active vault's alone: it is listed at
|
||||||
|
/// all, the row says which vault it is in, and a rename typed into it goes back to that vault rather
|
||||||
|
/// than forking a second group of the new name into the personal one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Reloaded between the write and the read, so what is asserted is what came back out of the vault
|
||||||
|
/// rather than the row the save left behind.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AGroupFiledIntoASharedVault_IsListedThereAndRenamedThere()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.ShowsGroupEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
|
||||||
|
|
||||||
|
vault.GroupEditorSelectedVault =
|
||||||
|
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
|
||||||
|
vault.GroupEditorLabel = "production";
|
||||||
|
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
var group = vault.Groups.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
group.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||||
|
group.VaultBadge.ShouldBe("PLATFORM SECRETS", "a card in a session holding two vaults says which");
|
||||||
|
|
||||||
|
vault.SelectedGroup = group;
|
||||||
|
vault.EditGroupCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.ShowsGroupEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
|
||||||
|
|
||||||
|
vault.DrawerSubtitle.ShouldBe(
|
||||||
|
"Platform secrets", "with no picker drawn, the header is what says whose shelf this is");
|
||||||
|
|
||||||
|
vault.GroupEditorLabel = "live";
|
||||||
|
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
var renamed = vault.Groups.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
renamed.Label.ShouldBe("live");
|
||||||
|
renamed.VaultId.ShouldBe(sharedVaultId, "a rename must not fork a copy into the personal vault");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The group editor's picker is the group's, exactly as the host editor's is the host's: moving it must
|
||||||
|
/// not move the keychain screen's standing preference, and moving that one must not move a group
|
||||||
|
/// half-typed here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The second half is the one worth the test. The picker is read when the form opens and the vault is
|
||||||
|
/// captured there, so a click on the other screen between typing the name and pressing ADD cannot
|
||||||
|
/// redirect the group somebody was making.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheGroupEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
|
||||||
|
{
|
||||||
|
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 personal = vault.SelectedTargetVault!;
|
||||||
|
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.GroupEditorSelectedVault =
|
||||||
|
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
|
||||||
|
vault.GroupEditorLabel = "production";
|
||||||
|
|
||||||
|
// Moved back after the editor opened, the way a click on the keychain screen would. The group must
|
||||||
|
// still land in the shared vault.
|
||||||
|
vault.SelectedTargetVault = personal;
|
||||||
|
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||||
|
|
||||||
|
vault.SelectedTargetVault.ShouldBe(
|
||||||
|
personal, "the editor's picker is the group's, not the screen's standing preference");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A parent belongs to one vault, and a group filed under one in another vault would be a level half
|
||||||
|
/// the people holding the key cannot resolve — their hosts would inherit a port and a username from
|
||||||
|
/// nothing. The same rule the host editor's group picker follows, one level up the same tree.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AGroupsParentPicker_OffersOnlyTheVaultItIsGoingInto()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
// In the personal vault, which is where the standing preference points.
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
vault.GroupEditorLabel = "estate";
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Groups.ShouldHaveSingleItem().Label.ShouldBe("estate", vault.Status);
|
||||||
|
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.GroupEditorParentChoices
|
||||||
|
.Any(choice => string.Equals(choice.Label, "estate", StringComparison.Ordinal))
|
||||||
|
.ShouldBeTrue("a group in the personal vault may be filed under a personal group");
|
||||||
|
|
||||||
|
vault.GroupEditorSelectedVault =
|
||||||
|
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
|
||||||
|
vault.GroupEditorParentChoices.ShouldHaveSingleItem()
|
||||||
|
.EntityId.ShouldBeNull("only 'no parent' is left once the group is going somewhere else");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Dragging a host card onto a group card is the one gesture that files a host without opening its
|
||||||
|
/// editor, and it can now be aimed across a vault boundary, because both grids draw every readable
|
||||||
|
/// vault. The write it would make is the exact thing the host editor's group picker was fixed to
|
||||||
|
/// prevent: an id only the other vault's holders can resolve.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Refused and said so, rather than quietly treated as "no group" — the user is plainly filing
|
||||||
|
/// something, and unfiling it instead would be the wrong answer delivered silently.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AHostDraggedOntoAnotherVaultsGroup_IsRefusedRatherThanFiledUnderIt()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.GroupEditorSelectedVault =
|
||||||
|
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
|
||||||
|
vault.GroupEditorLabel = "production";
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// The host stays in the personal vault, which is where a new one goes without being told otherwise.
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
vault.EditorLabel = "prod-db";
|
||||||
|
vault.EditorHostname = "db.internal";
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||||
|
var group = vault.Groups.Single(row => row.VaultId == sharedVaultId);
|
||||||
|
|
||||||
|
host.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
|
||||||
|
|
||||||
|
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group.EntityId));
|
||||||
|
|
||||||
|
vault.Status.ShouldContain("its own vault");
|
||||||
|
|
||||||
|
vault.Hosts
|
||||||
|
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
|
||||||
|
.Host.GroupId
|
||||||
|
.ShouldBeNull("the host is left where it was rather than filed under an unresolvable group");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
||||||
/// shared vault has to stay there, because hosts are read across every readable vault and so come back.
|
/// shared vault has to stay there, because hosts are read across every readable vault and so come back;
|
||||||
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
|
/// so does a group, since its list spans them too. Tags are not — the editable list is the active
|
||||||
/// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
|
/// vault's alone, like buckets — so a tag filed anywhere else would be created, pushed, reported as
|
||||||
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
|
/// added and then invisible, with nothing on the keychain screen able to rename or delete it and no
|
||||||
|
/// active-vault switcher to go and find it with.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
|
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
|
||||||
|
|||||||
@@ -451,8 +451,10 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// It is drawn in the menu and ticked, because a vault missing from a list of vaults reads as something
|
/// It is drawn in the menu and ticked, because a vault missing from a list of vaults reads as something
|
||||||
/// having gone wrong — and it cannot be switched off, because snippets, logs, buckets and the editable
|
/// having gone wrong — and it cannot be switched off, because snippets, logs, buckets and the editable
|
||||||
/// group and tag lists are all read from it alone. Switching it off would empty half the application
|
/// tag list are all read from it alone. Switching it off would empty half the application rather than
|
||||||
/// rather than filter it, so the refusal says why instead of doing nothing.
|
/// filter it, so the refusal says why instead of doing nothing. The group list is no longer among them:
|
||||||
|
/// it spans every readable vault, and hiding one drops that vault's cards and headings the way it drops
|
||||||
|
/// its hosts.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ThePersonalVaultIsListedAndCannotBeHidden()
|
public async Task ThePersonalVaultIsListedAndCannotBeHidden()
|
||||||
@@ -471,6 +473,67 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
|||||||
shell.StatusMessage.ShouldContain("always shown");
|
shell.StatusMessage.ShouldContain("always shown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The two halves of a group, and hiding a vault has to move exactly one of them. The cards and
|
||||||
|
/// headings are a list a person reads, so a hidden vault's group leaves it — a folder that cannot be
|
||||||
|
/// opened onto anything is worse than no folder. What a group also is is a port, a username and a
|
||||||
|
/// binding lent to the hosts beneath it, and that must not move: those hosts are still in
|
||||||
|
/// <c>Hosts</c>, which is what the connect path and the transfers screen read.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asserted through the resolved port rather than through the map directly, because the resolved port
|
||||||
|
/// is what a connection actually dials. A hidden vault whose hosts silently fell back to 22 would be
|
||||||
|
/// this split having collapsed, and nothing on screen would say so.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task HidingAVault_TakesItsGroupCardsButNotWhatItsHostsDial()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
await shell.Vaults.LoadAsync(Token);
|
||||||
|
|
||||||
|
var teamVaultId = await CreateVaultAsync("Platform secrets");
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.NewGroupCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.GroupEditorSelectedVault =
|
||||||
|
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == teamVaultId);
|
||||||
|
|
||||||
|
vault.GroupEditorLabel = "production";
|
||||||
|
vault.GroupEditorDefaultPort = 2222;
|
||||||
|
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
await AddHostAsync(vault, teamVaultId, "prod-db", "db.internal");
|
||||||
|
|
||||||
|
vault.SelectedHost = vault.Hosts.Single(
|
||||||
|
row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||||
|
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(teamVaultId, vault.Status);
|
||||||
|
|
||||||
|
await HideAsync(teamVaultId);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.Groups.ShouldBeEmpty("a hidden vault's groups are cards onto hosts that are not drawn");
|
||||||
|
|
||||||
|
vault.Hosts
|
||||||
|
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
|
||||||
|
.Resolved.Port.Value
|
||||||
|
.ShouldBe(2222, "hiding a vault must never change what one of its hosts dials");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The switches are the readable vaults, personal first. A vault whose grant awaits re-wrap has nothing
|
/// The switches are the readable vaults, personal first. A vault whose grant awaits re-wrap has nothing
|
||||||
/// that would decrypt, so a switch for it would do nothing at all.
|
/// that would decrypt, so a switch for it would do nothing at all.
|
||||||
|
|||||||
Reference in New Issue
Block a user