Merge branch 'main' into the Android head

Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
@@ -0,0 +1,188 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.TeamsScreen"
x:DataType="vm:TeamsViewModel">
<!--
Teams.
The screen is built around one fact that every other product in this category hides: adding somebody to
a team and giving them a vault key are two different acts, and only the first is something a server can
do. The second needs a machine that holds the key, because this server never does. So the members table
and the vaults table are side by side, an addition says out loud that it granted nothing readable yet,
and SHARE KEY is its own button rather than a checkbox on the member row.
What the design asked for and is still not here: pending invitations (there is no outbound mail path and
no invitation token), two-factor state and last-active (the server records neither), and avatars (no
picture is stored anywhere). None of them is drawn with invented data.
-->
<Grid ColumnDefinitions="268,*">
<!-- ============ The team list ============ -->
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<Grid RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="ghost" Content="NEW"
Command="{Binding NewTeamCommand}" IsEnabled="{Binding !IsBusy}" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel>
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
<TextBlock Classes="hint" FontSize="10" Text="{Binding Detail}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10" Margin="14,12" TextWrapping="Wrap"
IsVisible="{Binding !HasTeams}"
Text="No teams yet. A team is what makes a vault shareable: its vaults can be opened by every member you wrap a key to." />
</StackPanel>
</ScrollViewer>
<!-- The create form, in place rather than in a modal: this window has no idiom for one. -->
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsCreatingTeam}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="NEW TEAM" />
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- ============ Members and vaults ============ -->
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<TextBlock Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</Border>
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
<StackPanel Margin="14,14" Spacing="18">
<!-- Members -->
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MEMBERS" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0" MaxHeight="240">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding Email}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="hint" FontSize="10" VerticalAlignment="Center"
Text="{Binding KeyState}" TextWrapping="Wrap" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every vault key they hold from this team. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
</Grid>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
IsVisible="{Binding CanAdministerSelected}"
Text="Adding somebody lets the server serve them this team's vaults. It does not let them read one: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Vaults -->
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
Command="{Binding CreateVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
</Grid>
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0" MaxHeight="200">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
IsVisible="{Binding !HasSelection}"
Text="Select a team to see its vaults." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Wraps the selected vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
</StackPanel>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="1" Classes="hint" FontSize="11" Margin="20" TextWrapping="Wrap"
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
Text="Create a team on the left, or wait to be added to one. A team owns vaults; a vault's key is what makes its contents readable, and that key is handed out by people rather than by the server." />
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding Status}" TextWrapping="Wrap" />
</Border>
</Grid>
</Grid>
</UserControl>