Files
2026-09-12 17:31:39 +02:00

16 KiB
Raw Permalink Blame History

Auth flow refresh: guided invitation and sign-in

Specification: docs/plans/auth-flow-refresh.md. Drafts: docs/plans/mockups/auth-flow.html. Predecessor: ui-refresh.md, whose tokens, cards, buttons and sheets this package uses. The earlier state is described in 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 <address> 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=<token>&email=<address> 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.