Give the application a settings area built from what really exists

This commit is contained in:
2026-08-08 14:17:19 +02:00
parent 422d5ca10e
commit c8507b44fe
42 changed files with 3810 additions and 1083 deletions
@@ -0,0 +1,407 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:contracts="using:DodoSSH.Contracts"
x:Class="DodoSSH.Client.App.Views.SettingsVaultsPage"
x:DataType="vm:MainWindowViewModel"
x:Name="Root">
<!--
v5c-2: Vaults, against Settings-Vaults.dc.html. Replaces the old full-bleed VaultsScreen, which this
settings page now covers completely — every command it had is reachable here exactly once, and
VaultsScreen.axaml is gone. Its data is DataContext.Vaults (VaultsViewModel), reached as {Binding Vaults.*}
throughout because this page's own DataContext is the shell, the same choice every other settings page
makes.
── WHAT THE DESIGN DREW AND THIS PAGE DOES NOT ─────────────────────────────────────────────────────────
"Manage devices" and "3 devices" in the sync line — no list-devices endpoint exists. The whole VAULT
DEFAULTS card (auto-lock, require-password-on-unlock, relay toggle) and the whole RECOVERY card (kit,
export) — none of the three exists; sync is always on. The magenta "Default" badge — the app does track a
"new items go to" vault (VaultViewModel.TargetVaultId), but that lives on a different view model than the
row being drawn here and cross-referencing the two per card would be more machinery than the badge is
worth; dropped rather than faked. Per-vault UNLOCKED/LOCKED chips — this app locks the keychain as a
whole, not one vault at a time, so there is no per-vault state for a chip to draw; the keychain-level fact
lives in the sync card's dot instead. The "Unlock Rocateq" modal — same reason, there is nothing per-vault
to unlock. Member avatar stacks on the card list — VaultRowViewModel carries a member *count*, not the
members themselves; only the selected vault's Members collection is actually loaded, which is why avatars
are drawn in the members panel below and nowhere else. The "Sorted by name ▾" control — decorative in the
mock; Vaults.Vaults is already ordered personal-first-then-name and there is no second order to switch to.
── THE MEMBERS PANEL ────────────────────────────────────────────────────────────────────────────────────
The design draws an 860px modal. This app's only overlay idiom below a whole-window mode is the scrim a
Border with a translucent Background and a PointerPressed handler draws — see QuickConnect.axaml, the
other place a click-away-to-close panel exists — so that is what this is, capped at MaxWidth 860 rather
than fixed to it: the settings content column is narrower than 860 at the window's minimum, and a modal
that insisted on the full width would arrange its own rows past the edge of what this page is given.
Inside it is the old screen's own right-hand column, restyled rather than reinvented: a selectable list of
members, the SET ROLE buttons, the ADD row, REMOVE, HAND OVER, SHARE KEY/WITHDRAW KEY and the KEY HOLDERS
list, on the same VaultsViewModel.SelectedMember selection the old screen used. Deleting a vault stays a
card-level action (the danger icon below), not a members-panel one, so PendingAction is drawn in only one
of the two places at a time — see the two IsVisible guards below, which key off IsMembersPanelOpen because
HAND OVER can only ever be armed with the panel open (it needs a selected member) and DELETE only with it
closed (the card's own icon is what arms it).
The sentence that must survive from the old screen: a vault cannot be deleted through the membership list
behind it — there is no such call anywhere in the server, and archiving a team while its vault still
exists is refused. That is not the same fact as "a vault cannot be deleted" — the DELETE icon below does
delete the vault itself — and both sentences are kept, each where it is true.
-->
<Panel>
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<TextBlock Classes="settingstitle" Text="Vaults" />
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30">
<TextBlock Text="{Binding Vaults.Vaults.Count}" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextFaint}" Margin="8,0"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<Button Classes="ghost" Height="40" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
<Button Classes="accent" Height="40" Content="+ NEW VAULT"
Command="{Binding Vaults.NewVaultCommand}"
IsEnabled="{Binding !Vaults.IsBusy}" />
</StackPanel>
</Grid>
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
text field, and the members panel below is a different kind of overlay: over one vault's people,
not over a form. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
IsVisible="{Binding Vaults.IsCreatingVault}">
<StackPanel Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="New vault" />
<TextBox PlaceholderText="Name" Text="{Binding Vaults.NewVaultName}" />
<TextBlock Classes="settingsrowcaption"
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Height="36" Content="CREATE"
Command="{Binding Vaults.CreateVaultCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Height="36" Content="CANCEL"
Command="{Binding Vaults.CancelNewVaultCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
IsVisible="{Binding Vaults.IsRenamingVault}">
<StackPanel Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="Rename vault" />
<TextBox PlaceholderText="Name" Text="{Binding Vaults.EditVaultName}" />
<TextBlock Classes="settingsrowcaption"
Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes — the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Height="36" Content="SAVE"
Command="{Binding Vaults.SaveVaultNameCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Height="36" Content="CANCEL"
Command="{Binding Vaults.CancelRenameVaultCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- A vault's own destructive confirmation. Only while the members panel is shut — see the remark
above for why the two never compete for this one PendingAction. Both conditions on the same
Border, not one nested inside the other: a Border visible with its content hidden would still
draw an empty danger-tinted card while the members panel's own copy of this confirmation is
showing. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}">
<Border.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Vaults.IsConfirming" />
<Binding Path="!Vaults.IsMembersPanelOpen" />
</MultiBinding>
</Border.IsVisible>
<StackPanel Spacing="8">
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="14.5" FontWeight="Bold"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="12.5"
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Height="36" Content="CONFIRM"
Command="{Binding Vaults.ConfirmActionCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Height="36" Content="CANCEL"
Command="{Binding Vaults.CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Sync status: only what is real — no device count, no "ran N minutes ago" (this session tracks no
such timestamp). The dot and the word are MainWindowViewModel.IsFullySynced/SyncLabel, the exact
fact the titlebar's own dot already tells the truth with. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20,18">
<StackPanel Orientation="Horizontal" Spacing="14">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="9" Height="9"
Margin="0,7,0,0" VerticalAlignment="Top" />
<StackPanel Spacing="6">
<TextBlock Classes="mono" Text="{Binding SyncLabel}" FontSize="13" FontWeight="Bold"
LetterSpacing="0.5" Foreground="{StaticResource Text}" />
<TextBlock Classes="settingsrowcaption" Text="End-to-end encrypted." />
</StackPanel>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="YOUR VAULTS" Margin="0,32,0,12" />
<StackPanel Spacing="10" Margin="0,0,0,40" IsVisible="{Binding Vaults.HasVaults}">
<ItemsControl ItemsSource="{Binding Vaults.Vaults}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultRowViewModel">
<Border Classes="settingscard" Padding="22,18">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="52" Height="52" CornerRadius="12"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="20"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="7" Margin="16,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="Bold"
LetterSpacing="-0.25" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Classes="chip">
<TextBlock Text="{Binding RoleLabel}" />
</Border>
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="{Binding Detail}" TextWrapping="Wrap" />
<TextBlock Classes="mono" FontSize="10.5" Foreground="{StaticResource WarnText}"
Text="{Binding State}" IsVisible="{Binding HasState}" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.OpenMembersPanelCommand}"
CommandParameter="{Binding}" IsVisible="{Binding IsShared}"
ToolTip.Tip="See and manage who is in this vault.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE7EF;" />
</Button>
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.RenameVaultRowCommand}"
CommandParameter="{Binding}" IsVisible="{Binding CanAdminister}"
ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon danger" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.DeleteVaultRowCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding !IsPersonal}"
ToolTip.Tip="Deletes this vault and withdraws everybody's key to it. It cannot reach a machine that has already synced it.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Classes="settingsrowcaption" Margin="0,20,0,40" TextWrapping="Wrap"
IsVisible="{Binding !Vaults.HasVaults}"
Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
<TextBlock Margin="0,0,0,40" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding Vaults.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding Vaults.Status}" />
</StackPanel>
</ScrollViewer>
<!-- ============ THE MEMBERS PANEL ============ -->
<Border x:Name="MembersBackdrop" Background="#9905050A"
IsVisible="{Binding Vaults.IsMembersPanelOpen}" PointerPressed="OnBackdropPressed">
<Border MaxWidth="860" MaxHeight="620" Margin="30"
HorizontalAlignment="Center" VerticalAlignment="Center"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="14">
<Grid RowDefinitions="Auto,*" Margin="26,22">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
<TextBlock Grid.Column="0" Text="{Binding Vaults.SelectedVault.Name, StringFormat='{}{0} · Members'}"
FontSize="18" FontWeight="Bold" LetterSpacing="-0.25"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1" Classes="paneicon" Width="26" Height="26"
Command="{Binding Vaults.CloseMembersPanelCommand}" ToolTip.Tip="Close">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CD;" FontSize="15" />
</Button>
</Grid>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="16">
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
IsVisible="{Binding Vaults.HasSharedMembershipWarning}"
Text="{Binding Vaults.SharedMembershipWarning}" />
<StackPanel Spacing="8" IsVisible="{Binding !Vaults.SelectedIsPersonal}">
<TextBlock Classes="settingssection" Text="MEMBERS" Margin="0" />
<ListBox ItemsSource="{Binding Vaults.Members}" SelectedItem="{Binding Vaults.SelectedMember}"
Background="Transparent" BorderThickness="0" MaxHeight="220">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
<Grid ColumnDefinitions="Auto,*,150,Auto" Margin="0,4">
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding Initials}" FontWeight="Bold" FontSize="10"
Foreground="White" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="2" Margin="10,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding Email}" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="settingsrowcaption" FontSize="10.5" Text="{Binding KeyState}"
TextWrapping="Wrap" />
<TextBlock Classes="settingsrowcaption" FontSize="10" Text="{Binding LastActive}" />
</StackPanel>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Role}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- The hand-over confirmation, only while this panel is the one that armed it. -->
<Border Padding="14" CornerRadius="10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
IsVisible="{Binding Vaults.IsConfirming}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="13.5" FontWeight="Bold"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="11.5"
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="CONFIRM" Command="{Binding Vaults.ConfirmActionCommand}"
IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding Vaults.CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Command="{Binding Vaults.ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="flat choice" Content="MEMBER" Command="{Binding Vaults.ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="flat choice" Content="ADMIN" Command="{Binding Vaults.ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
IsEnabled="{Binding !Vaults.IsBusy}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Content="HAND OVER" Command="{Binding Vaults.HandOverCommand}"
IsEnabled="{Binding !Vaults.IsBusy}" IsVisible="{Binding Vaults.OwnsSelected}"
ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
<Button Classes="danger" Content="REMOVE" Command="{Binding Vaults.RemoveMemberCommand}"
IsEnabled="{Binding !Vaults.IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only." />
</StackPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
<TextBlock Classes="settingssection" Text="INVITE" Margin="0" />
<Grid ColumnDefinitions="*,140,44">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
Text="{Binding Vaults.NewMemberEmail}" Margin="0,0,8,0" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
<Button Classes="flat choice" Content="VIEWER"
Classes.active="{Binding Vaults.AddsAsViewer}"
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
<Button Classes="flat choice" Content="MEMBER"
Classes.active="{Binding Vaults.AddsAsMember}"
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
</StackPanel>
<Button Grid.Column="2" Classes="accent" Width="44" Content="+"
Command="{Binding Vaults.AddMemberCommand}" IsEnabled="{Binding !Vaults.IsBusy}"
Margin="8,0,0,0"
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so." />
</Grid>
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Adding somebody lets the server serve them this vault. It does not let them read it — that needs a vault key, which SHARE KEY below wraps for them." />
</StackPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Vaults.SelectedIsPersonal}">
<TextBlock Classes="settingssection" Text="YOURS ALONE" Margin="0" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright. Make a vault for the things you want to share, and put them in it." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding Vaults.SelectedIsShared}">
<Button Classes="accent" Content="SHARE KEY" Command="{Binding Vaults.ShareVaultCommand}"
IsEnabled="{Binding !Vaults.IsBusy}"
ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first." />
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding Vaults.RevokeVaultCommand}"
IsEnabled="{Binding !Vaults.IsBusy}"
ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
</StackPanel>
<TextBlock Classes="settingssection" Text="KEY HOLDERS" Margin="0" />
<ListBox ItemsSource="{Binding Vaults.Grants}" Background="Transparent" BorderThickness="0"
MaxHeight="120">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultGrantRowViewModel">
<Grid ColumnDefinitions="10,*" Margin="0,3">
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding State}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
Load-bearing, from the old screen: there is no DELETE here for a reason, and the reason is
not "not built yet". The membership list behind a shared vault cannot be archived while the
vault exists — that is a different fact from the vault itself, which the card's own DELETE
icon does remove.
-->
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="A vault's membership list cannot be deleted on its own — the server refuses to archive it while the vault it carries still exists. Deleting the vault itself is the card's own icon, outside this panel." />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Border>
</Panel>
</UserControl>