feat: per-account login lockout, mail index, resend captcha, i18n scoping
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m45s
CI / tests-ui (push) Successful in 2m29s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

Closes the four HIGH/MEDIUM items left open after the previous pass.

Login lockout
- The only login limits were keyed on the client IP, so a distributed attempt
  could grind on one account indefinitely. Added a per-account lockout with a
  budget of 8 failures per 15 minutes.
- The bucket is keyed on the RESOLVED account id, not on the submitted string:
  users may sign in with either username or e-mail and neither the lookup nor
  the input normaliser folds case, so an input-keyed bucket would hand out a
  fresh budget per spelling of the same account.
- precheckLogin and NextAuth's authorize share the bucket, so the pre-check
  cannot be used to buy extra attempts and a client that skips it entirely is
  still bounded. Both check the lockout BEFORE verifying the password: the
  success path clears the counter, which would otherwise walk a locked account
  straight back in on the right password.
- A successful login clears the failures, which needs two new primitives in
  rate-limit.ts: peekRateLimit (read-only, does not consume a unit) and
  clearRateLimit.
- Fixed a latent inconsistency while doing so: the in-process bucket capped its
  counter at the limit while Redis' INCR kept climbing, so the two backends
  disagreed about how far over the limit a key was. Both now track the true
  count.

Mail lookup index
- Added an index on users.mail (0035). Password reset, e-mail verification and
  the resend cooldown all resolve a single account from a submitted address and
  were full table scans of `users`. Deliberately non-unique: legacy rows can
  hold the same address more than once, so a unique index would fail to apply.

Resend captcha
- /verify's resend form triggers real outbound mail and was reachable with only
  a cooldown. It now runs the configured captcha before the account lookup and
  before any send.

Client message payload
- The root layout serialised the whole catalogue into every page. pages.admin
  and admin are ~177 KB of the ~235 KB and are unreachable from the public route
  group, so that layout now installs its own provider with the staff namespaces
  removed. Nested providers replace rather than merge, which is why this has to
  live in the segment layout. /admin, /mod, /client and /admin-next keep the
  full set; a guard test fails if a public page ever references a staff
  namespace.
This commit is contained in:
openhands committed 2026-10-09 17:12:50 +02:00
1 parent 6cc45d7413
commit 179484642f
41 files changed
+863 -42

No files matched your search

@@ -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));
+65
View File
@@ -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();
});
});
+18 -2
View File
@@ -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";
+186
View File
@@ -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<Record<string, unknown>>,
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<string, string>) => {
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: "[email protected]" }),
);
expect(res).toEqual({ ok: true, error: null });
expect(state.sendVerification).toHaveBeenCalledWith("[email protected]");
});
it("answers identically for an unknown address so it cannot be probed", async () => {
state.rows = [];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
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: "[email protected]" }));
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: "[email protected]" }));
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: "[email protected]" }));
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: "[email protected]" }));
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: "[email protected]" }));
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: "[email protected]",
"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: "[email protected]", "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("[email protected]");
});
it("checks the captcha before the account lookup", async () => {
state.verifyCaptcha.mockResolvedValue(false);
state.rows = [{ id: 5, mailVerified: "0" }];
await resendVerification(prev, form({ email: "[email protected]" }));
const order: string[] = [];
state.verifyCaptcha.mockImplementation(async () => {
order.push("captcha");
return false;
});
await resendVerification(prev, form({ email: "[email protected]" }));
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: "[email protected]" }));
expect(res).toEqual({ ok: true, error: null });
expect(state.verifyCaptcha).not.toHaveBeenCalled();
});
});
+16 -1
View File
@@ -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 })
+16 -3
View File
@@ -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 (
<>
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<CloudsField />
{/* 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({
<div data-theme-block="radio_player">
<RadioPlayerGate />
</div>
</>
</NextIntlClientProvider>
);
}
+9 -2
View File
@@ -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<Metadata> {
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({
</p>
{/* Transient failure: offer both the retry path and the way out
instead of leaving the visitor stranded on this card. */}
<ResendVerificationForm />
<ResendVerificationForm captcha={cfg} nonce={nonce} />
<Link
href="/login"
className={`${linkClass} !shadow-none`}
@@ -215,7 +222,7 @@ export default async function VerifyPage({
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{t("invalidBody")}
</p>
<ResendVerificationForm />
<ResendVerificationForm captcha={cfg} nonce={nonce} />
<Link
href="/login"
className={`${linkClass} !shadow-none`}
+10 -2
View File
@@ -50,12 +50,15 @@ export function LoginForm({
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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({
<button
type="submit"
disabled={pending}
disabled={pending || locked}
className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
>
{pending ? (
@@ -7,6 +7,10 @@ import {
type ResendVerificationState,
resendVerification,
} from "@/actions/verify";
import {
type CaptchaPublicConfig,
CaptchaWidget,
} from "@/components/auth/captcha-widget";
const INITIAL_STATE: ResendVerificationState = { ok: false, error: null };
@@ -14,9 +18,15 @@ const INITIAL_STATE: ResendVerificationState = { ok: false, error: null };
* Lets a visitor whose verification token expired or was mangled request a
* fresh mail straight from the invalid card of /verify. The server answers
* identically for unknown addresses, so no registered address can be probed;
* the live feedback is limited to "we tried" vs "throttled".
* the live feedback is limited to "we tried" vs "throttled" / "captcha".
*/
export function ResendVerificationForm() {
export function ResendVerificationForm({
captcha = { provider: "none" },
nonce,
}: {
captcha?: CaptchaPublicConfig;
nonce?: string;
} = {}) {
const t = useTranslations("pages.verify");
const fieldId = useId();
const [state, formAction, isPending] = useActionState(
@@ -35,7 +45,9 @@ export function ResendVerificationForm() {
</p>
) : state.error ? (
<p role="alert" className="auth-alert m-0 animate-fade-in-up">
{t("resendFailed")}
{state.error === "captcha"
? t("resendCaptchaFailed")
: t("resendFailed")}
</p>
) : null}
@@ -94,6 +106,8 @@ export function ResendVerificationForm() {
)}
</button>
</div>
<CaptchaWidget captcha={captcha} nonce={nonce} className="mt-1" />
</form>
);
}
+17 -1
View File
@@ -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;
+69
View File
@@ -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);
});
});
+53
View File
@@ -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<boolean> {
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<boolean> {
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<void> {
await clearRateLimit(lockoutKey(userId));
}
+113
View File
@@ -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();
});
});
+53
View File
@@ -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;
}
+61 -1
View File
@@ -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();
});
});
+66 -2
View File
@@ -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<RateLimitResult> {
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<void> {
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<string> {
try {
return resolveClientIp(await headers());
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Вие сте забранени",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Είστε απαγορευμένοι",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Вы заблокированы",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Забрањени сте",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Ви забанені",