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": "Ви забанені",