Public Access
Let a team change hands, and be joined by somebody with no account yet
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted rather than removed — removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,13 @@
|
||||
<!--
|
||||
Design v2 — MORE: the hub for everything the bottom bar has no room for.
|
||||
|
||||
Four slots and nine destinations is the arithmetic the design solves by putting five of them one tap
|
||||
Four slots and ten destinations is the arithmetic the design solves by putting six of them one tap
|
||||
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
|
||||
every row on it is a navigation command and nothing here reads an item.
|
||||
|
||||
Teams is the tenth and the design never drew it — see the row itself. The count is the design's plus
|
||||
one rather than a rearrangement of it: nothing moved out of the bottom bar to make room.
|
||||
|
||||
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
|
||||
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
|
||||
entries are one word each is a menu you have to open to read.
|
||||
@@ -89,6 +92,29 @@
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Teams, which the v2 design has no row for — it is a shipped screen the design had no slot for
|
||||
rather than a drawn one with nothing behind it. It is on the phone because an invitation is
|
||||
claimed by signing in, and somebody being invited is at least as likely to be holding a phone.
|
||||
|
||||
◎ rather than a glyph of its own. The desktop rail already draws teams with it, and two heads
|
||||
giving one destination two marks is how a user learns the wrong one.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Team}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Teams" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Who shares a keychain with you, and who holds its key." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
state machine. What differs is only what each one draws.
|
||||
|
||||
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper
|
||||
behind MORE: snippets, SFTP, S3, logs and preferences. That is the v2 design's own arrangement, and it
|
||||
replaces the first design's four, which had nothing behind them at all. Five characters was a desktop
|
||||
constraint and the phone uses words.
|
||||
The desktop's eight rail destinations become four in a bottom bar, and the rest live one tap deeper
|
||||
behind MORE: snippets, SFTP, S3, logs, preferences — and teams, which v2 did not draw and which is
|
||||
argued for on the screen itself. That is the v2 design's own arrangement, and it replaces the first
|
||||
design's four, which had nothing behind them at all. Five characters was a desktop constraint and the
|
||||
phone uses words.
|
||||
|
||||
The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
|
||||
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
|
||||
@@ -43,7 +44,7 @@
|
||||
|
||||
Hidden behind MORE, and that is the design's arrangement rather than a saving. v2 gives every screen
|
||||
one header carrying that screen's own name and its own actions — a back arrow, an add, a refresh —
|
||||
so the five hub screens draw their own and this one stands down rather than stacking a second row of
|
||||
so the hub's screens draw their own and this one stands down rather than stacking a second row of
|
||||
chrome above theirs. It stays on the three destinations that are the product's top level, where the
|
||||
vault's name and the sync light are the most useful thing a header could say.
|
||||
-->
|
||||
@@ -111,7 +112,7 @@
|
||||
============ under MORE ============
|
||||
|
||||
The hub itself takes the shell as its data context, because every row on it is a navigation
|
||||
command; the five destinations behind it each take the view model they are about, so each one is
|
||||
command; the destinations behind it each take the view model they are about, so each one is
|
||||
wrapped. SnippetsScreen and LogsScreen are nullable on the shell — they are rebuilt on every
|
||||
unlock and nulled on lock — and it is the collapsed wrapper that keeps a template from binding
|
||||
against nothing.
|
||||
@@ -126,6 +127,15 @@
|
||||
<views:LogsScreen DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
The sixth destination behind MORE, and the one v2 never drew — see the comment on the screen
|
||||
itself. Wrapped like its neighbours even though Teams is not nullable: the reason for the wrapper
|
||||
is the data context, not the null. IsTeamShowing is the shell's and Teams is not the shell.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTeamShowing}">
|
||||
<views:TeamsScreen DataContext="{Binding Teams}" />
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
One screen for both file destinations. SFTP and S3 differ in which picker they offer and in
|
||||
nothing else below it — the panes, the queue and the transfers are the same IRemoteFileStore
|
||||
@@ -237,8 +247,9 @@
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
|
||||
|
||||
<!--
|
||||
IsMoreSurface rather than IsMoreShowing: this tab stands for six screens, and a bar that went
|
||||
dark the moment you opened one of them would only ever light three of its four entries.
|
||||
IsMoreSurface rather than IsMoreShowing: this tab stands for the hub and everything behind it,
|
||||
and a bar that went dark the moment you opened one of them would only ever light three of its
|
||||
four entries.
|
||||
-->
|
||||
<views:NavButton Grid.Column="3" Label="More" Glyph="≣" IsCurrent="{Binding IsMoreSurface}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
|
||||
@@ -151,7 +151,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// v2 is the first arrangement here with a second level: five destinations sit behind MORE, each with
|
||||
/// v2 is the first arrangement here with a second level: six destinations sit behind MORE, each with
|
||||
/// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first,
|
||||
/// and left unhandled it does not go up — it finishes the activity. Ending the application from a log
|
||||
/// screen is not a plausible reading of "back".
|
||||
@@ -208,7 +208,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
switch (current.Screen)
|
||||
{
|
||||
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
|
||||
or ShellScreen.Buckets or ShellScreen.Preferences:
|
||||
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Team:
|
||||
current.ShowScreenCommand.Execute(ShellScreen.More);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<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.Android.Views"
|
||||
x:Class="DodoSSH.Client.Android.Views.TeamsScreen"
|
||||
x:DataType="vm:TeamsViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
TEAMS, under MORE — and the one screen behind that hub the v2 phone design never drew.
|
||||
|
||||
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
|
||||
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because an
|
||||
invitation is claimed by *signing in*, and the person being invited is at least as likely to be
|
||||
holding a phone as sitting at a desktop — a team the server has just put somebody into, visible only
|
||||
on a head they may never have installed, is a membership they cannot see.
|
||||
|
||||
That argument is also why the invited list is drawn here and not treated as an administrator's detail:
|
||||
the people on it are the ones who cannot yet see the team, and the row says out loud that no mail was
|
||||
sent.
|
||||
|
||||
So there is no mock-up to depart from. What this departs from instead is the desktop screen over the
|
||||
same view model, and every difference below is a phone difference rather than a second opinion.
|
||||
|
||||
**The desktop's two columns are one.** A 268-pixel team list beside a members-and-vaults table does
|
||||
not exist at 360dp, so the three lists stack in one scrolling column with the teams at the top. That
|
||||
is the same thing HOSTS does with the desktop's sidebar and its connect column, and for the same
|
||||
reason.
|
||||
|
||||
**Nothing scrolls inside anything.** The desktop caps its members and vaults lists at 240 and 200
|
||||
pixels so the two can sit above each other in one pane. Here every list is sized to its content and
|
||||
the screen's own ScrollViewer does all of the scrolling: a list that scrolls inside a page is a region
|
||||
a thumb has to find the edges of, and three of them on one screen is three ways to get stuck.
|
||||
|
||||
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
|
||||
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
|
||||
machine that already holds the key, and this phone is one — so a teams screen that could only be read
|
||||
would leave the product's central claim undemonstrated on the head most people carry. REMOVE MEMBER,
|
||||
WITHDRAW KEY and REVOKE INVITATION are the other half of that, and each of them acts on the first
|
||||
press: the view model's armed-confirmation state covers archiving a team and handing one over, and
|
||||
those three are not armed by it. The desktop guards them with a tooltip instead, which is a control a
|
||||
touch screen has no way to show. An irreversible revocation under a thumb with its explanation missing
|
||||
is the wrong trade, so all three stay on the desktop — where the sentence beside them is visible.
|
||||
Archiving and hand-over are not drawn either, for a plainer reason: they decide whether a team goes on
|
||||
existing and who controls it, which is not a thing to do while walking.
|
||||
|
||||
**ADD MEMBER is not drawn either**, and it is the operation this screen least needs. It is an address
|
||||
typed into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding
|
||||
somebody did *not* do — and since invitations arrived the ordinary way into a team is one the server
|
||||
claims at sign-in, which is what put this screen on the phone at all. Creating a team is here, because
|
||||
a team is where those invitations are sent from and it is two short fields.
|
||||
|
||||
**The key-holder list under a vault is not drawn.** It is a fourth list, it belongs to the selected
|
||||
vault rather than to the team, and the view model publishes no flag saying whether it has anything in
|
||||
it — so a heading for it would sit over nothing whenever nobody holds a key, which is exactly the
|
||||
empty state this head insists comes from the view model rather than from markup. What the phone can
|
||||
answer about a vault is on the vault's own row: whether *this* machine can open it.
|
||||
|
||||
**↻ and `+` both, because this screen has more reason to re-read than any other.** Nothing here is
|
||||
cached — it is all read from the server on arrival and again at the end of every command — so the one
|
||||
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
|
||||
from a colleague's desktop, or a team they have this moment been invited into. On the desktop the
|
||||
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
|
||||
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Team),
|
||||
which would set Screen to the value it already holds, raise nothing and reload nothing.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0">
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="Teams" Margin="4,0" />
|
||||
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="3" Classes="icon accent" Content="+" Command="{Binding NewTeamCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ a new team ============ -->
|
||||
<!--
|
||||
Above the list rather than in place of it, which is the opposite of what the host and snippet
|
||||
editors do — and the difference is what the form is about. Those two edit a row that is on screen,
|
||||
so a card stacked over the list hides the thing being changed. This one is about a team that does
|
||||
not exist yet, and the teams that do are exactly the useful thing to be able to see while naming it:
|
||||
the slug has to be unique on this server, and the near misses are right underneath.
|
||||
-->
|
||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding IsCreatingTeam}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
||||
|
||||
<TextBox Classes="field" Text="{Binding NewTeamName}" PlaceholderText="name" />
|
||||
<TextBox Classes="field" Text="{Binding NewTeamSlug}" PlaceholderText="slug-for-urls" />
|
||||
|
||||
<TextBlock Classes="body"
|
||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server. It is fixed once the team exists — a team can be renamed and its slug cannot." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
|
||||
Command="{Binding CreateTeamCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelNewTeamCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Status, and it is the empty state as well: the view model writes "you are not in a team yet" into
|
||||
the same property it writes an offline notice and every command's outcome into. A literal here would
|
||||
be a second voice saying the same thing slightly differently.
|
||||
-->
|
||||
<TextBlock Grid.Row="2" Classes="detail" Margin="18,2,18,6" TextWrapping="Wrap"
|
||||
Text="{Binding Status}"
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- ============ the column ============ -->
|
||||
<ScrollViewer Grid.Row="3">
|
||||
<StackPanel Margin="0,0,0,18">
|
||||
|
||||
<TextBlock Classes="section" Text="TEAMS" Margin="18,4,18,4" />
|
||||
|
||||
<!--
|
||||
Rows as cards, filled when chosen, which is what HOSTS settled on in v2 and what the radius
|
||||
ladder calls a card: one item, one rule, one thing you act on. The fill is on the item rather
|
||||
than on a Border inside it so the rounding the theme draws for selection is the row's own.
|
||||
-->
|
||||
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
|
||||
IsVisible="{Binding HasTeams}" Background="Transparent" BorderThickness="0">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
|
||||
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Detail}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- The caller's own role in this team, which is what says why some of it is read-only. -->
|
||||
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
|
||||
<TextBlock Text="{Binding Role}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- ============ the chosen team ============ -->
|
||||
<StackPanel IsVisible="{Binding HasSelection}">
|
||||
|
||||
<TextBlock Classes="section" Text="MEMBERS" Margin="18,18,18,4" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
|
||||
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
◆ The one fact on this row that decides whether the button at the foot of the screen
|
||||
can do anything: an account with no published identity key has nothing for a vault
|
||||
key to be wrapped to. One sentence, from the view model, painted twice rather than
|
||||
written twice — the warning colour is the whole of the difference, and a converter
|
||||
for it would hide that the two are the same string.
|
||||
|
||||
The published case is quiet rather than green. Green on this head means a shell is
|
||||
open right now, and a published key is a durable fact about an account — borrowing
|
||||
the status colour for it would be the second meaning that makes the first
|
||||
unreadable. Only the missing key is coloured, because only it needs answering.
|
||||
-->
|
||||
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextDim}" Text="{Binding KeyState}"
|
||||
IsVisible="{Binding Member.IsEnrolled}" />
|
||||
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource WarnText}" Text="{Binding KeyState}"
|
||||
IsVisible="{Binding !Member.IsEnrolled}" />
|
||||
|
||||
<!--
|
||||
A date to the day, or that they have never been here at all. The view model writes
|
||||
both, and neither is a guess: the server records the account's last authenticated
|
||||
request at most once an hour, which is what makes a day the honest unit.
|
||||
-->
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding LastActive}" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
|
||||
<TextBlock Text="{Binding Role}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="body" Margin="18,10,18,0"
|
||||
Text="Being in a team is what lets the server hand somebody this team's vaults. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
|
||||
|
||||
<!-- ============ ◆ who has been asked and has not arrived ============ -->
|
||||
<!--
|
||||
The section this screen exists for, and the one the desktop had nothing to draw until
|
||||
invitations were built. Read-only here: withdrawing one is a control that acts on the first
|
||||
press, which is the line drawn at the top of this file.
|
||||
|
||||
So these are cards rather than the flat rows above them, and the shape is the difference: a row
|
||||
that fills when you touch it is one of several you are choosing between, and there is nothing
|
||||
to choose here. An ItemsControl rather than a ListBox for the same reason — a list with a
|
||||
selection nothing reads would be a control offering something it cannot do.
|
||||
|
||||
Gated on the view model's own count rather than left to stand over an empty list, because a
|
||||
team with nobody outstanding is the ordinary case and a permanent empty heading would make it
|
||||
look like a section that had failed to load.
|
||||
|
||||
The waiting row carries the whole mechanism in its own sentence — no mail was sent, and they
|
||||
join when they first sign in here. That is the sentence somebody has to read, because every
|
||||
other product's version of this word means an email is on its way.
|
||||
-->
|
||||
<StackPanel IsVisible="{Binding HasInvitations}">
|
||||
<TextBlock Classes="section" Text="INVITED" Margin="18,18,18,4" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Invitations}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel">
|
||||
<Border Classes="card" Margin="12,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Email}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextDim}" Text="{Binding State}"
|
||||
IsVisible="{Binding !IsPending}" />
|
||||
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource WarnText}" Text="{Binding State}"
|
||||
IsVisible="{Binding IsPending}" />
|
||||
</StackPanel>
|
||||
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
|
||||
<TextBlock Text="{Binding Role}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="section" Text="VAULTS" Margin="18,18,18,4" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
|
||||
<StackPanel Spacing="3" MinHeight="54" Margin="14,11" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
Whether *this* phone can open it, which is a property of its keyring rather than
|
||||
anything the server could answer. Painted the same two ways as the member's key
|
||||
state above, because it is the same question asked from the other end.
|
||||
-->
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextDim}" Text="{Binding State}"
|
||||
IsVisible="{Binding IsReadable}" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource WarnText}" Text="{Binding State}"
|
||||
IsVisible="{Binding !IsReadable}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="body" Margin="18,10,18,0"
|
||||
Text="A vault listed here that this phone has no key to stays listed and stays shut. That is the ordinary case rather than a fault: somebody has been added to the team and nobody has wrapped the key to them yet." />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ ◆ giving somebody the key ============ -->
|
||||
<!--
|
||||
Raised over the column when both halves of the act have been chosen, as HOSTS raises its connect bar
|
||||
and SNIPPETS its insert bar, and for the reason written there: there is no second column to put it
|
||||
in, so it names what it will do rather than relying on a selection being visible beside the button.
|
||||
|
||||
Two wrappers rather than one condition. Sharing needs a member *and* a vault, and a binding cannot
|
||||
say `SelectedMember is not null && SelectedVault is not null` without a converter that does not
|
||||
exist — the log screen makes the same trade for the same reason. It also gets the halves in the
|
||||
right order: choosing who comes first, and until a vault is chosen there is nothing to offer them.
|
||||
-->
|
||||
<Panel Grid.Row="4" IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<Border IsVisible="{Binding SelectedVault, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Padding="14,12">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="THE KEY TO" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedVault.Name}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="FOR" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedMember.Name}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Classes="primary" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<!--
|
||||
The sentence the desktop hangs off a tooltip, which a phone cannot show — so it is body text
|
||||
under the button, where it is read before the tap rather than after it. It is not decoration:
|
||||
the key-log check proves this server has been consistent with itself and nothing more.
|
||||
-->
|
||||
<TextBlock Classes="body"
|
||||
Text="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. That proves the 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>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Teams, under MORE — who is in one, and which of its vaults this phone can open.</summary>
|
||||
internal sealed partial class TeamsScreen : UserControl
|
||||
{
|
||||
public TeamsScreen() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
Reference in New Issue
Block a user