Let a team be joined only by somebody who is already here

An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
This commit is contained in:
2026-08-05 08:28:57 +02:00
parent 7dc3b8950d
commit 69bc9e270b
39 changed files with 2258 additions and 2275 deletions
@@ -129,8 +129,8 @@
<!--
Vaults, 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 drawn one with nothing behind it. It is on the phone because a vault arrives
without being asked for, and somebody it arrives for is at least as likely to be holding a phone.
◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and
two heads giving one destination two marks is how a user learns the wrong one.
@@ -10,14 +10,11 @@
VAULTS, 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 vault 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 vault, and the row says out loud that no mail was
sent.
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because a vault
arrives without being asked for — somebody wraps its key to you from their machine — and the person it
arrives for is at least as likely to be holding a phone as sitting at a desktop. A vault the server has
just put somebody into, visible only on a head they may never have installed, is a membership they
cannot see.
── IT LISTED TEAMS UNTIL THE SCREEN STOPPED BEING ABOUT THEM. ───────────────────────────────────────
The rows are vaults now, and the members under one are the people that vault is shared with. Nothing
@@ -39,20 +36,20 @@
◆ **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 vaults screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE, WITHDRAW
KEY and WITHDRAW INVITATION are the other half of that, and each of them acts on the first press: the
view model's armed-confirmation state covers handing a vault over and nothing else. The desktop guards
would leave the product's central claim undemonstrated on the head most people carry. REMOVE and
WITHDRAW KEY are the other half of that, and both act on the first press: the view model's
armed-confirmation state covers handing a vault over and nothing else. 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
revocation under a thumb with its explanation missing is the wrong trade, so both stay on the
desktop — where the sentence beside them is visible. Handing a vault over is not drawn either, for a
plainer reason: it decides who controls the vault, which is not a thing to do while walking. Nor is
renaming, which is a keyboard on a screen that is otherwise all reading.
**ADD 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 vault is one the server claims at
sign-in, which is what put this screen on the phone at all. Making a vault is here, because it is one
field and because it is what a person carrying a phone can usefully start.
did *not* do — five controls for the one act on this screen that a colleague at a desktop is already
doing. Making a vault is here, because it is one field and because it is what a person carrying a
phone can usefully start.
**The key-holder list is not drawn.** It is a third list, and what the phone can answer about a vault
is the more useful half of the same question and is on the vault's own row: whether *this* machine can
@@ -61,7 +58,7 @@
**↻ and `+` both, because this screen has more reason to re-read than any other.** Who is in a vault is
not cached — it is 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 vault they have this moment been invited into. On the desktop the
from a colleague's desktop, or a vault they have this moment been added to. 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(Vaults),
which would set Screen to the value it already holds, raise nothing and reload nothing.
@@ -246,53 +243,6 @@
<TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a vault is what lets the server hand somebody its rows. 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
vault 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:VaultInvitationRowViewModel">
<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>
</StackPanel>
</StackPanel>