# Auth flow refresh: guided invitation and sign-in
Specification: [docs/plans/auth-flow-refresh.md](../plans/auth-flow-refresh.md). Drafts: [docs/plans/mockups/auth-flow.html](../plans/mockups/auth-flow.html). Predecessor: [ui-refresh.md](ui-refresh.md), whose tokens, cards, buttons and sheets this package uses. The earlier state is described in [invitation-login-ux.md](invitation-login-ux.md).
Token formats, lifetimes, rate limits, cookie handling, CSRF and session invalidation are unchanged. The only new stored datum is the invitation recipient the administrator typed in themselves.
## Test contract before the change
The suites bind to the following selectors, literals and attributes. Every item is either preserved or has a documented successor listed beside it.
### Login page (`LoginController.page`)
| Bound by | Selector or literal | Outcome |
|---|---|---|
| `BrowserSteps.login`, `inviteAccount` | `form[action='/login/start']` | preserved |
| `BrowserSteps.login`, `inviteAccount` | `input[name='email']` inside that form | preserved (address view and setup view) |
| `BrowserSteps.login` | `summary` inside that form, `select[name='kind']`, `input[name='credential']` | successor: `/login?setup` renders the setup view with `input[name='credential']`; the disclosure and the `kind` select are gone from the interface |
| `BrowserSteps.login`, `inviteAccount` | the single `button` inside each form | preserved; the steps scope by section (`#request-step form`, `#setup-step form`, `#code-step form[action='/login/code']`) because four forms now post to the two endpoints |
| `BrowserSteps.login` | redirect URL `/login?sent` | preserved |
| `BrowserSteps.login` | `form[action='/login/code']` with `input[name='email']` and `input[name='code']` | preserved; the address is a hidden input when the session knows it |
| `BrowserSteps.inviteAccount` | `#access-options` hidden | successor: `#invitation-step` visible, asserted instead |
| `LoginControllerTest` | `id="request-step"` / `id="code-step"` with `hidden` | preserved; `id="invitation-step"`, `id="setup-step"`, `id="link-login"` join them |
| `LoginControllerTest` | `method="post"`, `name="_csrf"`, escaped token, no reflected `credential` | preserved |
| `LoginControllerTest` | literal `Anmeldecode anfordern` | successor: `Code senden` |
| `EmailLoginHttpTest` | `Continue as guest`, `class="brand" href="/"`, `class="site-footer"`, `/en/privacy-policy/` | preserved |
| `AuthenticatedHttpTest` | `name="_csrf"` on `GET /login` | preserved |
| `login-invitation.js` | `#link-login` with `input[name=token]`, fragment forms `#token=`, `#invitation=…&email=…` | preserved |
### Admin page (`AdminView`)
| Bound by | Selector or literal | Outcome |
|---|---|---|
| `BrowserSteps.inviteAccount` | page button `Create invitation` | successor: `Invite` |
| `BrowserSteps.inviteAccount` | `role=dialog` | preserved |
| `BrowserSteps.inviteAccount` | textbox `Email address` in the dialog | preserved |
| `BrowserSteps.inviteAccount` | `vaadin-select` labelled `Email language` with `Deutsch` / `English` | successor: two pills `Deutsch` / `English` with `aria-pressed` |
| `BrowserSteps.inviteAccount` | dialog button `Create invitation` | successor: `Send invitation` (creating and sending are one action) |
| `BrowserSteps.inviteAccount` | textbox `Invitation link`, button `Copy link`, text `Link copied.` | preserved; the link field is inside the `Show link` disclosure of step 2 |
| `BrowserSteps.inviteAccount` | button `Send email`, text `Invitation submitted for delivery.` | successor: step 2 states `The email to
has been submitted for delivery.`; `Send again` only appears on the failure branch |
### Service level
`IdentitySteps` uses `admission.invite()` without arguments (WJ-010, WJ-011, WJ-016, WJ-062) — the new `recipient` parameter therefore keeps a default. `AdmissionServiceTest` and `InvitationLanguageMigrationTest` are extended rather than replaced.
## What changed
### Sign-in has four entry points instead of one form
`LoginController.page` renders five sections — `request-step`, `invitation-step`, `code-step`, `setup-step` and `link-step` — and makes exactly one of them visible. Without JavaScript the server picks it from the request: no parameter is the everyday form, `?sent` and `?step=code` are the code step, `?setup` is setup and recovery. The script reveals the invitation and link views, and only from the fragment.
The everyday form is one address field and one button. The access kind and the credential field, which used to sit in a disclosure below it, are gone from that path: `kind` is now a hidden `login` on the everyday form, `invitation` on the invitation view and `bootstrap` on `/login?setup`. The controller still accepts all three values, so nothing about admission changed.
The code step asks for the address only when the session does not know it — on another device. Otherwise the address is a hidden field and the page states it in a sentence with a quiet "Change address" link. The code is one field: 24 px monospace, `one-time-code`, `autocapitalize="characters"`, a `XXXXX-XXXXX` placeholder, and dashes and spaces allowed because the server normalises them. Ten fields would have broken pasting and password managers. The button enables once ten characters of the code alphabet are present; there is no automatic submit, and without JavaScript the button is simply enabled from the start.
Resending is a real request to `/login/start`, posting `kind=login` with the session address. That is the value that makes the controller keep the pending invitation or setup context instead of overwriting it with an empty credential, and it counts against the same limits as any other request. The button locks for 60 seconds with a countdown taken from session storage; the server limits stay the real boundary. With no session address the page offers a link back to `/login` instead.
### The invitation announces itself
Opening `/login#invitation=&email=` shows a view of its own: a pill, "You are invited", one sentence about what Werkjournal is, the address as a read-only line with a quiet "Use another address", and one button. The script still strips the fragment from history before anything else happens. A malformed token shows the everyday form with one added line about an incomplete link; whether a well-formed token is still valid is never judged in the browser.
### Errors say what happened and what to do
`?denied` renders a card in `--wj-crit-soft` naming the cause and the next action. On the code step it speaks about the code, on the request view about the sign-in link. The invitation postscript — "If your invitation is older than seven days, ask for a new one." — is added whenever the session carries an invitation context, never depending on a check result, so it cannot report whether a token exists.
### Inviting is one action with a visible outcome
`AdminView` is a list page: accounts and invitations as rows of the same shape, with state and language as pills and every timestamp formatted in the device timezone through `currentZone()`. An invitation row names its recipient, or reads "no address" for rows created before this package. Open invitations offer "Invite again", which revokes the current link before opening a prefilled sheet, and "Revoke".
`InvitationSheet` is a separate class with two steps. Step 1 asks for the address and the email language as two pills. Step 2 reports what happened to the email: on success a check mark and the recipient, with the link folded away under "Show link"; on a refused submission an amber card, the link already unfolded, and "Send again" as the primary action — the same invitation stays valid. The administrator's rights are rechecked through `admission.accounts()` before the mail capability is used, and the confirmation still promises submission, not delivery.
### The recipient is stored for display only
`account_invitation` gained a nullable `recipient VARCHAR(254)` in an additive changeset. It is written when the administrator creates the invitation through the page, shown in the list, and set to `NULL` again when the invitation is consumed, so no address is kept longer than it is useful. It never binds the invitation: whoever holds the unused link can still register, exactly as before. `AdmissionService.invite(language, recipient)` keeps both parameters optional, so the service scenarios that invite without a recipient are unchanged.
### Emails follow the new steps
The invitation email names Werkjournal in half a sentence, then the link, then what to do on the page ("Tap \"Send code\" and enter the code from the next email"). The code email puts the code on an indented line of its own, says where to type it, and offers the link below as the alternative. Deadlines, single use and the no-reply line are untouched, and both languages changed together.
### A new account is greeted once
Today shows a card with an accent border while the account has neither a work entry nor any habit activity: what happened, and the two next steps as buttons. Dismissing it writes one key to `localStorage`; when storage is unavailable the card simply stays. The check is two `EXISTS` queries — `WorkService.untouched` and `HabitService.untouched` — and needs no new table.
## Two things the suites taught us
`EmailLoginChallenges.issue` refuses to issue a second proof within 60 seconds of the previous one, and `EmailAccounts.request` swallows that refusal to keep the public answer identical. The page's "Send the code again" countdown is therefore not a decoration on top of the rate limits but the same minute the server already enforces; WJ-065 asserts both halves — nothing is sent inside the minute, a fresh proof is issued after it.
The code step's submit button is named in the page language, so the acceptance steps address it by its form rather than its caption. That surfaced while adapting WJ-038, whose browser is deliberately German.
The administrator page reads timestamps in the device timezone, which arrives after the first render. Rebuilding the page at that moment replaced the button an administrator may already be clicking, and a browser scenario caught exactly that. The page now waits for the zone before it renders its lists, the way Today waits for the date.
## Security invariants
| # | Invariant | Where it is kept |
|---|---|---|
| 1 | One public answer for every address | `LoginController.start` still redirects to `/login?sent` for every kind, address and outcome, including a refused mail submission; `LoginControllerTest.start accepts only the three known kinds…` |
| 2 | Tokens live in the fragment and leave history at once | `login-invitation.js` calls `history.replaceState` before reading the token; WJ-063 asserts the URL is plain `/login` afterwards; `login-invitation.test.cjs` covers both fragment forms |
| 3 | Invitation: single use, seven days, revocable, not bound to a recipient | `AdmissionService.invite` unchanged apart from two display columns; WJ-010, WJ-011, WJ-064; `AdmissionServiceTest.the invitation recipient is shown…` |
| 4 | Limits and session context unchanged; a resend is an ordinary request | the resend posts `kind=login`, which reaches the same `EmailRequestLimits.allow`; WJ-065 |
| 5 | Successful sign-in invalidates the session and issues the device cookie | `LoginController.finish` untouched; `EmailLoginHttpTest` |
| 6 | Setup and recovery still work with `kind=bootstrap` and a credential | `/login?setup`; every browser scenario bootstraps its administrator through that view; WJ-017 |
| 7 | The administrator sees no foreign tracking data | `AdminView` reads `accounts()` and `invitations()` only; the recipient is the address the administrator typed; WJ-012 |
| 8 | CSRF in every form, no GET with side effects | each of the five sections renders its own CSRF input; `LoginControllerTest`, `AuthenticatedHttpTest`, `EmailLoginHttpTest` |
## Verification on 2026-09-12
Complete local pipeline on 0.1.111-SNAPSHOT with `JAVA_HOME=$HOME/opt/jdk25`:
| Run | Result |
|---|---|
| `./gradlew test -Pproduction=true` | BUILD SUCCESSFUL, 257 tests, 46 skipped, 0 failures |
| Chromium browser suite inside that run | 22 scenarios, 102 steps, every step passed |
| `./gradlew firefoxAcceptance -Pproduction=true` | 22 scenarios, 146 steps, every step passed |
| `./gradlew productionAcceptance -Pproduction=true` | 7 scenarios, 32 steps passed |
| `./tools/test-frontend` | 20 tests passed |
| `InvitationRecipientMigrationTest` | a populated predecessor database upgrades, runs twice and keeps every row |
Manual chain against `build/libs/werkjournal.jar` at 390 × 844 in Chromium device mode, with `werkjournal.mail.sendmail` pointing at a recording stub: setup entry → administrator account → invite `mia@example.test` → step 2 reports the submission → the recorded invitation email → the link opened as a fresh document → "You are invited" with the address filled in and the URL already back to plain `/login` → one button → code step → the recorded code, pasted lowercase with its dash → Today with the welcome card. Dismissing the card stores one key and the card stays away across navigation. The administrator list then shows `mia@example.test` as an account and the used invitation as "Used" without an address.
Also exercised by hand: a refused transport (the stub exits 1) produces the identical code step, so nothing distinguishes it from a successful send; a wrong code produces the error card with the field cleared and the address kept; "Invite again" revokes the open invitation and reopens step 1 prefilled; the delivery-failure branch of the sheet shows the amber card with the link already unfolded and "Send again" as the primary action. Checked at 390 × 844 and 1280 × 900 in both themes.
Three defects surfaced there and were fixed: the welcome card and the account lists added their padding outside a `width: 100%`, overflowing the viewport; the dismissal queued its script on the very element it was hiding, so the choice was never stored; and the invitation email named a button caption that does not exist.
No real email was sent at any point. Every address used is under `example.test`, and the manual chain replaced the transport with a script that writes the message to a file and never contacts a mail system. The browser suites use the recording `BrowserMail` bean.
CI: the branch job `werkator/branch` went green on `ed41475`. Its run on `a2619b2` failed once on a single Firefox scenario, and not on anything this package changed: signing in after a sign-out typed into the `/login` document the sign-out had left mid-navigation, so the submission was discarded and the page stayed put. The helper now loads `/login` as its own document and waits for the redirect's load state, which is what a person does anyway. That is a test-side race, not application behaviour — the same step passed in three local Firefox runs before it appeared. The administrator page's own double render was a real one and is fixed above. The branch job went green again on `a792886`. After the merge the trunk job went green at `d75a117` and deployed: `{"result": "PASS", "commit": "d75a117c2e92…", "url": "https://werkjournal.javagil.de"}`. The live login page reported v0.1.111-SNAPSHOT at that deployment and serves all five sections.
## Deviations from the specification
- "No changed habit configuration" is read as "no habit has been counted and no habit mutation was recorded". The seeded default habits are configured by `AccountDefaults` itself, so comparing the configuration against the defaults would have meant restating them in a second place.
- The everyday form keeps the English "Continue as guest" while German follows the draft with "Als Gast ansehen": the English literal is part of the sign-in contract several tests read.
- The invitation email quotes the button the recipient will actually see ("Send a code to this address"), not the shorter caption the specification wrote out.
- The invitation view's "Use another address" is a button next to the read-only field rather than an inline affordance inside it, so it has an accessible name and reaches keyboard users.