Files
EpicNext-Cms/src/actions/password-reset.ts
T
openhands 6bffc53779
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
refactor(auth): merge the duplicate login form and localize the auth screens
`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical
components. Delete the former and give `LoginForm` a `variant` prop:

- `variant="page"`   sr-only labels plus the register/forgot footer (/login)
- `variant="compact"` visible labels, no footer (homepage sidebar)

Field ids now come from `useId()`, so the two usages can never collide, and the
hardcoded "Show"/"Hide"/"Loading" strings are translated.

Localization of the login and register screens:

- `home-login-form.tsx` was entirely hardcoded English.
- `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong".
- `register.ts` returned only English strings. It now returns a
  locale-independent `code` next to the message, and the form renders
  `t(code)` with the English string as a fallback.
- Backfilled the new keys across all 25 locales, plus the login/register
  strings that were still English in most of them. `ar`, `fi` and `ja` had
  their entire login/register namespace in English and are now filled in.
  Locale parity stays at 0 missing keys, as `i18n:check` requires.

Copy that did not match the enforced rules: the UI advertised "min 8 chars"
(EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an
uppercase, a lowercase, a digit and a special character. Corrected in every
locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to
match registration.

Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on
any field. All three are now present, and error banners are announced with
`role="alert"`.

Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode`
resolves to a non-empty message in all 25 locales; verified it fails when a key
is removed. The existing register tests now also assert the error `code`.
2026-10-04 18:50:23 +02:00

146 lines
4.3 KiB
TypeScript

"use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password";
import { db, PasswordReset, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { sendMail } from "@/lib/services/email";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
function sha256(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
const ip = await clientIp();
// CAPTCHA when a provider is configured (mirrors register).
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip))) {
redirect("/forgot?error=captcha");
}
}
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
const allowed = (await rateLimit(`reset:${ip}`, 3, 15 * 60_000)).ok;
// Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
const [user] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.limit(1);
if (user) {
const token = randomBytes(32).toString("hex");
const hashed = sha256(token);
const createdAt = new Date();
await db
.insert(PasswordReset)
.values({ email, token: hashed, createdAt })
.onDuplicateKeyUpdate({ set: { token: hashed, createdAt } });
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
await sendMail(
email,
`${env.HOTEL_NAME} — password reset`,
`<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`,
);
}
} catch {
// swallow — generic response below
}
}
redirect("/forgot?sent=1");
}
export async function resetPassword(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
const token = String(formData.get("token") ?? "")
.normalize("NFC")
.trim();
const password = String(formData.get("password") ?? "").normalize("NFC");
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
);
}
let error: string | null = null;
if (password.length < 12) error = "Password must be at least 12 characters";
if (!error) {
try {
const [row] = await db
.select({
token: PasswordReset.token,
createdAt: PasswordReset.createdAt,
})
.from(PasswordReset)
.where(eq(PasswordReset.email, email))
.limit(1);
const fresh = row?.createdAt
? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS
: false;
const a = Buffer.from(sha256(token), "hex");
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
const match =
row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired";
} else {
const [user] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.limit(1);
if (!user) {
error = "Account not found";
} else {
await db
.update(User)
.set({ password: await hashPassword(password) })
.where(eq(User.id, user.id));
await db
.delete(PasswordReset)
.where(eq(PasswordReset.email, email))
.catch((error) =>
logServerError("password.reset_delete_tokens_failed", error, {
email,
}),
);
}
}
} catch {
error = "Could not reset the password — try again";
}
}
if (error) {
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
);
}
redirect("/login?reset=1");
}