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")} +