diff --git a/drizzle/migrations/0035_users_mail_index.sql b/drizzle/migrations/0035_users_mail_index.sql new file mode 100644 index 00000000..2c772ed4 --- /dev/null +++ b/drizzle/migrations/0035_users_mail_index.sql @@ -0,0 +1,19 @@ +-- 0035_users_mail_index.sql +-- Index on users.mail. +-- +-- The authentication paths all look an account up by mail: password reset, +-- e-mail verification, duplicate-address detection and the verify/resend +-- cooldown all resolve a single user from a submitted address. Without an index +-- each of those is a full table scan of `users`, which grows with every +-- registration. +-- +-- Deliberately NOT unique. Legacy rows predate the duplicate-address handling +-- and can legitimately contain the same address more than once, so a unique +-- index would fail to apply on an existing database. The lookup is made +-- deterministic by ordering on `id` (see requestReset / the verify page), which +-- is stable without the index and correct with it. +-- +-- The column is VARCHAR(500), which exceeds the 767-byte InnoDB prefix limit on +-- older row formats, hence an explicit 191-character prefix: enough to make the +-- lookup selective and still indexable everywhere. +CREATE INDEX IF NOT EXISTS `users_mail_index` ON `users` (`mail`(191)); \ No newline at end of file diff --git a/src/actions/auth-precheck.test.ts b/src/actions/auth-precheck.test.ts index 1f340493..43ad0d81 100644 --- a/src/actions/auth-precheck.test.ts +++ b/src/actions/auth-precheck.test.ts @@ -15,6 +15,9 @@ const core = vi.hoisted(() => ({ .trim(), password: String(password ?? "").normalize("NFC"), }), + isLoginLocked: vi.fn(async () => false), + recordLoginFailure: vi.fn(async () => false), + clearLoginLockout: vi.fn(async () => undefined), })); vi.mock("@/env", () => ({ env: {} })); @@ -27,8 +30,14 @@ vi.mock("@/lib/services/captcha", () => ({ vi.mock("@/lib/services/site-settings", () => ({ siteSettings: { getBool: vi.fn() }, })); +vi.mock("@/lib/auth/login-lockout", () => ({ + isLoginLocked: core.isLoginLocked, + recordLoginFailure: core.recordLoginFailure, + clearLoginLockout: core.clearLoginLockout, +})); const user = (overrides = {}) => ({ + id: 42, password: "hash", twoFactorConfirmedAt: null, mail: null, @@ -45,6 +54,9 @@ beforeEach(() => { core.verifyLoginPassword.mockResolvedValue({ valid: true }); core.isEmailUnverified.mockResolvedValue(false); core.runDummyHashCheck.mockResolvedValue(undefined); + core.isLoginLocked.mockResolvedValue(false); + core.recordLoginFailure.mockResolvedValue(false); + core.clearLoginLockout.mockResolvedValue(undefined); }); describe("precheckLogin", () => { @@ -85,4 +97,57 @@ describe("precheckLogin", () => { core.isEmailUnverified.mockResolvedValue(true); expect(await precheckLogin("user", "pass")).toBe("unverified"); }); + + it("returns locked for an account that is already locked out", async () => { + core.getLoginUser.mockResolvedValue(user()); + core.isLoginLocked.mockResolvedValue(true); + expect(await precheckLogin("user", "pass")).toBe("locked"); + // The password is never verified while locked, so a correct password + // cannot walk a locked account back in. + expect(core.verifyLoginPassword).not.toHaveBeenCalled(); + expect(core.clearLoginLockout).not.toHaveBeenCalled(); + }); + + it("checks the lockout before verifying the password", async () => { + core.getLoginUser.mockResolvedValue(user()); + const order: string[] = []; + core.getLoginUser.mockImplementation(async () => { + order.push("lookup"); + return user(); + }); + core.isLoginLocked.mockImplementation(async () => { + order.push("lock"); + return false; + }); + core.verifyLoginPassword.mockImplementation(async () => { + order.push("verify"); + return { valid: true }; + }); + expect(await precheckLogin("user", "pass")).toBe("ok"); + expect(order).toEqual(["lookup", "lock", "verify"]); + }); + + it("records a failure and skips the clear when the password is wrong", async () => { + core.getLoginUser.mockResolvedValue(user()); + core.verifyLoginPassword.mockResolvedValue({ valid: false }); + core.recordLoginFailure.mockResolvedValue(false); + expect(await precheckLogin("user", "pass")).toBe("invalid"); + expect(core.recordLoginFailure).toHaveBeenCalledWith(42); + expect(core.clearLoginLockout).not.toHaveBeenCalled(); + }); + + it("clears the lockout after a successful authentication", async () => { + core.getLoginUser.mockResolvedValue(user()); + expect(await precheckLogin("user", "pass")).toBe("ok"); + expect(core.clearLoginLockout).toHaveBeenCalledWith(42); + expect(core.recordLoginFailure).not.toHaveBeenCalled(); + }); + + it("does not lock or clear a bucket for an unknown account", async () => { + core.getLoginUser.mockResolvedValue(null); + expect(await precheckLogin("nonexistent", "pass")).toBe("invalid"); + expect(core.isLoginLocked).not.toHaveBeenCalled(); + expect(core.recordLoginFailure).not.toHaveBeenCalled(); + expect(core.clearLoginLockout).not.toHaveBeenCalled(); + }); }); diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts index 64acb0f9..86d44438 100644 --- a/src/actions/auth-precheck.ts +++ b/src/actions/auth-precheck.ts @@ -7,6 +7,11 @@ import { runDummyHashCheck, verifyLoginPassword, } from "@/lib/auth/login-core"; +import { + clearLoginLockout, + isLoginLocked, + recordLoginFailure, +} from "@/lib/auth/login-lockout"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; @@ -15,7 +20,8 @@ export type PrecheckResult = | "invalid" | "twofactor" | "unverified" - | "captcha"; + | "captcha" + | "locked"; /** * Validates username+password WITHOUT creating a session, and reports whether a @@ -38,6 +44,9 @@ export async function precheckLogin( if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha"; } + // A lockout must be checked BEFORE the password is verified: the success + // path clears the counter, which would otherwise let an already-locked + // account straight back in with the correct credentials. const user = await getLoginUser(u); if (!user) { // Prevent timing-based enumeration: always run a dummy hash check. @@ -45,8 +54,15 @@ export async function precheckLogin( return "invalid"; } + if (await isLoginLocked(user.id)) return "locked"; + const res = await verifyLoginPassword(user, p); - if (!res.valid) return "invalid"; + if (!res.valid) { + await recordLoginFailure(user.id); + return "invalid"; + } + + await clearLoginLockout(user.id); if (await isEmailUnverified(user)) { return "unverified"; diff --git a/src/actions/verify.test.ts b/src/actions/verify.test.ts new file mode 100644 index 00000000..3b4844b1 --- /dev/null +++ b/src/actions/verify.test.ts @@ -0,0 +1,186 @@ +// @ts-nocheck +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + clientIp: vi.fn(async () => "203.0.113.7"), + rateLimit: vi.fn(async () => ({ ok: true, retryAfter: 0 })), + captchaConfig: vi.fn(async () => ({ provider: "none", field: "" })), + // Mirrors the real verifier: a missing token never passes. + verifyCaptcha: vi.fn(async (token: string | null) => Boolean(token)), + sendVerification: vi.fn(async () => undefined), + rows: [] as Array>, + rateLimitedFor: null as string | null, + failDb: false, +})); + +vi.mock("@/lib/rate-limit", () => ({ + clientIp: state.clientIp, + rateLimit: vi.fn(async (key: string) => { + state.rateLimitedFor = key; + return state.rateLimit(); + }), +})); + +vi.mock("@/lib/services/captcha", () => ({ + captchaConfig: state.captchaConfig, + verifyCaptcha: state.verifyCaptcha, +})); + +vi.mock("@/lib/auth/email-verification", () => ({ + sendVerification: state.sendVerification, +})); + +vi.mock("@/lib/db", async () => { + const schema = await import("@/db/schema"); + const { createFakeDb } = await import("@/test/fake-db"); + return { + ...schema, + db: createFakeDb(() => { + if (state.failDb) throw new Error("db down"); + return state.rows; + }), + }; +}); + +import { resendVerification } from "./verify"; + +const form = (fields: Record) => { + const f = new FormData(); + for (const [k, v] of Object.entries(fields)) f.set(k, v); + return f; +}; + +const prev = { ok: false, error: null }; + +beforeEach(() => { + vi.clearAllMocks(); + state.clientIp.mockResolvedValue("203.0.113.7"); + state.rateLimit.mockResolvedValue({ ok: true, retryAfter: 0 }); + state.captchaConfig.mockResolvedValue({ provider: "none", field: "" }); + state.verifyCaptcha.mockImplementation(async (t) => Boolean(t)); + state.sendVerification.mockResolvedValue(undefined); + state.rows = []; + state.rateLimitedFor = null; + state.failDb = false; +}); + +describe("resendVerification", () => { + it("rejects a malformed address", async () => { + const res = await resendVerification(prev, form({ email: "nope" })); + expect(res).toEqual({ ok: false, error: "invalid" }); + expect(state.sendVerification).not.toHaveBeenCalled(); + }); + + it("sends for an unverified account", async () => { + state.rows = [{ id: 5, mailVerified: "0" }]; + const res = await resendVerification( + prev, + form({ email: "User@Example.com" }), + ); + expect(res).toEqual({ ok: true, error: null }); + expect(state.sendVerification).toHaveBeenCalledWith("user@example.com"); + }); + + it("answers identically for an unknown address so it cannot be probed", async () => { + state.rows = []; + const res = await resendVerification(prev, form({ email: "nobody@x.com" })); + expect(res).toEqual({ ok: true, error: null }); + expect(state.sendVerification).not.toHaveBeenCalled(); + }); + + it("does not mail an already verified account", async () => { + state.rows = [{ id: 5, mailVerified: "1" }]; + const res = await resendVerification(prev, form({ email: "a@b.com" })); + expect(res).toEqual({ ok: true, error: null }); + expect(state.sendVerification).not.toHaveBeenCalled(); + }); + + it("rate limits on the ip", async () => { + state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 60 }); + const res = await resendVerification(prev, form({ email: "a@b.com" })); + expect(res).toEqual({ ok: false, error: "rateLimited" }); + expect(state.sendVerification).not.toHaveBeenCalled(); + }); + + it("rate limits on the address so rotating ips cannot mail-bomb", async () => { + state.rateLimit + .mockResolvedValueOnce({ ok: true, retryAfter: 0 }) + .mockResolvedValueOnce({ ok: false, retryAfter: 300 }); + const res = await resendVerification(prev, form({ email: "a@b.com" })); + expect(res).toEqual({ ok: false, error: "rateLimited" }); + expect(state.sendVerification).not.toHaveBeenCalled(); + }); + + it("reports unavailable when the lookup throws", async () => { + state.failDb = true; + const res = await resendVerification(prev, form({ email: "a@b.com" })); + expect(res).toEqual({ ok: false, error: "unavailable" }); + }); +}); + +describe("resendVerification captcha", () => { + beforeEach(() => { + state.captchaConfig.mockResolvedValue({ + provider: "turnstile", + field: "cf-turnstile-response", + }); + }); + + it("rejects a missing token when a provider is configured", async () => { + state.rows = [{ id: 5, mailVerified: "0" }]; + const res = await resendVerification(prev, form({ email: "a@b.com" })); + expect(res).toEqual({ ok: false, error: "captcha" }); + expect(state.verifyCaptcha).toHaveBeenCalledWith(null, "203.0.113.7"); + expect(state.sendVerification).not.toHaveBeenCalled(); + }); + + it("rejects a failing token", async () => { + state.verifyCaptcha.mockResolvedValue(false); + state.rows = [{ id: 5, mailVerified: "0" }]; + const res = await resendVerification( + prev, + form({ + email: "a@b.com", + "cf-turnstile-response": "bad-token", + }), + ); + expect(res).toEqual({ ok: false, error: "captcha" }); + expect(state.sendVerification).not.toHaveBeenCalled(); + }); + + it("accepts a valid token and mails the account", async () => { + state.rows = [{ id: 5, mailVerified: "0" }]; + const res = await resendVerification( + prev, + form({ email: "a@b.com", "cf-turnstile-response": "good-token" }), + ); + expect(res).toEqual({ ok: true, error: null }); + expect(state.verifyCaptcha).toHaveBeenCalledWith( + "good-token", + "203.0.113.7", + ); + expect(state.sendVerification).toHaveBeenCalledWith("a@b.com"); + }); + + it("checks the captcha before the account lookup", async () => { + state.verifyCaptcha.mockResolvedValue(false); + state.rows = [{ id: 5, mailVerified: "0" }]; + await resendVerification(prev, form({ email: "a@b.com" })); + const order: string[] = []; + state.verifyCaptcha.mockImplementation(async () => { + order.push("captcha"); + return false; + }); + await resendVerification(prev, form({ email: "a@b.com" })); + order.push("done"); + expect(order).toEqual(["captcha", "done"]); + }); + + it("does not verify a captcha when no provider is configured", async () => { + state.captchaConfig.mockResolvedValue({ provider: "none", field: "" }); + state.rows = [{ id: 5, mailVerified: "0" }]; + const res = await resendVerification(prev, form({ email: "a@b.com" })); + expect(res).toEqual({ ok: true, error: null }); + expect(state.verifyCaptcha).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/verify.ts b/src/actions/verify.ts index 7737f20a..576ae2c8 100644 --- a/src/actions/verify.ts +++ b/src/actions/verify.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { sendVerification } from "@/lib/auth/email-verification"; import { db, User } from "@/lib/db"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; export interface ResendVerificationState { ok: boolean; @@ -18,7 +19,9 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * Deliberately reports success even when no matching unverified account exists: * a distinct failure would let anyone probe which addresses are registered. The * identical-privacy behaviour also applies to the e-mail templates, which are - * only sent for real accounts. Rate limiting is the spam defence. + * only sent for real accounts. Rate limiting plus captcha are the spam defence: + * this endpoint triggers real outbound mail, so an unverified address must not + * be usable as a free mail cannon. */ export async function resendVerification( _prevState: ResendVerificationState, @@ -42,6 +45,18 @@ export async function resendVerification( return { ok: false, error: "rateLimited" }; } + // Captcha runs before any lookup or send, and answers with the same + // `captcha` code the login form uses so the UI can point at the widget. + const cfg = await captchaConfig(); + if (cfg.provider !== "none") { + const token = String(formData.get(cfg.field) ?? "") + .normalize("NFC") + .trim(); + if (!(await verifyCaptcha(token || null, ip))) { + return { ok: false, error: "captcha" }; + } + } + try { const [user] = await db .select({ id: User.id, mailVerified: User.mailVerified }) diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx index ab8bbfa9..2a18e7a4 100644 --- a/src/app/(site)/layout.tsx +++ b/src/app/(site)/layout.tsx @@ -1,4 +1,6 @@ import dynamic from "next/dynamic"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages } from "next-intl/server"; import type { ReactNode } from "react"; import { CloudsField } from "@/components/clouds-field"; import MotionPageWrapper from "@/components/motion-page-wrapper"; @@ -7,6 +9,7 @@ import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; import { TopHeader } from "@/components/top-header"; import { auth } from "@/lib/auth"; +import { publicClientMessages } from "@/lib/i18n-client-messages"; const RadioPlayerGate = dynamic( () => import("@/components/public/radio-player-gate"), @@ -20,16 +23,26 @@ const RadioPlayerGate = dynamic( /** * Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`, * so housekeeping is never constrained by the public max-w-7xl grid. + * + * The message provider lives here rather than only in the root layout: nested + * NextIntlClientProviders replace the parent set instead of merging, so this is + * where the public catalogue is installed — without the ~177 KB of staff-tool + * namespaces that no page in this group can reach. */ export default async function SiteLayout({ children, }: { children: ReactNode; }) { - const session = await auth(); + const [session, locale, messages] = await Promise.all([ + auth(), + getLocale(), + getMessages(), + ]); + const clientMessages = publicClientMessages(messages); return ( - <> + {/* Site chrome is public: hiding it all for anonymous visitors used to strand them — from /news, /leaderboard or /shop there was no way to @@ -61,6 +74,6 @@ export default async function SiteLayout({
- +
); } diff --git a/src/app/(site)/verify/page.tsx b/src/app/(site)/verify/page.tsx index 87f9e74e..877abf5a 100644 --- a/src/app/(site)/verify/page.tsx +++ b/src/app/(site)/verify/page.tsx @@ -1,12 +1,14 @@ import { asc, eq } from "drizzle-orm"; import { CheckCircle2, Clock, MailX } from "lucide-react"; import type { Metadata } from "next"; +import { headers } from "next/headers"; import { getTranslations } from "next-intl/server"; import { ResendVerificationForm } from "@/components/auth/resend-verification-form"; import Link from "@/components/link"; import { SurfaceCard } from "@/components/surface-card"; import { isValidVerificationToken } from "@/lib/auth/email-verification"; import { db, User } from "@/lib/db"; +import { captchaConfig } from "@/lib/services/captcha"; export async function generateMetadata(): Promise { const t = await getTranslations("pages.verify"); @@ -103,6 +105,11 @@ export default async function VerifyPage({ const { token = "", email = "" } = await searchParams; const normalisedEmail = email.trim().toLowerCase(); + // The resend form triggers real outbound mail, so it carries the same + // captcha as the login/register forms. + const cfg = await captchaConfig(); + const nonce = (await headers()).get("x-nonce") ?? undefined; + let status: Status = "invalid"; if (normalisedEmail && token) { @@ -189,7 +196,7 @@ export default async function VerifyPage({

{/* Transient failure: offer both the retry path and the way out instead of leaving the visitor stranded on this card. */} - + {t("invalidBody")}

- + (null); const [unverified, setUnverified] = useState(false); const [pending, setPending] = useState(false); + const [locked, setLocked] = useState(false); const showFooter = variant === "page"; - const lockCredentials = needs2fa ? "opacity-50 pointer-events-none" : ""; + const lockCredentials = + needs2fa || locked ? "opacity-50 pointer-events-none" : ""; async function onSubmit(e: FormEvent) { e.preventDefault(); + if (locked) return; setError(null); setUnverified(false); setPending(true); @@ -67,6 +70,11 @@ export function LoginForm({ setError(t("errorInvalidCredentials")); return; } + if (pre === "locked") { + setLocked(true); + setError(t("errorLocked")); + return; + } if (pre === "captcha") { setError(t("errorCaptcha")); return; @@ -240,7 +248,7 @@ export function LoginForm({ + + ); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b720accb..48eacda0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,6 +11,11 @@ import { runDummyHashCheck, verifyLoginPassword, } from "@/lib/auth/login-core"; +import { + clearLoginLockout, + isLoginLocked, + recordLoginFailure, +} from "@/lib/auth/login-lockout"; export { invalidateLoginCache }; @@ -59,8 +64,19 @@ export const { handlers, signOut, auth } = NextAuth({ return null; } + // Same per-account lockout the pre-check uses (same key and budget), so + // the two paths cannot be used to buy extra attempts, and a client that + // skips the pre-check is still bounded. Checked before the password is + // verified — clearing on success would unlock a locked account. + if (await isLoginLocked(user.id)) return null; + const res = await verifyLoginPassword(user, password); - if (!res.valid) return null; + if (!res.valid) { + await recordLoginFailure(user.id); + return null; + } + + await clearLoginLockout(user.id); if (await isEmailUnverified(user)) { return null; diff --git a/src/lib/auth/login-lockout.test.ts b/src/lib/auth/login-lockout.test.ts new file mode 100644 index 00000000..8235ddbc --- /dev/null +++ b/src/lib/auth/login-lockout.test.ts @@ -0,0 +1,69 @@ +// @ts-nocheck +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/redis", () => ({ redis: null })); + +// In-process buckets survive across the module boundary, so every suite uses a +// distinct account id to keep the windows independent. +import { + clearLoginLockout, + isLoginLocked, + LOGIN_MAX_ATTEMPTS, + recordLoginFailure, +} from "./login-lockout"; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("login lockout", () => { + it("starts unlocked", async () => { + expect(await isLoginLocked(1)).toBe(false); + }); + + it("stays unlocked until the attempt budget is exhausted", async () => { + for (let i = 0; i < LOGIN_MAX_ATTEMPTS; i++) { + expect(await recordLoginFailure(2)).toBe(false); + expect(await isLoginLocked(2)).toBe(false); + } + }); + + it("locks on the attempt that exceeds the budget", async () => { + for (let i = 0; i < LOGIN_MAX_ATTEMPTS; i++) { + await recordLoginFailure(3); + } + expect(await recordLoginFailure(3)).toBe(true); + expect(await isLoginLocked(3)).toBe(true); + }); + + it("clears on a successful authentication", async () => { + for (let i = 0; i < LOGIN_MAX_ATTEMPTS; i++) { + await recordLoginFailure(4); + } + await recordLoginFailure(4); + expect(await isLoginLocked(4)).toBe(true); + + await clearLoginLockout(4); + + expect(await isLoginLocked(4)).toBe(false); + expect(await recordLoginFailure(4)).toBe(false); + }); + + it("does not consume an attempt just to check the lock", async () => { + for (let i = 0; i < 40; i++) { + await isLoginLocked(5); + } + await recordLoginFailure(5); + // Still one failure recorded, not forty. + expect(await isLoginLocked(5)).toBe(false); + }); + + it("keeps buckets per account", async () => { + for (let i = 0; i < LOGIN_MAX_ATTEMPTS; i++) { + await recordLoginFailure(6); + } + await recordLoginFailure(6); + expect(await isLoginLocked(6)).toBe(true); + expect(await isLoginLocked(7)).toBe(false); + }); +}); diff --git a/src/lib/auth/login-lockout.ts b/src/lib/auth/login-lockout.ts new file mode 100644 index 00000000..75202592 --- /dev/null +++ b/src/lib/auth/login-lockout.ts @@ -0,0 +1,53 @@ +import { clearRateLimit, peekRateLimit, rateLimit } from "@/lib/rate-limit"; + +/** + * Per-account login lockout. + * + * The pre-existing limits were keyed on the client IP, so a distributed attack + * — or simply a botnet — could grind on a single account indefinitely. This one + * is keyed on the resolved account id, which is what actually needs protecting. + * + * Keying on the id (rather than the submitted string) is deliberate: users may + * sign in with either their username or their e-mail, and neither `getLoginUser` + * nor `normalizeLoginInput` fold case. Keying on the input would hand an + * attacker a fresh budget for every spelling of the same account. + */ + +/** Failed attempts tolerated before an account locks. */ +export const LOGIN_MAX_ATTEMPTS = 8; +/** How long a failure stays on the record. */ +export const LOGIN_LOCKOUT_WINDOW_MS = 15 * 60_000; + +function lockoutKey(userId: number): string { + return `login-fail:${userId}`; +} + +/** + * Whether this account is currently locked. Read-only: the caller must not + * have to consume an attempt just to find out. + */ +export async function isLoginLocked(userId: number): Promise { + return !( + await peekRateLimit( + lockoutKey(userId), + LOGIN_MAX_ATTEMPTS, + LOGIN_LOCKOUT_WINDOW_MS, + ) + ).ok; +} + +/** Record a failed attempt. Resolves true when this failure trips the lockout. */ +export async function recordLoginFailure(userId: number): Promise { + return !( + await rateLimit( + lockoutKey(userId), + LOGIN_MAX_ATTEMPTS, + LOGIN_LOCKOUT_WINDOW_MS, + ) + ).ok; +} + +/** Forget the account's failures after a successful authentication. */ +export async function clearLoginLockout(userId: number): Promise { + await clearRateLimit(lockoutKey(userId)); +} diff --git a/src/lib/i18n-client-messages.test.ts b/src/lib/i18n-client-messages.test.ts new file mode 100644 index 00000000..54bbc9d6 --- /dev/null +++ b/src/lib/i18n-client-messages.test.ts @@ -0,0 +1,113 @@ +// @ts-nocheck +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * The public route group installs a reduced catalogue (see + * `publicClientMessages`). Anything a public page asks for must therefore live + * outside the staff-only namespaces — a reference to one of them would silently + * fall back to the raw key, so this is enforced here rather than discovered in + * production. + */ + +const SITE_DIR = join(process.cwd(), "src/app/(site)"); +const STAFF_NAMESPACES = ['useTranslations("admin', 'getTranslations("admin']; + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...sourceFiles(full)); + } else if (/\.(ts|tsx)$/.test(entry)) { + out.push(full); + } + } + return out; +} + +describe("public client message scope", () => { + it("the site route group has pages to check", () => { + expect(sourceFiles(SITE_DIR).length).toBeGreaterThan(10); + }); + + it("no public page reads a staff-only namespace", () => { + const offenders: string[] = []; + for (const file of sourceFiles(SITE_DIR)) { + const src = readFileSync(file, "utf8"); + for (const ns of STAFF_NAMESPACES) { + // `pages.admin.*` is the second form; `admin.*` the first. + for (const needle of [ + `${ns})`, + `${ns}.`, + ns.replace("(admin", "(pages.admin"), + ]) { + if (src.includes(needle)) { + offenders.push(`${file.replace(process.cwd(), "")}: ${needle}`); + } + } + } + } + expect(offenders).toEqual([]); + }); +}); + +describe("publicClientMessages", () => { + async function load() { + const mod = await import("@/lib/i18n-client-messages"); + return mod.publicClientMessages; + } + + it("strips the staff-only namespaces", async () => { + const publicClientMessages = await load(); + const result = publicClientMessages({ + admin: { title: "x" }, + nav: { home: "Home" }, + pages: { + admin: { dashboard: "y" }, + home: { title: "z" }, + }, + }); + expect(result.admin).toBeUndefined(); + expect(result.nav).toEqual({ home: "Home" }); + expect(result.pages.admin).toBeUndefined(); + expect(result.pages.home).toEqual({ title: "z" }); + }); + + it("keeps every non-admin top-level namespace", async () => { + const publicClientMessages = await load(); + const en = JSON.parse( + readFileSync(join(process.cwd(), "src/messages/en.json"), "utf8"), + ); + const result = publicClientMessages(en); + for (const key of Object.keys(en)) { + if (key === "admin") continue; + expect(result[key]).toBeDefined(); + } + expect(result.pages.admin).toBeUndefined(); + }); + + it("actually shrinks the catalogue", async () => { + const publicClientMessages = await load(); + const en = JSON.parse( + readFileSync(join(process.cwd(), "src/messages/en.json"), "utf8"), + ); + const full = JSON.stringify(en).length; + const scoped = JSON.stringify(publicClientMessages(en)).length; + // The staff-tool namespaces dominate the file; without them the payload + // should drop by well over half. + expect(scoped).toBeLessThan(full * 0.5); + }); + + it("does not mutate the input", async () => { + const publicClientMessages = await load(); + const en = JSON.parse( + readFileSync(join(process.cwd(), "src/messages/en.json"), "utf8"), + ); + const before = JSON.stringify(en).length; + publicClientMessages(en); + expect(JSON.stringify(en).length).toBe(before); + expect(en.pages.admin).toBeDefined(); + }); +}); diff --git a/src/lib/i18n-client-messages.ts b/src/lib/i18n-client-messages.ts new file mode 100644 index 00000000..673f7886 --- /dev/null +++ b/src/lib/i18n-client-messages.ts @@ -0,0 +1,53 @@ +import type { AbstractIntlMessages } from "next-intl"; + +/** + * Client-side message scoping. + * + * `getMessages()` hands back the whole catalogue, and the root layout feeds all + * of it to `NextIntlClientProvider`, which serialises every message into the RSC + * payload of every page. The catalogue is ~235 KB of which `pages.admin` and + * `admin` together are ~177 KB — and no public page ever reads either. + * + * Nested `NextIntlClientProvider`s REPLACE the parent's messages rather than + * merging them (see use-intl's IntlProvider: `messages ?? parent.messages`), so + * a segment cannot ask for "just my part". Instead the public layout installs a + * provider seeded with the public subset, which shrinks the payload for every + * public page while leaving /admin, /mod, /client and /admin-next on the full set. + */ + +/** Top-level namespaces plus `pages.*` sub-namespaces reserved for staff tools. */ +const ADMIN_ONLY_NAMESPACES = new Set(["admin"]); +const ADMIN_ONLY_PAGE_NAMESPACES = new Set(["admin"]); + +/** + * Drop the staff-tool namespaces from a full catalogue. + * + * A denylist rather than an allowlist on purpose: a new public page should work + * immediately rather than silently 500 on a missing namespace, and the only + * namespaces that must never reach a public client are the two admin ones. A + * guard test asserts that no component under `app/(site)` references them. + */ +export function publicClientMessages( + messages: AbstractIntlMessages, +): AbstractIntlMessages { + const result: AbstractIntlMessages = {}; + + for (const [key, value] of Object.entries(messages)) { + if (ADMIN_ONLY_NAMESPACES.has(key)) continue; + if (key !== "pages") { + result[key] = value; + continue; + } + + const pages: AbstractIntlMessages = {}; + for (const [pageKey, pageValue] of Object.entries( + value as AbstractIntlMessages, + )) { + if (ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) continue; + pages[pageKey] = pageValue; + } + result.pages = pages; + } + + return result; +} diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts index 7604bfd7..5def78e7 100644 --- a/src/lib/rate-limit.test.ts +++ b/src/lib/rate-limit.test.ts @@ -5,7 +5,7 @@ vi.mock("@/lib/redis", () => ({ redis: null, })); -import { rateLimit } from "./rate-limit"; +import { clearRateLimit, peekRateLimit, rateLimit } from "./rate-limit"; beforeEach(() => { vi.restoreAllMocks(); @@ -46,3 +46,63 @@ describe("rateLimit (in-memory fallback)", () => { expect(a2.ok).toBe(false); }); }); + +describe("peekRateLimit", () => { + it("reports a fresh bucket as available without consuming a unit", async () => { + const key = `peek:fresh:${Date.now()}`; + const first = await peekRateLimit(key, 2, 60_000); + expect(first.ok).toBe(true); + expect(first.retryAfter).toBe(0); + }); + + it("does not consume a unit", async () => { + const key = `peek:noconsume:${Date.now()}`; + await peekRateLimit(key, 2, 60_000); + await peekRateLimit(key, 2, 60_000); + await peekRateLimit(key, 2, 60_000); + // Three peeks, budget of two: still allowed, and count is still 0. + expect((await peekRateLimit(key, 2, 60_000)).ok).toBe(true); + expect((await rateLimit(key, 2, 60_000)).ok).toBe(true); + expect((await rateLimit(key, 2, 60_000)).ok).toBe(true); + expect((await rateLimit(key, 2, 60_000)).ok).toBe(false); + }); + + it("reports a bucket that is already over the limit", async () => { + const key = `peek:over:${Date.now()}`; + await rateLimit(key, 1, 60_000); + expect((await peekRateLimit(key, 1, 60_000)).ok).toBe(true); + await rateLimit(key, 1, 60_000); + const res = await peekRateLimit(key, 1, 60_000); + expect(res.ok).toBe(false); + expect(res.retryAfter).toBeGreaterThan(0); + }); + + it("reports an expired bucket as available again", async () => { + const key = `peek:expired:${Date.now()}`; + await rateLimit(key, 1, 50); + await rateLimit(key, 1, 50); + expect((await peekRateLimit(key, 1, 50)).ok).toBe(false); + await new Promise((r) => setTimeout(r, 60)); + expect((await peekRateLimit(key, 1, 50)).ok).toBe(true); + }); +}); + +describe("clearRateLimit", () => { + it("empties the bucket so the limit is fully available again", async () => { + const key = `clear:basic:${Date.now()}`; + expect((await rateLimit(key, 2, 60_000)).ok).toBe(true); + expect((await rateLimit(key, 2, 60_000)).ok).toBe(true); + expect((await rateLimit(key, 2, 60_000)).ok).toBe(false); + + await clearRateLimit(key); + + expect((await peekRateLimit(key, 2, 60_000)).ok).toBe(true); + expect((await rateLimit(key, 2, 60_000)).ok).toBe(true); + }); + + it("is a no-op on an unknown key", async () => { + await expect( + clearRateLimit(`clear:missing:${Date.now()}`), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index db0b5947..47ffb691 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -85,10 +85,14 @@ export async function rateLimit( if (!bucket || now >= bucket.resetAt) { buckets.set(windowKey, { count: 1, resetAt: now + windowMs }); - return { ok: true, retryAfter: 0 }; + return { ok: limit >= 1, retryAfter: 0 }; } + // The counter keeps climbing past the limit, matching Redis' INCR. Capping + // it here would make the two backends disagree about how far over the limit + // a key is, which breaks read-only consumers such as the login lockout. const newCount = bucket.count + 1; + bucket.count = newCount; if (newCount > limit) { return { ok: false, @@ -96,10 +100,70 @@ export async function rateLimit( }; } - bucket.count = newCount; return { ok: true, retryAfter: 0 }; } +/** + * Read-only peek at a bucket — how it would judge the next call WITHOUT + * consuming a unit. + * + * The login lockout needs this: it has to know whether an account is already + * locked *before* the password is verified. Verifying first would clear the + * counter on the success path, handing a locked account a session the moment + * the right password turns up. + */ +export async function peekRateLimit( + key: string, + limit: number, + windowMs: number, +): Promise { + if (redis) { + try { + const [countRaw, ttl] = (await redis.eval( + `local c = redis.call('GET', KEYS[1]) + if not c then c = 0 end + local t = redis.call('PTTL', KEYS[1]) + if t < 0 then t = tonumber(ARGV[1]) end + return {c, t}`, + 1, + `ratelimit:${key}`, + String(windowMs), + )) as [string | null, number]; + const count = Number(countRaw ?? 0); + return { + ok: count <= limit, + retryAfter: Math.max(1, Math.ceil(ttl / 1000)), + }; + } catch { + // Redis unavailable — fall through to the in-process bucket. + } + } + + const bucket = buckets.get(`mem:${key}`); + if (!bucket || Date.now() >= bucket.resetAt) { + return { ok: true, retryAfter: 0 }; + } + return { + ok: bucket.count <= limit, + retryAfter: Math.max(1, Math.ceil((bucket.resetAt - Date.now()) / 1000)), + }; +} + +/** + * Drop a bucket entirely, both in Redis and in-process. Required wherever a + * successful action must reset a counter — a failed-attempt budget is only + * usable if a success clears it. + */ +export async function clearRateLimit(key: string): Promise { + buckets.delete(`mem:${key}`); + if (!redis) return; + try { + await redis.del(`ratelimit:${key}`); + } catch { + // Best effort: a stale key only re-arms the lockout for its own TTL. + } +} + export async function clientIp(): Promise { try { return resolveClientIp(await headers()); diff --git a/src/messages/ar.json b/src/messages/ar.json index f09b417b..14f2de85 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -6087,6 +6087,7 @@ "errorInvalid2fa": "رمز التحقق بخطوتين غير صالح", "errorUnverified": "يرجى التحقق من بريدك الإلكتروني قبل تسجيل الدخول.", "errorCaptcha": "فشل التحقق من الكابتشا. حاول مرة أخرى.", + "errorLocked": "عدد كبير جدًا من المحاولات الفاشلة. حاول مرة أخرى لاحقًا.", "whoIsOnline": "من متصل الآن", "newestCitizens": "أحدث السكان", "usersOnline": "{count} متصل", @@ -6203,7 +6204,8 @@ "resendEmail": "البريد الإلكتروني", "resendButton": "إرسال رابط جديد", "resendSent": "إذا كان لهذا العنوان حساب، فسيصل رابط تحقق جديد قريبًا — تحقق من بريدك الوارد.", - "resendFailed": "تعذّر إرسال رابط جديد. حاول مرة أخرى بعد بضع دقائق." + "resendFailed": "تعذّر إرسال رابط جديد. حاول مرة أخرى بعد بضع دقائق.", + "resendCaptchaFailed": "تعذّر إرسال رابط جديد. تحقّق من الكابتشا ثم حاول مجددًا." }, "banned": { "title": "You are banned", diff --git a/src/messages/bg.json b/src/messages/bg.json index 37188153..45b638ae 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Влезте, за да продължите към {hotelName}", "errorUnverified": "Моля, потвърдете имейла си, преди да влезете.", "errorCaptcha": "Проверката на капчата е неуспешна. Опитайте отново.", + "errorLocked": "Твърде много неуспешни опита. Опитайте отново по-късно.", "whoIsOnline": "Кой е на линия", "newestCitizens": "Най-нови граждани", "usersOnline": "{count} на линия", @@ -958,7 +959,8 @@ "resendEmail": "Имейл адрес", "resendButton": "Изпрати нова вързка", "resendSent": "Ако този адрес има акаунт, нова вързка за потвърждение е на път — проверете пощата си.", - "resendFailed": "Не можахме да изпратим нова вързка. Опитайте отново след няколко минути." + "resendFailed": "Не можахме да изпратим нова вързка. Опитайте отново след няколко минути.", + "resendCaptchaFailed": "Не може да се изпрати нов линк. Проверете капчата и опитайте отново." }, "banned": { "title": "Вие сте забранени", diff --git a/src/messages/cs.json b/src/messages/cs.json index 011376ef..445123e5 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Přihlas se pro pokračování do {hotelName}", "errorUnverified": "Před přihlášením ověřte svou e-mailovou adresu.", "errorCaptcha": "Ověření captcha selhalo. Zkuste to znovu.", + "errorLocked": "Příliš mnoho neúspěšných pokusů. Zkuste to prosím později.", "whoIsOnline": "Kdo je online", "newestCitizens": "Nejnovější občané", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "E-mailová adresa", "resendButton": "Odeslat nový odkaz", "resendSent": "Pokud má tento e-mail účet, nový ověřovací odkaz je na cestě — zkontrolujte schránku.", - "resendFailed": "Nový odkaz se nepodařilo odeslat. Zkuste to znovu za několik minut." + "resendFailed": "Nový odkaz se nepodařilo odeslat. Zkuste to znovu za několik minut.", + "resendCaptchaFailed": "Nový odkaz se nepodařilo odeslat. Ověřte captchu a zkuste to znovu." }, "banned": { "title": "Máte zákaz", diff --git a/src/messages/da.json b/src/messages/da.json index 71cc63d9..2de149b8 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Log ind for at fortsætte til {hotelName}", "errorUnverified": "Bekræft din e-mail, før du logger ind.", "errorCaptcha": "Captcha-verificering mislykkedes. Prøv igen.", + "errorLocked": "For mange mislykkede forsøg. Prøv igen senere.", "whoIsOnline": "Hvem er online", "newestCitizens": "Nyeste borgere", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "E-mailadresse", "resendButton": "Send nyt link", "resendSent": "Hvis den adresse har en konto, er et nyt bekræftelseslink på vej — tjek din indbakke.", - "resendFailed": "Det var ikke muligt at sende et nyt link. Prøv igen om et par minutter." + "resendFailed": "Det var ikke muligt at sende et nyt link. Prøv igen om et par minutter.", + "resendCaptchaFailed": "Kunne ikke sende et nyt link. Kontrollér captchaen og prøv igen." }, "banned": { "title": "Du er bandlyst", diff --git a/src/messages/de.json b/src/messages/de.json index 95ea0bfa..dc321a46 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -812,6 +812,7 @@ "welcomeBackSub": "Melde dich an, um zu {hotelName} zu gelangen", "errorUnverified": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.", "errorCaptcha": "Captcha-Verifizierung fehlgeschlagen. Bitte erneut versuchen.", + "errorLocked": "Zu viele fehlgeschlagene Versuche. Bitte später erneut versuchen.", "whoIsOnline": "Wer ist online", "newestCitizens": "Neueste Bürger", "usersOnline": "{count} online", @@ -928,7 +929,8 @@ "resendEmail": "E-Mail-Adresse", "resendButton": "Neuen Link senden", "resendSent": "Wenn zu dieser Adresse ein Konto gehört, ist ein neuer Bestätigungslink unterwegs — prüf dein Postfach.", - "resendFailed": "Der neue Link konnte nicht gesendet werden. Versuche es in einigen Minuten erneut." + "resendFailed": "Der neue Link konnte nicht gesendet werden. Versuche es in einigen Minuten erneut.", + "resendCaptchaFailed": "Der konnte kein neuer Link gesendet werden. Bitte Captcha prüfen und erneut versuchen." }, "banned": { "title": "Du bist gebannt", diff --git a/src/messages/el.json b/src/messages/el.json index 83b4168e..890db532 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Συνδέσου για να συνεχίσεις στο {hotelName}", "errorUnverified": "Επαληθεύστε το email σας πριν συνδεθείτε.", "errorCaptcha": "Η επαλήθευση captcha απέτυχε. Δοκιμάστε ξανά.", + "errorLocked": "Πάρα πολλές αποτυχημένες προσπάθειες. Δοκιμάστε ξανά αργότερα.", "whoIsOnline": "Ποιος είναι σε σύνδεση", "newestCitizens": "Νέοι πολίτες", "usersOnline": "{count} σε σύνδεση", @@ -958,7 +959,8 @@ "resendEmail": "Διεύθυνση email", "resendButton": "Αποστολή νέου συνδέσμου", "resendSent": "Αν αυτή η διεύθυνση έχει λογαριασμό, ένας νέος σύνδεσμος επαλήθευσης είναι καθ' οδόν — ελέγξτε τα εισερχόμενά σας.", - "resendFailed": "Δεν ήταν δυνατή η αποστολή νέου συνδέσμου. Δοκιμάστε ξανά σε λίγα λεπτά." + "resendFailed": "Δεν ήταν δυνατή η αποστολή νέου συνδέσμου. Δοκιμάστε ξανά σε λίγα λεπτά.", + "resendCaptchaFailed": "Δεν ήταν δυνατή η αποστολή νέου συνδέσμου. Ελέγξτε το captcha και δοκιμάστε ξανά." }, "banned": { "title": "Είστε απαγορευμένοι", diff --git a/src/messages/en.json b/src/messages/en.json index 0f85b668..e8dac669 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1014,6 +1014,7 @@ "errorInvalid2fa": "Invalid 2FA code", "errorUnverified": "Please verify your email before signing in.", "errorCaptcha": "Captcha verification failed. Please try again.", + "errorLocked": "Too many failed sign-in attempts. Please try again later.", "whoIsOnline": "Who's online", "newestCitizens": "Newest citizens", "usersOnline": "{count} online", @@ -1130,7 +1131,8 @@ "resendEmail": "Email address", "resendButton": "Send new link", "resendSent": "If that address has an account, a new verification link is on its way — check your inbox.", - "resendFailed": "Couldn’t send a new link. Wait a few minutes and try again." + "resendFailed": "Couldn’t send a new link. Wait a few minutes and try again.", + "resendCaptchaFailed": "Couldn’t send a new link. Please complete the captcha and try again." }, "banned": { "title": "You are banned", diff --git a/src/messages/es.json b/src/messages/es.json index bdb69363..046359fa 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -812,6 +812,7 @@ "welcomeBackSub": "Inicia sesión para continuar en {hotelName}", "errorUnverified": "Verifica tu correo electrónico antes de iniciar sesión.", "errorCaptcha": "La verificación captcha falló. Inténtalo de nuevo.", + "errorLocked": "Demasiados intentos de inicio de sesión fallidos. Inténtalo de nuevo más tarde.", "whoIsOnline": "Quién está en línea", "newestCitizens": "Ciudadanos más recientes", "usersOnline": "{count} en línea", @@ -928,7 +929,8 @@ "resendEmail": "Dirección de correo electrónico", "resendButton": "Enviar nuevo enlace", "resendSent": "Si esa dirección tiene una cuenta, un nuevo enlace de verificación está en camino: revisa tu bandeja de entrada.", - "resendFailed": "No se pudo enviar un nuevo enlace. Vuelve a intentarlo en unos minutos." + "resendFailed": "No se pudo enviar un nuevo enlace. Vuelve a intentarlo en unos minutos.", + "resendCaptchaFailed": "No se pudo enviar un nuevo enlace. Completa el captcha e inténtalo de nuevo." }, "banned": { "title": "Has sido baneado", diff --git a/src/messages/fi.json b/src/messages/fi.json index f14edf32..f4f8225f 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -6087,6 +6087,7 @@ "errorInvalid2fa": "Virheellinen 2FA-koodi", "errorUnverified": "Vahvista sähköpostiosoitteesi ennen kirjautumista.", "errorCaptcha": "Captcha-varmennus epäonnistui. Yritä uudelleen.", + "errorLocked": "Liian monta epäonnistunutta kirjautumisyritystä. Yritä myöhemmin uudelleen.", "whoIsOnline": "Ketkä ovat paikalla", "newestCitizens": "Uusimmat asukkaat", "usersOnline": "{count} paikalla", @@ -6203,7 +6204,8 @@ "resendEmail": "Sähköpostiosoite", "resendButton": "Lähetä uusi linkki", "resendSent": "Jos kyseiseen osoitteeseen on liitetty tili, uusi vahvistuslinkki on matkalla — tarkista sähköpostisi.", - "resendFailed": "Uutta linkkiä ei voitu lähettää. Yritä uudelleen muutaman minuutin kuluttua." + "resendFailed": "Uutta linkkiä ei voitu lähettää. Yritä uudelleen muutaman minuutin kuluttua.", + "resendCaptchaFailed": "Uutta linkkiä ei voitu lähettää. Tarkista captcha ja yritä uudelleen." }, "banned": { "title": "You are banned", diff --git a/src/messages/fr.json b/src/messages/fr.json index a30c2ca2..facb055e 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -812,6 +812,7 @@ "welcomeBackSub": "Connecte-toi pour continuer vers {hotelName}", "errorUnverified": "Veuillez vérifier votre e-mail avant de vous connecter.", "errorCaptcha": "La vérification captcha a échoué. Réessayez.", + "errorLocked": "Trop de tentatives de connexion échouées. Réessayez plus tard.", "whoIsOnline": "Qui est en ligne", "newestCitizens": "Nouveaux citoyens", "usersOnline": "{count} en ligne", @@ -928,7 +929,8 @@ "resendEmail": "Adresse e-mail", "resendButton": "Envoyer un nouveau lien", "resendSent": "Si cette adresse possède un compte, un nouveau lien de vérification est en route — vérifiez votre boîte mail.", - "resendFailed": "Impossible d’envoyer un nouveau lien. Réessayez dans quelques minutes." + "resendFailed": "Impossible d’envoyer un nouveau lien. Réessayez dans quelques minutes.", + "resendCaptchaFailed": "Impossible d’envoyer un nouveau lien. Vérifiez le captcha et réessayez." }, "banned": { "title": "Vous êtes banni", diff --git a/src/messages/hr.json b/src/messages/hr.json index b391a53f..928840e9 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Prijavi se za nastavak u {hotelName}", "errorUnverified": "Potvrdite svoju e-poštu prije prijave.", "errorCaptcha": "Captcha verifikacija nije uspjela. Pokušajte ponovno.", + "errorLocked": "Previše neuspjelih pokušaja. Pokušajte ponovo kasnije.", "whoIsOnline": "Tko je na mreži", "newestCitizens": "Najnoviji građani", "usersOnline": "{count} na mreži", @@ -958,7 +959,8 @@ "resendEmail": "Adresa e-pošte", "resendButton": "Pošalji novu poveznicu", "resendSent": "Ako ta adresa ima račun, nova poveznica za verifikaciju je na putu — provjerite poštanski sandučić.", - "resendFailed": "Novu poveznicu nije bilo moguće poslati. Pokušajte ponovo za nekoliko minuta." + "resendFailed": "Novu poveznicu nije bilo moguće poslati. Pokušajte ponovo za nekoliko minuta.", + "resendCaptchaFailed": "Nije moguće poslati novu vezu. Provjerite captcha i pokušajte ponovno." }, "banned": { "title": "Zabranjeni ste", diff --git a/src/messages/hu.json b/src/messages/hu.json index 2c462569..ee4ba54d 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Jelentkezz be a {hotelName} folytatáshoz", "errorUnverified": "Kérlek, erősítsd meg az e-mail-címedet bejelentkezés előtt.", "errorCaptcha": "A captcha-ellenőrzés sikertelen. Próbáld újra.", + "errorLocked": "Túl sok sikertelen bejelentkezési kísérlet. Próbálja újra később.", "whoIsOnline": "Ki van online", "newestCitizens": "Legújabb lakosok", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "E-mail cím", "resendButton": "Új link küldése", "resendSent": "Ha ehhez a címhez tartozik fiók, új megerősítő link útban van — nézze meg a postaládáját.", - "resendFailed": "Az új linket nem sikerült elküldeni. Próbálja újra néhány perc múlva." + "resendFailed": "Az új linket nem sikerült elküldeni. Próbálja újra néhány perc múlva.", + "resendCaptchaFailed": "Nem sikerült új linket küldeni. Ellenőrizze a captchát, majd próbálja újra." }, "banned": { "title": "Ki vagy tiltva", diff --git a/src/messages/it.json b/src/messages/it.json index a8990c96..9f731b05 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -982,6 +982,7 @@ "errorInvalid2fa": "Codice 2FA non valido", "errorUnverified": "Verifica il tuo indirizzo email prima di accedere.", "errorCaptcha": "Verifica captcha non riuscita. Riprova.", + "errorLocked": "Troppi tentativi di accesso non riusciti. Riprova più tardi.", "welcomeBack": "Bentornato", "welcomeBackSub": "Accedi per continuare su {hotelName}", "whoIsOnline": "Chi è online", @@ -1100,7 +1101,8 @@ "resendEmail": "Indirizzo e-mail", "resendButton": "Invia nuovo link", "resendSent": "Se quell'indirizzo ha un account, un nuovo link di verifica è in arrivo: controlla la casella.", - "resendFailed": "Impossibile inviare un nuovo link. Riprova tra qualche minuto." + "resendFailed": "Impossibile inviare un nuovo link. Riprova tra qualche minuto.", + "resendCaptchaFailed": "Impossibile inviare un nuovo link. Verifica il captcha e riprova." }, "banned": { "title": "Sei stato bannato", diff --git a/src/messages/ja.json b/src/messages/ja.json index ad27ab90..13cb4fdd 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -6087,6 +6087,7 @@ "errorInvalid2fa": "2FAコードが正しくありません", "errorUnverified": "ログインする前にメールアドレスを認証してください。", "errorCaptcha": "認証コードの検証に失敗しました。再度お試しください。", + "errorLocked": "ログインの失敗回数が多すぎます。しばらくしてから再度お試しください。", "whoIsOnline": "オンラインの人", "newestCitizens": "最新の住民", "usersOnline": "{count} 人がオンライン", @@ -6203,7 +6204,8 @@ "resendEmail": "メールアドレス", "resendButton": "新しいリンクを送信", "resendSent": "そのアドレスにアカウントがあれば、新しい確認リンクが届くはずです。受信トレイをご確認ください。", - "resendFailed": "新しいリンクを送信できませんでした。数分後にもう一度お試しください。" + "resendFailed": "新しいリンクを送信できませんでした。数分後にもう一度お試しください。", + "resendCaptchaFailed": "新しいリンクを送信できませんでした。reCAPTCHA を確認して再度お試しください。" }, "banned": { "title": "You are banned", diff --git a/src/messages/nl.json b/src/messages/nl.json index 4c31a514..4c2323c6 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1012,6 +1012,7 @@ "errorInvalid2fa": "Ongeldige 2FA-code", "errorUnverified": "Verifieer je e-mail voordat je inlogt.", "errorCaptcha": "Captcha-verificatie mislukt. Probeer het opnieuw.", + "errorLocked": "Te veel mislukte inlogpogingen. Probeer het later opnieuw.", "welcomeBack": "Welkom terug", "welcomeBackSub": "Log in om verder te gaan naar {hotelName}", "whoIsOnline": "Wie is online", @@ -1130,7 +1131,8 @@ "resendEmail": "E-mailadres", "resendButton": "Nieuwe link versturen", "resendSent": "Als dit adres een account heeft, is er een nieuwe verificatielink onderweg — check je inbox.", - "resendFailed": "De nieuwe link kon niet worden verstuurd. Probeer het over een paar minuten opnieuw." + "resendFailed": "De nieuwe link kon niet worden verstuurd. Probeer het over een paar minuten opnieuw.", + "resendCaptchaFailed": "Kon geen nieuwe link versturen. Voltooi de captcha en probeer het opnieuw." }, "banned": { "title": "Je bent verbannen", diff --git a/src/messages/no.json b/src/messages/no.json index 57021f7c..ca568221 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Logg inn for å fortsette til {hotelName}", "errorUnverified": "Bekreft e-postadressen din før du logger inn.", "errorCaptcha": "Captcha-verifisering mislyktes. Prøv igjen.", + "errorLocked": "For mange mislykkede påloggingsforsøk. Prøv igjen senere.", "whoIsOnline": "Hvem er påloggede", "newestCitizens": "Nyeste innbyggere", "usersOnline": "{count} påloggede", @@ -958,7 +959,8 @@ "resendEmail": "E-postadresse", "resendButton": "Send ny lenke", "resendSent": "Hvis den adressen har en konto, er en ny verifiseringslenke på vei — sjekk innboksen.", - "resendFailed": "Kunne ikke sende en ny lenke. Prøv igjen om noen minutter." + "resendFailed": "Kunne ikke sende en ny lenke. Prøv igjen om noen minutter.", + "resendCaptchaFailed": "Kunne ikke sende en ny lenke. Kontroller captchaen og prøv igjen." }, "banned": { "title": "Du er utestengt", diff --git a/src/messages/pl.json b/src/messages/pl.json index e4f68048..38bbd266 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Zaloguj się, aby kontynuować w {hotelName}", "errorUnverified": "Potwierdź swój e-mail przed zalogowaniem.", "errorCaptcha": "Weryfikacja captcha nie powiodła się. Spróbuj ponownie.", + "errorLocked": "Zbyt wiele nieudanych prób logowania. Spróbuj ponownie później.", "whoIsOnline": "Kto jest online", "newestCitizens": "Najnowsi mieszkańcy", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "Adres e-mail", "resendButton": "Wyślij nowy link", "resendSent": "Jeśli ten adres ma konto, nowy link weryfikacyjny jest już w drodze — sprawdź skrzynkę odbiorczą.", - "resendFailed": "Nie udało się wysłać nowego linku. Spróbuj ponownie za kilka minut." + "resendFailed": "Nie udało się wysłać nowego linku. Spróbuj ponownie za kilka minut.", + "resendCaptchaFailed": "Nie udało się wysłać nowego linku. Zweryfikuj captchę i spróbuj ponownie." }, "banned": { "title": "Masz zakaz", diff --git a/src/messages/pt.json b/src/messages/pt.json index 4f1a415b..4fc2dbdc 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Entre para continuar em {hotelName}", "errorUnverified": "Verifique seu e-mail antes de entrar.", "errorCaptcha": "A verificação captcha falhou. Tente novamente.", + "errorLocked": "Demasiadas tentativas de início de sessão falhadas. Tente novamente mais tarde.", "whoIsOnline": "Quem está online", "newestCitizens": "Cidadãos mais recentes", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "Endereço de e-mail", "resendButton": "Enviar novo link", "resendSent": "Se esse endereço tiver uma conta, um novo link de verificação estará a caminho — confira a sua caixa de entrada.", - "resendFailed": "Não foi possível enviar um novo link. Tente novamente daqui a alguns minutos." + "resendFailed": "Não foi possível enviar um novo link. Tente novamente daqui a alguns minutos.", + "resendCaptchaFailed": "Não foi possível enviar um novo link. Conclua o captcha e tente novamente." }, "banned": { "title": "Você está banido", diff --git a/src/messages/ro.json b/src/messages/ro.json index c6543381..eb644434 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Autentifică-te pentru a continua pe {hotelName}", "errorUnverified": "Te rugăm să îți verifici e-mailul înainte de a te autentifica.", "errorCaptcha": "Verificarea captcha a eșuat. Încearcă din nou.", + "errorLocked": "Prea multe încercări de autentificare eșuate. Încearcă din nou mai târziu.", "whoIsOnline": "Cine este online", "newestCitizens": "Cei mai noi locuitori", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "Adresă de e-mail", "resendButton": "Trimite un nou link", "resendSent": "Dacă acestă adresă are un cont, un nou link de verificare este pe drum — verifică căsuța.", - "resendFailed": "Nu s-a putut trimite un nou link. Încearcă din nou în câteva minute." + "resendFailed": "Nu s-a putut trimite un nou link. Încearcă din nou în câteva minute.", + "resendCaptchaFailed": "Nu s-a putut trimite un link nou. Verifică captcha și încearcă din nou." }, "banned": { "title": "Esti interzis", diff --git a/src/messages/ru.json b/src/messages/ru.json index fa7c8eb1..eee5a28a 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -840,6 +840,7 @@ "welcomeBackSub": "Войди, чтобы продолжить в {hotelName}", "errorUnverified": "Подтвердите адрес электронной почты перед входом.", "errorCaptcha": "Проверка captcha не пройдена. Попробуйте ещё раз.", + "errorLocked": "Слишком много неудачных попыток входа. Повторите попытку позже.", "whoIsOnline": "Кто в сети", "newestCitizens": "Новые жители", "usersOnline": "{count} в сети", @@ -956,7 +957,8 @@ "resendEmail": "Адрес электронной почты", "resendButton": "Отправить новую ссылку", "resendSent": "Если к этому адресу привязан аккаунт, новая ссылка уже в пути — проверьте почту.", - "resendFailed": "Не удалось отправить новую ссылку. Попробуйте еще раз через несколько минут." + "resendFailed": "Не удалось отправить новую ссылку. Попробуйте еще раз через несколько минут.", + "resendCaptchaFailed": "Не удалось отправить новую ссылку. Пройдите проверку captcha и повторите." }, "banned": { "title": "Вы заблокированы", diff --git a/src/messages/sk.json b/src/messages/sk.json index e6c261dc..6d434b3a 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Prihlás sa pre pokračovanie do {hotelName}", "errorUnverified": "Pred prihlásením overte svoju e-mailovú adresu.", "errorCaptcha": "Overenie captcha zlyhalo. Skúste to znova.", + "errorLocked": "Príliš veľa neúspešných pokusov. Skúste to neskôr.", "whoIsOnline": "Kto je online", "newestCitizens": "Najnovší obyvatelia", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "E-mailová adresa", "resendButton": "Odoslať nový odkaz", "resendSent": "Ak má tento e-mail účet, nový overovací odkaz je na ceste — skontrolujte schránku.", - "resendFailed": "Nový odkaz sa nepodarilo odoslať. Skúste to znova o niekoľko minút." + "resendFailed": "Nový odkaz sa nepodarilo odoslať. Skúste to znova o niekoľko minút.", + "resendCaptchaFailed": "Nepodarilo sa odoslať nový odkaz. Overte captchu a skúste to znova." }, "banned": { "title": "Máte zákaz", diff --git a/src/messages/sr.json b/src/messages/sr.json index df6de161..556eabf6 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Пријави се да наставиш ка {hotelName}", "errorUnverified": "Потврдите имејл пре пријаве.", "errorCaptcha": "Провера капче није успела. Покушајте поново.", + "errorLocked": "Превише неуспелих покушаја. Покушајте поново касније.", "whoIsOnline": "Ко је на мрежи", "newestCitizens": "Најновији грађани", "usersOnline": "{count} на мрежи", @@ -958,7 +959,8 @@ "resendEmail": "Имејл адреса", "resendButton": "Пошаљи нову везу", "resendSent": "Ако за ту адресу постоји налог, нова веза за верификацију је на путу — проверите пошту.", - "resendFailed": "Нова веза није могла бити послата. Покушајте поново за неколико минута." + "resendFailed": "Нова веза није могла бити послата. Покушајте поново за неколико минута.", + "resendCaptchaFailed": "Nije moguće poslati novu vezu. Proverite captcha i pokušajte ponovo." }, "banned": { "title": "Забрањени сте", diff --git a/src/messages/sv.json b/src/messages/sv.json index f3ee6130..15f0b2a1 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Logga in för att fortsätta till {hotelName}", "errorUnverified": "Bekräfta din e-postadress innan du loggar in.", "errorCaptcha": "Captcha-verifieringen misslyckades. Försök igen.", + "errorLocked": "För många misslyckade inloggningsförsök. Försök igen senare.", "whoIsOnline": "Vem är online", "newestCitizens": "Nyaste medborgare", "usersOnline": "{count} online", @@ -958,7 +959,8 @@ "resendEmail": "E-postadress", "resendButton": "Skicka ny länk", "resendSent": "Om den adressen har ett konto är en ny verifieringslänk på väg — kontrollera din inkorg.", - "resendFailed": "Det gick inte att skicka en ny länk. Försök igen om några minuter." + "resendFailed": "Det gick inte att skicka en ny länk. Försök igen om några minuter.", + "resendCaptchaFailed": "Det gick inte att skicka en ny länk. Kontrollera captcha och försök igen." }, "banned": { "title": "Du är förbjuden", diff --git a/src/messages/tr.json b/src/messages/tr.json index 6060ccbc..c6a8c9d9 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -842,6 +842,7 @@ "welcomeBackSub": "{hotelName} devam etmek için giriş yap", "errorUnverified": "Giriş yapmadan önce lütfen e-posta adresinizi doğrulayın.", "errorCaptcha": "Captcha doğrulaması başarısız oldu. Tekrar deneyin.", + "errorLocked": "Çok fazla başarısız giriş denemesi. Lütfen daha sonra tekrar deneyin.", "whoIsOnline": "Kimler çevrimiçi", "newestCitizens": "En yeni vatandaşlar", "usersOnline": "{count} çevrimiçi", @@ -958,7 +959,8 @@ "resendEmail": "E-posta adresi", "resendButton": "Yeni bağlantı gönder", "resendSent": "Bu adrese bağlı bir hesap varsa yeni bir doğrulama bağlantısı yolda — gelen kutunuzu kontrol edin.", - "resendFailed": "Yeni bağlantı gönderilemedi. Birkaç dakika sonra tekrar deneyin." + "resendFailed": "Yeni bağlantı gönderilemedi. Birkaç dakika sonra tekrar deneyin.", + "resendCaptchaFailed": "Yeni bir bağlantı gönderilemedi. Lütfen captcha'yı doğrulayıp tekrar deneyin." }, "banned": { "title": "Yasaklandın", diff --git a/src/messages/uk.json b/src/messages/uk.json index d602f737..647cbd4e 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -842,6 +842,7 @@ "welcomeBackSub": "Увійди, щоб продовжити в {hotelName}", "errorUnverified": "Підтвердьте свою електронну адресу перед входом.", "errorCaptcha": "Перевірка captcha не вдалася. Спробуйте ще раз.", + "errorLocked": "Забагато невдалих спроб входу. Повторіть спробу пізніше.", "whoIsOnline": "Хто в мережі", "newestCitizens": "Нові мешканці", "usersOnline": "{count} у мережі", @@ -958,7 +959,8 @@ "resendEmail": "Адреса електронної пошти", "resendButton": "Надіслати нове посилання", "resendSent": "Якщо до цієї адреси прив'язаний аккаунт, нове посилання вже в дорозі — перевірте пошту.", - "resendFailed": "Не вдалося надіслати нове посилання. Спробуйте ще раз через кілька хвилин." + "resendFailed": "Не вдалося надіслати нове посилання. Спробуйте ще раз через кілька хвилин.", + "resendCaptchaFailed": "Не вдалося надіслати нове посилання. Пройдіть перевірку captcha та спробуйте ще раз." }, "banned": { "title": "Ви забанені",