From fcf1366feab9d02a483b5a3c0b0653dacc741189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 21:31:12 +0200 Subject: [PATCH] feat(insights): cycle ring replaces strain tile, tidy overview spacing, nav order, password-reset CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When cycle tracking is on, the cycle ring takes the Strain slot in the health-scores strip rather than adding a sixth tile: the wellness strip gains a hideStrain flag the insights page sets from the cycle gate, so Strain is hidden for cycle accounts and shown unchanged for everyone else. Fix the oversized vertical gap on the insights overview. The cycle summary card set data-revealed on the same node that carried wellness-tile-rise, but the entrance keyframe selector is a descendant combinator, so the animation never matched and the card stayed at the rise rule's opacity: 0 — invisible yet still occupying its box. Wrap the card so data-revealed sits on an ancestor, restoring the entrance and removing the phantom gap. Move the gated cycle entry directly after Medications (before Insights) in the sidebar nav instead of appending it at the end, spliced by the Medications index so the position survives a list reorder. Add an operator-only password-reset CLI. It runs under plain node in the production standalone image (which strips tsx) via the in-image pg and @node-rs/argon2, reusing the app's Argon2id parameters through a shared .mjs module so a reset hash matches one the app would mint. It matches a single user by username or email, reads the new password without echoing, never prints it back, and exits non-zero on zero or multiple matches. Documented in docs/ops/password-reset.md. --- docs/ops/password-reset.md | 61 ++++++++++ scripts/reset-password.mjs | 113 +++++++++++++++++ src/app/insights/page.tsx | 4 + .../cycle-insight-summary-card.test.tsx | 10 ++ .../cycle/cycle-insight-summary-card.tsx | 114 ++++++++++-------- .../__tests__/wellness-scores.test.tsx | 19 +++ .../insights/derived/wellness-scores.tsx | 13 +- .../layout/__tests__/sidebar-nav.test.tsx | 13 ++ src/components/layout/sidebar-nav.tsx | 21 +++- src/lib/auth/argon2-params.mjs | 23 ++++ src/lib/auth/password.ts | 12 +- 11 files changed, 339 insertions(+), 64 deletions(-) create mode 100644 docs/ops/password-reset.md create mode 100644 scripts/reset-password.mjs create mode 100644 src/lib/auth/argon2-params.mjs diff --git a/docs/ops/password-reset.md b/docs/ops/password-reset.md new file mode 100644 index 000000000..8f6f29f72 --- /dev/null +++ b/docs/ops/password-reset.md @@ -0,0 +1,61 @@ +# Password reset (operator) + +A self-hosted operator can reset a single user's password from inside the +running container. This is an **operator-only** maintenance path — there is no +user-facing or API route for it, and it bypasses the normal account-recovery +flow. Use it only for an account you administer (for example, after a forgotten +password on a single-user instance). + +> **Operator-only.** The script writes directly to the `users` table and is +> intended to be run by the host operator with database access. Treat the new +> password like any other secret: pass it on stdin (it is read without echoing) +> rather than as a shell argument where it would land in shell history and the +> process list. + +## Run it + +The reset CLI ships in the image and runs under plain `node` against the built +runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the +in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id +parameters): + +```sh +docker compose exec app node scripts/reset-password.mjs +``` + +The script prompts for the new password without echoing it, then prints a +confirmation that names only the user — never the password: + +``` +New password (input hidden): +reset-password: password updated for "alice" +``` + +You can also pass the password as a second argument for non-interactive use, +but prefer the stdin prompt so the secret stays out of shell history: + +```sh +docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphrase' +``` + +## Behaviour + +- The identifier is matched **case-insensitively** against `username` **or** + `email`, mirroring the login lookup. +- The match must resolve to exactly one user. Zero matches exits non-zero with + `no user matches ""`; multiple matches exits non-zero and refuses to guess. +- The new password must be at least 12 characters (the app's minimum). +- The password is hashed with Argon2id using the same cost parameters as the + application (`src/lib/auth/argon2-params.mjs`), so the stored hash is + identical to one the app would mint. +- `DATABASE_URL` is read from the environment — the same variable the app uses, + honouring any `sslmode=` in the connection string. + +## Source checkout + +From a source checkout (not the production image) the same script runs under +Node directly: + +```sh +DATABASE_URL='postgres://…' node scripts/reset-password.mjs +``` diff --git a/scripts/reset-password.mjs b/scripts/reset-password.mjs new file mode 100644 index 000000000..e31cfd8c8 --- /dev/null +++ b/scripts/reset-password.mjs @@ -0,0 +1,113 @@ +/** + * scripts/reset-password.mjs — operator-only CLI to reset a single user's + * password from inside the running container. + * + * WHY plain `.mjs` and not the `.ts` scripts pattern: the production standalone + * image strips `tsx` and ships the Prisma client only as bundled TypeScript + * inside `server.js` (not importable from a standalone process). This script + * therefore talks to Postgres through `pg` (present in the image) and hashes + * with `@node-rs/argon2` (also present), reusing the ONE shared Argon2id param + * object that the app's `hashPassword()` uses, so the hash it writes is + * byte-compatible with a hash the app would mint. + * + * Usage (inside the container — operator only): + * docker compose exec app node scripts/reset-password.mjs [new-password] + * + * If the password is omitted it is read from stdin without echoing. The + * password is never printed back. Exits non-zero with a clear message when the + * identifier matches zero or more than one user. + * + * Reads `DATABASE_URL` from the environment (the same var the app uses). The + * connection honours any `sslmode=` in the URL exactly like the app pool. + */ + +import { createInterface } from "node:readline"; +import { hash } from "@node-rs/argon2"; +import pg from "pg"; +import { ARGON2_HASH_OPTIONS } from "../src/lib/auth/argon2-params.mjs"; + +function fail(message) { + console.error(`reset-password: ${message}`); + process.exit(1); +} + +/** Read a line from stdin without echoing it to the terminal. */ +function promptHidden(label) { + return new Promise((resolve) => { + process.stdout.write(label); + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + // Override the line-writer so typed characters are never echoed. The + // prompt label above is already written; the closing newline is emitted + // when the answer resolves. + rl._writeToOutput = () => {}; + rl.question("", (answer) => { + rl.close(); + process.stdout.write("\n"); + resolve(answer); + }); + }); +} + +async function main() { + const identifier = process.argv[2]; + if (!identifier) { + fail( + "usage: node scripts/reset-password.mjs [new-password]", + ); + } + + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + fail("DATABASE_URL must be set"); + } + + let newPassword = process.argv[3]; + if (!newPassword) { + newPassword = await promptHidden("New password (input hidden): "); + } + if (!newPassword || newPassword.length < 12) { + fail("new password must be at least 12 characters"); + } + + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + // Case-insensitive match on username OR email, mirroring the login lookup. + // Scope to a single user: zero or multiple matches is a hard error so an + // operator never silently resets the wrong account. + const found = await client.query( + `SELECT id, username FROM users + WHERE lower(username) = lower($1) OR lower(email) = lower($1)`, + [identifier], + ); + + if (found.rowCount === 0) { + fail(`no user matches "${identifier}"`); + } + if (found.rowCount > 1) { + fail( + `"${identifier}" matches ${found.rowCount} users — refusing to guess; reset by exact username`, + ); + } + + const user = found.rows[0]; + const passwordHash = await hash(newPassword, ARGON2_HASH_OPTIONS); + + await client.query( + `UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2`, + [passwordHash, user.id], + ); + + // Never echo the password; confirm by username only. + console.log(`reset-password: password updated for "${user.username}"`); + } finally { + await client.end(); + } +} + +main().catch((err) => { + fail(err instanceof Error ? err.message : String(err)); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 5018a9035..71b67c6f2 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -395,6 +395,10 @@ export default function InsightsPage() { extraTile={ user?.cycleTrackingEnabled ? : undefined } + // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: + // hide Strain so the strip stays compact instead of growing a sixth + // tile. Strain stays visible for non-cycle accounts. + hideStrain={user?.cycleTrackingEnabled === true} /> {flags.briefing && ( diff --git a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx index c24d38226..60b10499c 100644 --- a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx @@ -142,6 +142,16 @@ describe("", () => { expect(html).toContain('href="/cycle?tab=insights"'); expect(html).toContain("View cycle insights"); expect(html).toContain('data-phase="LUTEAL"'); + // Regression: the entrance-animation element (`.wellness-tile-rise`, which + // is `opacity: 0` until revealed) must sit UNDER a `data-revealed` ancestor + // — the CSS selector is a descendant combinator. With both on the same node + // the card stayed permanently invisible but still occupied its box, which + // read as an oversized gap on the overview. Assert the wrapper precedes the + // rise element so the descendant selector matches. + const revealedIdx = html.indexOf('data-revealed="true"'); + const riseIdx = html.indexOf("wellness-tile-rise"); + expect(revealedIdx).toBeGreaterThan(-1); + expect(riseIdx).toBeGreaterThan(revealedIdx); }); it("shows the honest empty headline line when nothing cleared the FDR gate", () => { diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index c0a3136ca..1641c6419 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -77,65 +77,75 @@ export function CycleInsightSummaryCard() { : t("cycle.ring.ariaUnknown"); return ( -
-
- {/* Zone 1 — eyebrow + the current phase / cycle-day read. */} -
-
- +
+
+ {/* Zone 1 — eyebrow + the current phase / cycle-day read. */} +
+
+
+ +

+

+ +
- -

-

- -
-
- {/* Zone 2 — the one headline phase finding (shared component owns the + {/* Zone 2 — the one headline phase finding (shared component owns the FDR-gated copy AND the honest "not enough cycles yet" empty line, so this can never show a broken state). */} -
- -
+
+ +
- {/* Zone 3 — the deep-link into the single source of truth: the cycle + {/* Zone 3 — the deep-link into the single source of truth: the cycle insights tab. The cycle page reads `?tab=insights` and opens that tab. */} - - {t("cycle.insightsSummary.viewDetails")} -
+ + {t("cycle.insightsSummary.viewDetails")} +