Merge branch 'claude/adding-hosts-phone-docs-e472b0'
ci / build and test (push) Successful in 1m16s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 45s

This commit is contained in:
2026-08-03 14:41:18 +02:00
43 changed files with 6976 additions and 147 deletions
+177 -9
View File
@@ -1,6 +1,7 @@
<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.TeamsScreen"
x:DataType="vm:TeamsViewModel">
@@ -13,9 +14,12 @@
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.
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
this product) and avatars (no picture is stored anywhere). Invitations and last-active are here, and
both are narrower than the design drew. Nothing is sent — there is no outbound mail path and no token,
so an invitation is a standing instruction that the next account signing in with that address joins the
team, and there is consequently nothing to resend. Last-active is recorded at most once per account per
hour, so it is drawn coarsely rather than to the minute. None of it is drawn with invented data.
-->
<Grid ColumnDefinitions="268,*">
@@ -82,13 +86,71 @@
<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" />
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<!--
The team's own operations. RENAME is an admin's; the other two are the owner's alone, and
that is the line the server draws as well — an admin the owner promoted must not be able
to archive the team or take it from them.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding ShowsTeamActions}">
<Button Classes="ghost" Content="RENAME" Command="{Binding RenameTeamCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
<Button Classes="ghost" Content="HAND OVER" Command="{Binding TransferOwnershipCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
ToolTip.Tip="Hands this team to the selected member. They become the owner and you become an admin; only the new owner can hand it on again." />
<Button Classes="danger" Content="ARCHIVE" Command="{Binding ArchiveTeamCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
ToolTip.Tip="Takes the team out of every member's list. Refused while it still owns any vault, and only somebody with database access can bring it back." />
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
<StackPanel Margin="14,14" Spacing="18">
<!-- The rename form, in place, exactly as the create form on the left is. -->
<Border Padding="12" CornerRadius="4" BorderThickness="1"
BorderBrush="{StaticResource Border}" IsVisible="{Binding IsEditingTeam}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="RENAME TEAM" />
<TextBox PlaceholderText="Name" Text="{Binding EditTeamName}" />
<TextBox PlaceholderText="What this team is for (optional)"
Text="{Binding EditTeamDescription}" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="The slug does not change. It is what URLs and the server's own records use, and it is unique only among live teams — so a rename that moved it could take one an archived team is still holding." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameTeamCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
The armed confirmation, drawn where the buttons that armed it were. The vault screen's
idiom, and for the same reason: there is no modal anywhere in this window.
-->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="4" Padding="12"
IsVisible="{Binding IsConfirming}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding PendingAction.Question}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="{Binding PendingAction.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="CONFIRM" Command="{Binding ConfirmActionCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Members -->
<StackPanel Spacing="8">
@@ -98,14 +160,17 @@
Background="Transparent" BorderThickness="0" MaxHeight="240">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
<Grid ColumnDefinitions="*,168,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" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="hint" FontSize="10" Text="{Binding KeyState}"
TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5" Text="{Binding LastActive}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
@@ -114,21 +179,96 @@
</ListBox.ItemTemplate>
</ListBox>
<!--
Buttons and a command rather than a selector bound to the role, which is the choice the
key editor and the category rail already make and for the reason they record: a selector
moves its own highlight before anything can refuse, so it can end up showing a role
nobody was given. OWNER is absent because it is not a role that can be assigned —
handing the team over is its own act, with its own confirmation.
-->
<StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May pull this team's vaults and may not push. It does not withdraw a vault key they already hold." />
<Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May read and change this team's vaults." />
<Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May also manage members, create vaults and share vault keys." />
</StackPanel>
</StackPanel>
<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}" />
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Adds the account with this address, or invites the address if there is no account here yet. Nothing is sent either way — tell them yourself." />
<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>
<StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="THEY ARRIVE AS" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Classes.active="{Binding AddsAsViewer}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
<Button Classes="flat choice" Content="MEMBER" Classes.active="{Binding AddsAsMember}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
<Button Classes="flat choice" Content="ADMIN" Classes.active="{Binding AddsAsAdmin}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}" />
</StackPanel>
</StackPanel>
<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>
<!--
Invitations, drawn only when there are any. An empty INVITED heading on every team would be
a permanent reminder of a feature most teams never use.
-->
<StackPanel Spacing="8" IsVisible="{Binding HasInvitations}">
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<TextBlock Classes="label" Text="INVITED" />
<ListBox ItemsSource="{Binding Invitations}" SelectedItem="{Binding SelectedInvitation}"
Background="Transparent" BorderThickness="0" MaxHeight="160">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Email}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Classes="danger" Content="WITHDRAW INVITATION" HorizontalAlignment="Left"
Command="{Binding RevokeInvitationCommand}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanAdministerSelected}"
ToolTip.Tip="Signing in with that address will no longer put them in this team. An invitation already taken up is a membership — remove the member instead." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Vaults -->
@@ -167,6 +307,34 @@
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
</StackPanel>
<!--
Who can open the selected vault — the design's "shared with" avatars, as names and a
state. Under the vault rather than beside the member, because a grant is per vault: a
count on a member row would imply per-item sharing, which is M5 and does not exist.
Withdrawn and stale grants stay listed and say which they are, because a list that
quietly dropped them would show a departed colleague as merely absent rather than as
somebody whose key was taken away. The dot is Live and means exactly what it says: this
person can open this vault right now.
-->
<TextBlock Classes="label" Text="KEY HOLDERS" />
<ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0"
MaxHeight="150">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamGrantRowViewModel">
<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="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<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>