mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(insights): cycle ring replaces strain tile, tidy overview spacing, nav order, password-reset CLI
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.
This commit is contained in:
1 parent
e62bb9f96c
commit
fcf1366fea
11 files changed
+339
-64
No files matched your search
@@ -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 <username-or-email>
|
||||
```
|
||||
|
||||
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 "<id>"`; 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 <username-or-email>
|
||||
```
|
||||
@@ -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 <username-or-email> [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 <username-or-email> [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));
|
||||
});
|
||||
@@ -395,6 +395,10 @@ export default function InsightsPage() {
|
||||
extraTile={
|
||||
user?.cycleTrackingEnabled ? <CycleRingTile /> : 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 && (
|
||||
|
||||
@@ -142,6 +142,16 @@ describe("<CycleInsightSummaryCard>", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -77,65 +77,75 @@ export function CycleInsightSummaryCard() {
|
||||
: t("cycle.ring.ariaUnknown");
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="cycle-insight-summary"
|
||||
data-revealed="true"
|
||||
data-phase={phase ?? "none"}
|
||||
aria-label={t("cycle.insightsSummary.title")}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: hue }}
|
||||
// `data-revealed` must sit on an ANCESTOR of the `.wellness-tile-rise`
|
||||
// element — the keyframe selector is `[data-revealed="true"]
|
||||
// .wellness-tile-rise` (descendant combinator). Putting both on the same
|
||||
// node never matched, so the tile stayed stuck at the rise rule's
|
||||
// `opacity: 0` — invisible but still occupying its box, which read as an
|
||||
// oversized gap on the overview. The wrapper restores the entrance.
|
||||
<div data-revealed="true" className="contents">
|
||||
<section
|
||||
data-slot="cycle-insight-summary"
|
||||
data-phase={phase ?? "none"}
|
||||
aria-label={t("cycle.insightsSummary.title")}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: hue }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("cycle.insightsSummary.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span>{phaseName}</span>
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground mt-1 text-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("cycle.insightsSummary.title")}
|
||||
</span>
|
||||
>
|
||||
{dayOfCycle != null
|
||||
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
|
||||
: t("cycle.insightsSummary.noActiveCycle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span>{phaseName}</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm" aria-hidden="true">
|
||||
{dayOfCycle != null
|
||||
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
|
||||
: t("cycle.insightsSummary.noActiveCycle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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). */}
|
||||
<div className="mt-3">
|
||||
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<Link
|
||||
href="/cycle?tab=insights"
|
||||
data-slot="cycle-insight-summary-link"
|
||||
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{t("cycle.insightsSummary.viewDetails")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
<Link
|
||||
href="/cycle?tab=insights"
|
||||
data-slot="cycle-insight-summary-link"
|
||||
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{t("cycle.insightsSummary.viewDetails")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,4 +101,23 @@ describe("<WellnessScores>", () => {
|
||||
expect(html).not.toContain('data-metric="STRESS_SCORE"');
|
||||
expect(html).not.toContain('data-metric="STRAIN_SCORE"');
|
||||
});
|
||||
|
||||
it("drops the Strain tile when hideStrain is set (cycle ring takes its slot)", () => {
|
||||
const scores = {
|
||||
READINESS: ok({ score: 80, band: "green" }),
|
||||
STRAIN_SCORE: ok({ score: 40, band: "yellow" }),
|
||||
};
|
||||
// Without the flag Strain renders.
|
||||
const withStrain = render(
|
||||
<WellnessScores read={readFrom(scores)} isLoading={false} />,
|
||||
);
|
||||
expect(withStrain).toContain('data-metric="STRAIN_SCORE"');
|
||||
|
||||
// With the flag Strain is suppressed while the other score stays.
|
||||
const hidden = render(
|
||||
<WellnessScores read={readFrom(scores)} isLoading={false} hideStrain />,
|
||||
);
|
||||
expect(hidden).not.toContain('data-metric="STRAIN_SCORE"');
|
||||
expect(hidden).toContain('data-metric="READINESS"');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,13 @@ interface ScoreStripProps {
|
||||
* show, so the strip subtracts it from the count in that case.
|
||||
*/
|
||||
extraTile?: React.ReactNode;
|
||||
/**
|
||||
* Drop the Strain score tile from the rendered strip. Set when the cycle ring
|
||||
* takes Strain's slot (cycle-tracking accounts) so the row stays compact at
|
||||
* the same column count instead of growing a sixth tile. Strain stays
|
||||
* available for non-cycle accounts; it is only hidden, never removed.
|
||||
*/
|
||||
hideStrain?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -160,6 +167,7 @@ export function WellnessScores({
|
||||
isError = false,
|
||||
refetch,
|
||||
extraTile,
|
||||
hideStrain = false,
|
||||
className,
|
||||
}: ScoreStripProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -302,7 +310,10 @@ export function WellnessScores({
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && strain?.status === "ok" && strain.value) {
|
||||
// When the cycle ring takes the slot (cycle-tracking accounts), the Strain
|
||||
// tile is dropped so the row stays compact rather than growing a sixth tile.
|
||||
// Strain renders normally for everyone else.
|
||||
if (!hideStrain && !isLoading && strain?.status === "ok" && strain.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRAIN_SCORE"
|
||||
|
||||
@@ -98,6 +98,19 @@ describe("<SidebarNav> cycle entry gate (v1.15.0)", () => {
|
||||
const html = render({ cycleTrackingEnabled: false });
|
||||
expect(html).not.toContain('href="/cycle"');
|
||||
});
|
||||
|
||||
it("places the Cycle entry between Medications and Insights when enabled", () => {
|
||||
const html = render({ cycleTrackingEnabled: true });
|
||||
const med = html.indexOf('href="/medications"');
|
||||
const cycle = html.indexOf('href="/cycle"');
|
||||
const insights = html.indexOf('href="/insights"');
|
||||
expect(med).toBeGreaterThan(-1);
|
||||
expect(cycle).toBeGreaterThan(-1);
|
||||
expect(insights).toBeGreaterThan(-1);
|
||||
// Cycle sits after Medications and before Insights in document order.
|
||||
expect(med).toBeLessThan(cycle);
|
||||
expect(cycle).toBeLessThan(insights);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
@@ -260,10 +260,21 @@ export function SidebarNav() {
|
||||
// with no sub-item expansion in the global sidebar — `<AdminShell>`
|
||||
// renders its own per-section nav inside the page itself.
|
||||
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||
// v1.15.0 — append the cycle entry only when the gate resolves true.
|
||||
const visibleNavItems = user?.cycleTrackingEnabled
|
||||
? [...navItems, cycleNavItem]
|
||||
: navItems;
|
||||
// v1.15.5 — surface the cycle entry directly after Medications (before
|
||||
// Insights) when the gate resolves true, instead of tacking it on at the
|
||||
// end. Splice by the Medications index so the position survives a reorder
|
||||
// of the static list; fall back to append if the anchor ever moves.
|
||||
const visibleNavItems = useMemo(() => {
|
||||
if (!user?.cycleTrackingEnabled) return navItems;
|
||||
const afterMedications =
|
||||
navItems.findIndex((item) => item.href === "/medications") + 1;
|
||||
if (afterMedications <= 0) return [...navItems, cycleNavItem];
|
||||
return [
|
||||
...navItems.slice(0, afterMedications),
|
||||
cycleNavItem,
|
||||
...navItems.slice(afterMedications),
|
||||
];
|
||||
}, [user?.cycleTrackingEnabled]);
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The single source of truth for the Argon2id hashing parameters.
|
||||
*
|
||||
* Both the application's `hashPassword()` (src/lib/auth/password.ts) and the
|
||||
* operator password-reset CLI (scripts/reset-password.mjs) import these exact
|
||||
* options so a hash minted from the container matches one minted by the running
|
||||
* app byte-for-byte. The CLI must run under plain `node` inside the production
|
||||
* standalone image (which strips `tsx` and cannot import the TS client), so the
|
||||
* shared params live in a plain `.mjs` module that both an ESM `node` process
|
||||
* and the bundled TypeScript app can import.
|
||||
*
|
||||
* Do not fork these values. Changing them changes the verifier cost for every
|
||||
* new hash; existing stored hashes carry their own parameters in the encoded
|
||||
* string and keep verifying regardless.
|
||||
*
|
||||
* @type {{ memoryCost: number; timeCost: number; outputLen: number; parallelism: number }}
|
||||
*/
|
||||
export const ARGON2_HASH_OPTIONS = {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
};
|
||||
@@ -3,14 +3,14 @@ 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, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
});
|
||||
return hash(password, ARGON2_HASH_OPTIONS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
|
||||
Reference in new issue
Block a user