diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc38df..38d4b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ at boot. Entries start at 0.3.0. ## Unreleased +### The admin's recovery-code notice links to the recovery flow the code belongs to + +After an admin generates a recovery code for a user, the notice asks them to send the user both the +code and its link. The link opens the recovery flow the code belongs to, where the user enters it. +Before, the notice linked a new recovery flow at its email step, which doesn't accept the code. + ### A verification or recovery code pasted with whitespace around it is accepted The code field on the verification and recovery pages takes a code with spaces or tabs around it. diff --git a/docs/comprehension-history.md b/docs/comprehension-history.md index 93a263a..9e4e47f 100644 --- a/docs/comprehension-history.md +++ b/docs/comprehension-history.md @@ -106,3 +106,119 @@ Inherited architect, decided by: - Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: the predicate's contract is readable on its own line - Self-sufficiency: `e2e-tests/Dockerfile:11` `COPY tsconfig.json`: the relative `extends` path works in the checkout and in the image, and a comment names the link - Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the registry matches its description, with no dead ids + +## 2026-10-31T12:28:23Z, PR #200 at 3592a30, against a8a9744 + +Ruling: same + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Mid A | better | better | better | same | better | +| Inherited architect | worse | worse | better | same | same | +| Maintainability senior | same | same | better | same | same | +| Junior A | better | better | better | better | better | + +Mid A, decided by: + +- Navigation, Locality, Shape: `src/ui/chrome.ts:74` `shellUser`: the deleted `buildShellContext` and `buildDashboardModel` left two shell models side by side, and now every page reads one `chrome` +- Locality, Shape: `src/auth/routes.ts:58` `flowFailure`: a named unit shared by `flowPage` and `codeFlowSubmit`, where the old `flowPage` held the whole error ladder inline +- Shape: `src/auth/kratos-public.ts:117` `submitFlow`: takes a flow type and id instead of an action URL, and the Referer quirk is explained where it applies +- Locality: `src/http/static.ts:93` `versioned`: reads the module-level `hashes` map, so an asset href depends on what an earlier request hashed +- Navigation, Shape: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a 249-line regex walker that only tests use sits in `src/ui/` beside runtime modules, though the README now points straight at it + +Inherited architect, decided by: + +- Navigation: `public/css/styles.css:25` `:root`: the old comment next to the palette answered the 3am question and the new text drops it +- Navigation: `src/ui/stylesheet-floor.ts:1` `floorViolations`: its header never says an unsupported value in a custom property blanks every `var()` reading it, so a cold reader walks the regex walker to find the cause +- Navigation, Shape: `src/ui/stylesheet-floor.ts:12` `import`: test-only tooling that pulls a devDependency sits beside runtime modules and is missing from the README file map +- Locality: `src/http/static.ts:64` `hashes`: a module-level map shared by every `publicAssets()` instance makes a fresh href depend on request history elsewhere +- Locality: `src/http/app.ts:188` `renderPage`: `assetHref` is appended last at three render sites, and only a comment keeps a fourth from emitting unversioned hrefs +- Shape: `src/ui/chrome.ts:74` `shellUser`: removes the second shell model that duplicated `PageChrome` +- Shape: `src/auth/routes.ts:58` `flowFailure`: one named unit shared by `flowPage` and `codeFlowSubmit`, each handler reads in a screen + +Maintainability senior, decided by: + +- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: the deleted `ShellModel` left two shapes for the same chrome, and now the shell has one input and one builder +- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: the old `flowPage` mixed about 60 lines of error mapping with `return_to` handling, and now `flowReturnTo` and `flowFailure` read on their own +- Locality: `src/http/static.ts:64` `hashes`: a hidden module-level cache filled by two readers, and `assetHref` is merged by hand at three render sites +- Shape, Navigation: `src/ui/stylesheet-floor.ts:119` `positive`: a 249-line regex walker that only a test uses sits among runtime view modules + +Junior A, decided by: + +- Navigation, Self-sufficiency: `src/ui/stylesheet-floor.ts:1` `floorViolations`: the README's "Two browser floors" points here and the header lists what is checked, where the base had a prose rule and a hand-written regex test +- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: one shell view model replaces `ShellModel` beside `PageChrome`, and its helpers sit beside their only caller +- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: named units replace about 60 inline lines in `flowPage`, and `codeFlowSubmit` reuses them +- Self-sufficiency: `src/auth/kratos-admin.ts:68` `createRecoveryCode`: the comment no longer claims Kratos mails the link +- Locality: `src/http/static.ts:64` `hashes`: a module-level mutable map backs asset versioning, hidden state the base didn't have + +## 2026-10-31T12:30:19Z, PR #200 at 3592a30 + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 | +| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 | +| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 | +| Inherited architect | 7 | 5.5 | 5.5 | 7.5 | 6.5 | +| Mean | 7.00 | 5.88 | 6.25 | 6.75 | 6.50 | + +Junior A, hardest first: + +- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built `@supports` evaluator whose header says what it enforces but not how it decides a guard names a feature +- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in one closure where each stage reads state an earlier stage set +- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: its 4xx branches can't fire from `codeFlowSubmit` because `submitFlow` throws only on 5xx, and the call site doesn't say so + +Mid A, hardest first: + +- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-written CSS walker with three regex passes per declaration, opaque at line level +- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a fixed order of about 15 stages that nothing in the code enforces +- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment names the tuple `#members` where the code uses `granted` +- Locality: `src/auth/routes.ts:121` `codeFlowSubmit`: one idea split across `flow-view.ts`, `routes.ts` and `kratos-public.ts` + +Maintainability senior, hardest first: + +- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a 167-line closure of complexity 43 whose order across closure state nothing names as a contract +- Self-sufficiency, Locality: `src/auth/routes.ts:58` `flowFailure`: on the submit path every 4xx branch is dead and nothing at the call site says which can fire +- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: the one module whose correctness can't be checked by reading +- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` while the code writes `granted` + +Inherited architect, would restructure first: + +- Shape, Navigation: `src/auth/flow-view.ts:152` `formAction`: the flow-to-route mapping has two homes, here and `routes.ts`, so a code flow's route and its form target are decided in different files +- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a CSS linter that never runs at runtime sits among the runtime view helpers +- Locality, Shape: `src/http/app.ts:185` `handleRequest`: a level of its own inside `createApp` +- Shape: `src/http/builtin-routes.ts:23` `matchBuiltinRoute`: holds no routes, because the table is inline in `app.ts:176` and `buildAuthRoutes` + +## 2026-10-31T12:30:27Z, PR #200 rerun at 3592a30 + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Junior A | 7 | 5.5 | 6 | 6.5 | 6 | +| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 | +| Maintainability senior | 7 | 5.5 | 6 | 6.5 | 6 | +| Inherited architect | 7 | 6 | 6 | 6.5 | 6.5 | +| Mean | 7.00 | 5.75 | 6.13 | 6.50 | 6.25 | + +Junior A, hardest first: + +- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built CSS walker that stayed opaque without tracing it by hand +- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 320 lines of interdependent locals, with a fixed stage order that only a top-to-bottom read shows +- Self-sufficiency, Shape: `src/auth/login.ts:40` `completeLogin`: the header says `#members` where the code uses `granted` + +Mid A, hardest first: + +- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines of order-dependent stages, with `renderPage` reassigned partway +- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: how it decides a guard names a feature stayed opaque +- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `seedPermissions`: the comment says `#members` where the code uses `granted` + +Maintainability senior, hardest first: + +- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 167 lines and complexity 43 in a fixed order nobody states +- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS walker, partly opaque +- Locality, Shape: `src/plugin-host/plugin.ts:126` `RESERVED_PLUGIN_IDS`: a hand-kept mirror of the host's path segments that has already drifted, as it omits `error` +- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted` + +Inherited architect, would restructure first: + +- Locality, Overall: `src/http/app.ts:185` `handleRequest`: the most concerns and the highest complexity, already grandfathered, and every request-wide concern lands in it +- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/` +- Shape: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes six concerns, and this file with `flow-view.ts` is where the next flow lands diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 69e6c51..46aa50b 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -257,3 +257,20 @@ Read most (opens and searches): - `README.md` 2 - `AGENTS.md` 1 - `src/ui/theme-switch.test.ts` 1 + +## Reading cost, chunk 4a258a237c53, finished 2026-10-31T12:04:07Z + +154777 new input tokens before the first commit. + +Read most (opens and searches): + +- `e2e-tests/full-flow.spec.ts` 9 +- `todo.md` 6 +- `README.md` 5 +- `src/auth/flow-view.ts` 5 +- `src/auth/flow-view.test.ts` 4 +- `src/auth/routes.ts` 4 +- `src/auth/kratos-public.test.ts` 3 +- `src/i18n/locales/en-US.ts` 3 +- `e2e-tests/compose.full.yml` 2 +- `src/auth/kratos-admin.ts` 2 diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 3ae774e..3118329 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -138,6 +138,26 @@ test.describe.serial("authenticated admin journey", () => { await expect(page.locator("tr", { hasText: email })).toHaveCount(0); }); + // Kratos accepts an admin-minted code only in the flow it was minted on, which the notice's link opens. + test("recovery code: the notice's link opens the flow that accepts the code, and the code signs the user in", async () => { + test.setTimeout(90_000); + const email = `admin-recovery-${suffix}@plainpages.local`; + await createIdentity(email); + await page.goto("/admin/users"); + const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href"); + await page.goto(editHref!); + await page.getByRole("button", { name: "Generate recovery code" }).click(); + const code = await page.locator(".recovery-code code").innerText(); + const link = await page.locator(".alert-body a").getAttribute("href"); + + const user = await watchedPage(await browser.newContext()); + await user.goto(link!); + await user.fill('input[name="code"]', code); + await user.getByRole("button", { name: "Continue" }).click(); + await expect(user).toHaveURL(/\/settings/); + await user.context().close(); + }); + test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => { // A Keto set exists only while it has ≥1 member, so create needs a first member (the form // enforces it); pick the first option (a user) from the required picker. diff --git a/examples/plugins/admin/i18n/en-US.ts b/examples/plugins/admin/i18n/en-US.ts index 6cb414d..b3bad1f 100644 --- a/examples/plugins/admin/i18n/en-US.ts +++ b/examples/plugins/admin/i18n/en-US.ts @@ -141,8 +141,8 @@ const messages = { "admin.users.new": "New user", "admin.users.pagination": "Users pagination", "admin.users.reactivate": "Reactivate", - "admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):", - "admin.users.recovery.link": "the password-reset screen", + "admin.users.recovery.body": "Send the user this link and code. They open the link and enter the code to set a new password (generate a fresh code if it has expired):", + "admin.users.recovery.link": "password-reset link", "admin.users.recovery.generate": "Generate recovery code", "admin.users.recovery.title": "Recovery code generated", "admin.users.save": "Save changes", diff --git a/examples/plugins/admin/i18n/sv-SE.ts b/examples/plugins/admin/i18n/sv-SE.ts index 00c6824..1dec870 100644 --- a/examples/plugins/admin/i18n/sv-SE.ts +++ b/examples/plugins/admin/i18n/sv-SE.ts @@ -141,8 +141,8 @@ const messages: AdminMessages = { "admin.users.new": "Ny användare", "admin.users.pagination": "Sidnavigering för användare", "admin.users.reactivate": "Aktivera igen", - "admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):", - "admin.users.recovery.link": "sidan för lösenordsåterställning", + "admin.users.recovery.body": "Skicka länken och koden till användaren. Användaren öppnar länken och anger koden för att sätta ett nytt lösenord (skapa en ny kod om den hunnit gå ut):", + "admin.users.recovery.link": "länk för lösenordsåterställning", "admin.users.recovery.generate": "Skapa återställningskod", "admin.users.recovery.title": "Återställningskod skapad", "admin.users.save": "Spara ändringar", diff --git a/examples/plugins/admin/views/partials/user-form-body.ejs b/examples/plugins/admin/views/partials/user-form-body.ejs index a9c3d78..bf8188b 100644 --- a/examples/plugins/admin/views/partials/user-form-body.ejs +++ b/examples/plugins/admin/views/partials/user-form-body.ejs @@ -2,7 +2,7 @@ Admin user create/edit form body, captured into the shell content slot. Config: form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] } edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only) - recovery? { code? } shown after a recovery code is generated (recovery is code-based) + recovery? { code, link } shown after a recovery code is generated; link is the URL of the flow the code was minted on, the only flow that accepts the code error? string shown when a write was rejected %><% const form = locals.form; @@ -14,7 +14,7 @@ <%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <% } -%> <% if (recovery) { -%> -
<%= recovery.code %><% } %><%= recovery.code %>