mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
65 lines
1.8 KiB
TypeScript
65 lines
1.8 KiB
TypeScript
import { hash, verify } from "@node-rs/argon2";
|
|
import zxcvbn from "zxcvbn-typescript";
|
|
import { getZxcvbnTranslations } from "@/lib/zxcvbn-i18n";
|
|
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
|
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
|
// The Argon2id params live in a plain `.mjs` module so the operator
|
|
// password-reset CLI (scripts/reset-password.mjs) can mint a byte-identical
|
|
// hash under plain `node` in the production standalone image — there is one
|
|
// source of truth for the cost parameters, not two.
|
|
import { ARGON2_HASH_OPTIONS } from "./argon2-params.mjs";
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return hash(password, ARGON2_HASH_OPTIONS);
|
|
}
|
|
|
|
export async function verifyPassword(
|
|
storedHash: string,
|
|
password: string,
|
|
): Promise<boolean> {
|
|
return verify(storedHash, password);
|
|
}
|
|
|
|
export interface PasswordStrength {
|
|
score: number; // 0-4
|
|
feedback: string[];
|
|
isAcceptable: boolean;
|
|
}
|
|
|
|
const MIN_PASSWORD_LENGTH = 12;
|
|
|
|
export function checkPasswordStrength(
|
|
password: string,
|
|
userInputs: string[] = [],
|
|
locale: Locale = defaultLocale,
|
|
): PasswordStrength {
|
|
const { t } = getServerTranslator(locale);
|
|
const { translate } = getZxcvbnTranslations(locale);
|
|
|
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
return {
|
|
score: 0,
|
|
feedback: [
|
|
t("auth.passwordTooShort", { minLength: MIN_PASSWORD_LENGTH }),
|
|
],
|
|
isAcceptable: false,
|
|
};
|
|
}
|
|
|
|
const result = zxcvbn(password, userInputs);
|
|
const feedback: string[] = [];
|
|
|
|
if (result.feedback.warning) {
|
|
feedback.push(translate(result.feedback.warning));
|
|
}
|
|
if (result.feedback.suggestions) {
|
|
feedback.push(...result.feedback.suggestions.map(translate));
|
|
}
|
|
|
|
return {
|
|
score: result.score,
|
|
feedback,
|
|
isAcceptable: result.score >= 3,
|
|
};
|
|
}
|