Files
DodoSSH/src/DodoSSH.Client.App/Views/KeychainScreen.axaml
T

641 lines
41 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.App.Views.KeychainScreen"
x:DataType="vm:VaultViewModel">
<!--
The keychain: the SSH keys and the stored passwords. Things a person creates and edits.
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
shape for every kind, which is what makes the ALL category possible and is why the row projection
exists; see VaultItemRowViewModel.
HOST KEYS was a fourth category here and is now a screen of its own; see KnownHostsScreen. It never fit:
the two categories left are things somebody made on purpose, and a pin is a decision recorded at the
moment of connecting — nobody goes looking for one in a list of credentials. It also has a workflow the
shared table could not serve, which is comparing an untruncated fingerprint against a published one.
Two of the design's five categories are still not here. IDENTITIES and CERTIFICATES have no item type
behind them, so listing them would be two headings that could never have anything under them. Recorded
in docs/design-import-gaps.md.
The SCOPES rail below the categories is the keychain list. Since M3 it genuinely has more than one entry
when somebody is in a team — but it is still not a selector, because every table on this screen already
spans every keychain this session holds a key for and each row names its own. What it carries instead is
the one keychain question with an answer: where a new item is filed.
── v5b — Keychain.dc.html ──────────────────────────────────────────────────────────────────────────────
A fidelity pass over the shape above, not a new one. What moved:
◆ THE HEADER. "Keychain" 33 bold, a count chip, and on the right a "Host keys" ghost button — the
design's own doorway to the pins screen, wired to the shell's existing ShowScreenCommand via
$parent[Window] since this screen's own DataContext is the vault rather than the shell — and one
"+ New key" accent button rather than the five GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET
buttons the toolbar used to spread across the table's own header. ◆ DECIDED DEVIATION: the design draws
one button because its mock has one "add" concept; this application has five, and folding five capabilities
into one visible button without losing any of them means the button opens a menu naming all five, in the
same order the old toolbar had them, rather than guessing which one the design's single button "really"
meant. GENERATE keeps its own tooltip; the strip's own essay comment about why it lost the word KEY is now
moot — the width pressure that produced it left with the buttons.
◆ THE RAIL. 200px, Sidebar-bg, KEYCHAIN tracked label, category rows restyled to Button.cat's own idiom
(unchanged binding, new width). Below the divider: SCOPES stays — this session's current vault, named,
since that fact is real and the design's mock is a single-vault sample with nothing to show it — and then
NEW ITEMS FILE TO, the design's own wording for the picker this screen already had as "NEW ITEMS GO TO".
Adopted rather than kept: nothing in this file's own essay comments ever defended "GO TO" over "FILE TO",
and the comment above the picker already says "where a new item is filed" — the design's word was this
screen's own vocabulary already.
◆ THE TABLE. A 44px sub-toolbar (the section summary, left; a 240px filter box, right — this table had no
filter box before, and the design's has one) and tracked column headers. The design's own five are
NAME/TYPE/VAULT/USED BY/MODIFIED; MODIFIED is dropped — VaultItem carries no timestamp of any kind (id,
secret, version, three sync flags — see docs/design-import-gaps.md) — and USED BY is real for a key or a
credential (which hosts authenticate with it, the same fact VaultViewModel.HostsBoundTo already computes
for the deletion warning) and empty for a tag (its own host count already covers the same ground) or a
bucket (nothing in this codebase resolves a host's authentication to an object store). Rows: a type glyph,
the mono name, the type word, and the vault chip — VaultItemRowViewModel.VaultBadge, empty except where
more than one vault is in play, the same convention every other list in this application follows.
◆ THE DETAIL PANE, 300px. PUBLIC KEY draws when the selected key has one stored. FINGERPRINT is not
drawn at all — this codebase has never computed one; see docs/design-import-gaps.md's own recorded gap,
"no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim."
USED BY draws real host rows — the same HostsBoundTo scan, with each host's own two-state dot — only for
a key or a credential, and only while at least one host actually authenticates with the selected item; the
"in use · N hosts" chip beside the vault chip is gated the same way. The action buttons keep their
existing commands and their existing honesty: COPY PUBLIC KEY only for a key, MOVE only where
CanMoveSelectedItem says there is somewhere to move to, DELETE always. "Choose something on the left…"
stays as the empty state, restyled.
-->
<Grid RowDefinitions="Auto,*" Margin="26">
<!-- ============ THE HEADER ============ -->
<Grid Grid.Row="0" Margin="0,0,0,20" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Keychain" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Border Grid.Column="1" Margin="12,0,0,0" MinWidth="34" Height="30" CornerRadius="9" Padding="8,0"
Background="{StaticResource Chip}" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding TotalItemCount}" FontSize="12.5"
Foreground="{StaticResource TextDim}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<!--
◆ "Host keys", to the pins screen. $parent[Window] is what reaches the shell from here: this
control's own DataContext is the vault, not MainWindowViewModel, and Window is the nearest ancestor
whose DataContext is the shell — see MainWindow.axaml, which sets this screen's DataContext to
{Binding Vault} rather than putting IsKeychainScreen and the vault on one element.
-->
<Button Grid.Column="3" Classes="ghost" Height="40" Margin="0,0,10,0"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE90D;"
Foreground="{StaticResource TextFaint}" />
<TextBlock Text="Host keys" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" />
</StackPanel>
</Button>
<!--
◆ "+ New key": one button standing in for the five this screen can still do. See the file-level
remark above for why a menu rather than a guess at which one the design meant.
-->
<Button Grid.Column="4" Classes="accent" Height="40" FontSize="13.5" Content="+ New key">
<Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuItem Header="Generate a key…" Command="{Binding NewGeneratedKeyCommand}"
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
<MenuItem Header="Add SSH key…" Command="{Binding NewKeyCommand}"
ToolTip.Tip="Pastes in a key you already have." />
<MenuItem Header="Add password…" Command="{Binding NewCredentialCommand}" />
<MenuItem Header="Add tag…" Command="{Binding NewTagCommand}"
ToolTip.Tip="A name to put on hosts. Usually made from a host's editor instead; this is for setting a scheme up before there is anything to put it on." />
<MenuItem Header="Add bucket…" Command="{Binding NewObjectStoreCommand}"
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<!-- ============ THE BORDERED BODY ============ -->
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
ClipToBounds="True">
<Grid ColumnDefinitions="200,*,300">
<!-- ============ Categories and scopes ============ -->
<Border Grid.Column="0" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<ScrollViewer>
<StackPanel Margin="10,16">
<TextBlock Classes="label" Text="KEYCHAIN" Margin="12,0,12,10" FontSize="10" />
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.All}"
Classes.active="{Binding ShowsAll}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="ALL" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding TotalItemCount}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Keys}"
Classes.active="{Binding ShowsKeys}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="SSH KEYS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Keys.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Credentials}"
Classes.active="{Binding ShowsCredentials}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="PASSWORDS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Credentials.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<!--
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a
bucket is something somebody creates, edits and keeps a secret for, which is what the other
two categories are. A pin is a decision recorded at connect time and is not.
-->
<!--
Tags. The odd category: it is the only one holding nothing secret — a tag is a name. It is
here because the reason a tag is an item at all is that renaming it should be one write
instead of twenty, and a rename needs somewhere to happen; so does deleting, or the host
editor's picker fills with names nobody uses and never empties.
-->
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Tags}"
Classes.active="{Binding ShowsTags}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="TAGS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Tags.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Buckets}"
Classes.active="{Binding ShowsBuckets}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding ObjectStores.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="12,12" />
<TextBlock Classes="label" Text="SCOPES" Margin="12,0,12,8" FontSize="10" />
<!--
Still not a selector. Every list on this screen now spans every vault this session holds a
key for, and each row names its own vault — so there is nothing to switch to. What the
picker below chooses is where a *new* item is filed, which is a different question and the
only one that has an answer worth asking for.
-->
<StackPanel Orientation="Horizontal" Margin="12,2" Spacing="7">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="11"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
<!--
Hidden at one vault, which is where most people stay. A control offering a single option is
a question with no answer. "NEW ITEMS FILE TO" — the design's own wording, adopted: this
screen's own comments already call the act "filing" (see above), so the design's label was
this application's own vocabulary already.
-->
<StackPanel Margin="12,14,12,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
<TextBlock Classes="label" Text="NEW ITEMS FILE TO" FontSize="10" />
<ComboBox ItemsSource="{Binding TargetVaults}"
SelectedItem="{Binding SelectedTargetVault}"
HorizontalAlignment="Stretch">
<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="An item filed into a shared vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own. A host is asked separately, in its own editor." />
</StackPanel>
<!--
Items that would not decrypt. Shown here rather than only in the status line because this is
the screen the number is about, and because a non-zero count after a rekey is the signal that
new grants are needed rather than a transient.
-->
<Border Classes="chip warn" Margin="12,14,12,0" HorizontalAlignment="Left"
IsVisible="{Binding HasUnreadableItems}">
<TextBlock Text="{Binding UnreadableSummary}" />
</Border>
</StackPanel>
</ScrollViewer>
</Border>
<!-- ============ The table ============ -->
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}">
<Border Grid.Row="0" Padding="16,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SectionSummary}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBox Grid.Column="1" x:Name="ItemFilterBox" Text="{Binding ItemFilter}" Width="240"
Height="30" CornerRadius="9" PlaceholderText="filter items"
VerticalAlignment="Center" />
</Grid>
</Border>
<!--
The design's columns are NAME / TYPE / VAULT / USED BY / MODIFIED. MODIFIED is not here — no
item this application stores carries a timestamp; see the file-level remark above and
docs/design-import-gaps.md.
-->
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
IsVisible="{Binding HasVaultItems}">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="2" x:Name="ItemList" Classes="filerows" Focusable="True"
ItemsSource="{Binding VaultItems}"
SelectedItem="{Binding SelectedVaultItem}"
Margin="8,0,8,10">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
VerticalAlignment="Center">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
</Border>
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding UsedBySummary}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasUsedBySummary}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
The empty state says which category is empty and what to do about it, rather than showing an
empty grid that reads as a list still loading.
-->
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptySectionMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="320"
IsVisible="{Binding !HasVaultItems}" />
</Grid>
<!-- ============ The detail pane, and the editors ============ -->
<Border Grid.Column="2" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="18,20" Spacing="14">
<!-- Nothing selected. -->
<TextBlock Classes="hint" FontSize="12"
Text="Choose something on the left to see what is known about it."
IsVisible="{Binding !HasSelectedVaultItem}" />
<StackPanel Spacing="14" IsVisible="{Binding HasSelectedVaultItem}">
<StackPanel Orientation="Horizontal" Spacing="12">
<Border Width="36" Height="36" CornerRadius="10" Background="{StaticResource AccentWash}"
VerticalAlignment="Center">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="17"
Text="{Binding SelectedVaultItem.IconGlyph}"
Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Name}" FontSize="13.5"
FontWeight="Bold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Detail}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="chip">
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
</Border>
<!--
"in use · N hosts" — real only for a key or a credential with at least one host actually
bound to it, off the same VaultViewModel.HostsBoundTo scan the USED BY list below reads.
-->
<Border CornerRadius="5" Background="{StaticResource LiveWash}" Padding="9,0" Height="21"
VerticalAlignment="Center" IsVisible="{Binding HasSelectedItemInUseSummary}">
<TextBlock Text="{Binding SelectedItemInUseSummary}" FontSize="11" FontWeight="Medium"
Foreground="{StaticResource Live}" VerticalAlignment="Center" />
</Border>
</StackPanel>
<!--
PUBLIC KEY, when the selected key has one stored — never the private half. FINGERPRINT is
not drawn: this codebase has never computed one, and computing it here would mean parsing
armour the type stores verbatim; see docs/design-import-gaps.md's own recorded gap.
-->
<StackPanel Spacing="6" IsVisible="{Binding SelectedItemIsKey}">
<TextBlock Classes="label" Text="PUBLIC KEY" FontSize="10" />
<Border CornerRadius="10" Background="{StaticResource Pane}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12,10"
IsVisible="{Binding SelectedKey.Key.PublicKey, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
<SelectableTextBlock Classes="mono" Text="{Binding SelectedKey.Key.PublicKey}"
FontSize="10.5" LineHeight="17"
Foreground="{StaticResource TextGhost}" TextWrapping="Wrap" />
</Border>
<TextBlock Classes="hint" FontSize="10.5"
Text="No public half is stored for this key."
IsVisible="{Binding !SelectedKey.Key.PublicKey, FallbackValue=False}" />
</StackPanel>
<!--
USED BY: real host rows, off the same scan the "in use" chip above reads, with each host's
own two-state dot — never a third colour, since nothing here pings anything.
-->
<StackPanel Spacing="6" IsVisible="{Binding HasSelectedItemUsedByHosts}">
<TextBlock Classes="label" Text="USED BY" FontSize="10" />
<StackPanel Spacing="2">
<ItemsControl ItemsSource="{Binding SelectedItemUsedByHosts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UsedByHostRowViewModel">
<StackPanel Orientation="Horizontal" Spacing="9" Height="28">
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
<!--
What the design puts here beyond what is above — who added it, when, and a TEST CONNECT
button — has nothing behind it: items carry no author or timestamp, and nothing can
exercise a credential without a host to exercise it against.
-->
<TextBlock Classes="hint" FontSize="10.5"
Text="Keychain items record no author or timestamp yet, so there is nothing more to show here." />
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding ShowsItemActions}">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
<!--
Only where there is somewhere to move to, unlike EDIT beside it, which is the same rule
the host's MOVE follows on the phone: a button that answers with "this is the only vault
you can write to" is a button that should not have been drawn. Keys and passwords only —
a tag and a bucket are read from the active vault alone, so "another vault" is not a
question they have.
-->
<Button Classes="ghost" Content="MOVE" Command="{Binding MoveSelectedItemCommand}"
IsVisible="{Binding CanMoveSelectedItem}"
ToolTip.Tip="Re-encrypts this under another vault's key, and re-aims every host and group that used it at where it has gone." />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
<!--
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 every application on the machine.
-->
<Button Classes="ghost" Content="Copy public key" HorizontalAlignment="Left"
IsVisible="{Binding SelectedItemIsKey}"
Command="{Binding CopyPublicKeyCommand}"
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
<!--
◆ MOVING THE ITEM TO ANOTHER VAULT, in the place those buttons were. See HostDrawer.axaml —
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.
-->
<StackPanel Spacing="8" IsVisible="{Binding IsMovingItem}">
<TextBlock Classes="label" Text="MOVE TO VAULT" FontSize="10" />
<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 question DELETE asks, in the place the buttons above were. Here rather than over the
screen, because this pane is where the item being deleted is described.
-->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="10" Padding="12"
IsVisible="{Binding IsConfirmingDeletion}">
<views:ConfirmDeleteCard />
</Border>
</StackPanel>
<!--
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm,
a comment, and a button.
-->
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
<TextBlock Classes="label" Text="NEW SSH KEY" FontSize="10" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="ED25519"
Classes.active="{Binding GeneratesEd25519}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}"
ToolTip.Tip="What every current OpenSSH prefers. Small, fast, and generated instantly." />
<Button Classes="flat choice" Content="RSA 4096"
Classes.active="{Binding GeneratesRsa}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}"
ToolTip.Tip="For servers too old to accept Ed25519. Larger, and a few seconds to generate." />
</StackPanel>
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,4,0,0"
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
<Button Classes="accent" Content="GENERATE" Command="{Binding GenerateKeyCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGenerateKeyCommand}" />
</StackPanel>
</StackPanel>
<!-- The key editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingKey}">
<TextBlock Classes="label" Text="SSH KEY" FontSize="10" />
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
<!--
Not a password box. The armour has to be visible to be pasted and checked — a masked
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this
actually prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
-->
<TextBox Text="{Binding KeyEditorPrivateKey}"
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
FontFamily="{StaticResource MonoFont}" FontSize="11.5" />
<TextBox Text="{Binding KeyEditorPassphrase}"
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
<TextBox Text="{Binding KeyEditorPublicKey}" PlaceholderText="public half (optional)" />
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
</StackPanel>
</StackPanel>
<!-- The password editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingCredential}">
<TextBlock Classes="label" Text="PASSWORD" FontSize="10" />
<TextBox Text="{Binding CredentialEditorLabel}" PlaceholderText="name" />
<!--
Optional, and the reason a credential is worth being its own item rather than two more
fields on a host: one account on twenty machines is described once and rotated once. Left
blank, each host supplies its own username and only the password is shared.
-->
<TextBox Text="{Binding CredentialEditorUsername}"
PlaceholderText="username (blank: use each host's own)" />
<!--
Masked, unlike the private key box, and the difference is not inconsistency. A key's armour
has to be visible to be checked for truncation after a paste; a password is short, usually
typed, and shoulder-surfing is the likelier problem.
-->
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5"
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to this in the host's own editor, under how it authenticates." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCredentialCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCredentialEditCommand}" />
</StackPanel>
</StackPanel>
<!--
The tag editor, and the whole of it is one box. What it does not have is the point: renaming
a tag touches no host, because every host wearing it names its id. That is the entire reason
a tag is an item rather than a string repeated inside twenty payloads.
-->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingTag}">
<TextBlock Classes="label" Text="TAG" FontSize="10" />
<TextBox Text="{Binding TagEditorLabel}" PlaceholderText="name">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="hint" FontSize="10.5"
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveTagCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelTagEditCommand}" />
</StackPanel>
</StackPanel>
<!-- The bucket editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
<TextBlock Classes="label" Text="BUCKET" FontSize="10" />
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
<!--
Masked, like a password and for the same reason: a secret access key is one. The access
key id beside it is an identifier and is shown, which is also why the two are separate
boxes.
-->
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
PasswordChar="•" />
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
<!--
Blank means Amazon, and then the region resolves the host. Anything else is a full URL,
which is what makes this work against a self-hosted service.
-->
<TextBox Text="{Binding BucketEditorEndpoint}"
PlaceholderText="endpoint (blank: Amazon S3)" />
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}"
Content="Address the bucket as a path" />
<!--
Said where the decision is made. Getting this wrong produces a DNS failure whose message
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a
guess.
-->
<TextBlock Classes="hint" FontSize="10.5"
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5"
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelObjectStoreEditCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Grid>
</UserControl>