feat(auth): implement all 16 homepage/login/register review items
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m56s

- add countArticles() (published-only, mirrors news-list) and warm total_articles
- localize homepage metadata; bind articleCount to both stats; unique photo alts
- drop duplicate news date and the mascot preload priorities
- extract shared AuthPageFrame/AuthUsersCards used by /login and /register
- login: localized noindex metadata, session redirect via safeRedirectPath,
  ?from passthrough from proxy, unified auth roster cache keys, registered notice
- register: localized metadata, session redirect to /me, unified cache keys
- add resend-verification flow on /verify with rate-limited non-enumerable action
- add safeRedirectPath() with unit tests
- register form: live requirements checklist + password mismatch guard
- login form: unverified state with resend-link CTA
- honour prefers-reduced-motion in TypewriterText
- add 6 translations across all 25 locales
This commit is contained in:
openhands committed 2026-10-08 18:49:27 +02:00
1 parent 8561c3f85e
commit 3933214953
43 files changed
+1037 -490

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
"use server";
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";
export interface ResendVerificationState {
ok: boolean;
error: string | null;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Re-send a verification e-mail for an address the visitor typed on /verify.
*
* 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.
*/
export async function resendVerification(
_prevState: ResendVerificationState,
formData: FormData,
): Promise<ResendVerificationState> {
const email = String(formData.get("email") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
if (!EMAIL_RE.test(email)) {
return { ok: false, error: "invalid" };
}
const ip = await clientIp();
if (!(await rateLimit(`verify:resend:${ip}`, 3, 10 * 60_000)).ok) {
return { ok: false, error: "rateLimited" };
}
try {
const [user] = await db
.select({ id: User.id, mailVerified: User.mailVerified })
.from(User)
.where(eq(User.mail, email))
.limit(1);
if (user && user.mailVerified !== "1") {
await sendVerification(email);
}
} catch {
return { ok: false, error: "unavailable" };
}
return { ok: true, error: null };
}
File diff suppressed because it is too large. Load diff
+22 -24
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { CheckCircle2, Clock, MailX } from "lucide-react";
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";
@@ -185,6 +186,7 @@ export default async function VerifyPage({
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{t("invalidBody")}
</p>
<ResendVerificationForm />
<Link
href="/login"
className={`${linkClass} !shadow-none`}
+11
View File
@@ -2679,6 +2679,17 @@ html {
border: 1px solid color-mix(in srgb, var(--color-danger) 32%, transparent);
}
/* Same box with the success tint — post-registration confirmation, etc. */
.auth-alert.auth-alert--success {
color: color-mix(
in srgb,
var(--color-success) 85%,
var(--color-text-readable)
);
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-color: color-mix(in srgb, var(--color-success) 32%, transparent);
}
/* ── Public page polish: explore pills ────────────────────────────── */
.explore-pill {
display: inline-flex;
File diff suppressed because it is too large. Load diff
+18
View File
@@ -48,6 +48,7 @@ export function LoginForm({
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [unverified, setUnverified] = useState(false);
const [pending, setPending] = useState(false);
const showFooter = variant === "page";
@@ -56,6 +57,7 @@ export function LoginForm({
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setUnverified(false);
setPending(true);
try {
if (!needs2fa) {
@@ -71,6 +73,7 @@ export function LoginForm({
}
if (pre === "unverified") {
setError(t("errorUnverified"));
setUnverified(true);
return;
}
if (pre === "twofactor") {
@@ -220,6 +223,21 @@ export function LoginForm({
</p>
)}
{unverified && (
<p
className="m-0 text-center text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<Link
href="/verify"
className="font-extrabold transition-colors hover:underline"
style={{ color: "var(--color-primary)" }}
>
{t("verifyEmailCta")}
</Link>
</p>
)}
<button
type="submit"
disabled={pending}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,99 @@
"use client";
import { Mail } from "lucide-react";
import { useTranslations } from "next-intl";
import { useActionState, useId } from "react";
import {
type ResendVerificationState,
resendVerification,
} from "@/actions/verify";
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".
*/
export function ResendVerificationForm() {
const t = useTranslations("pages.verify");
const fieldId = useId();
const [state, formAction, isPending] = useActionState(
resendVerification,
INITIAL_STATE,
);
return (
<form action={formAction} className="mt-1 w-full space-y-2 text-left">
{state.ok ? (
<p
role="status"
className="auth-alert auth-alert--success m-0 animate-fade-in-up"
>
{t("resendSent")}
</p>
) : state.error ? (
<p role="alert" className="auth-alert m-0 animate-fade-in-up">
{t("resendFailed")}
</p>
) : null}
<label htmlFor={fieldId} className="auth-label">
{t("resendEmail")}
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id={fieldId}
name="email"
type="email"
required
autoComplete="email"
placeholder={t("resendEmail")}
className="auth-input has-icon"
/>
</div>
<button
type="submit"
disabled={isPending}
className="btn-brand btn-shine mb-0.5 shrink-0 px-5 py-3 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
>
{isPending ? (
<span className="inline-flex items-center justify-center gap-2">
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-label="Loading"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{t("resendButton")}
</span>
) : (
t("resendButton")
)}
</button>
</div>
</form>
);
}
@@ -4,17 +4,17 @@ import { describe, expect, it } from "vitest";
const compactCallSites = new Map<string, number>([
["src/components/top-header.tsx", 1],
// /login and /register render their rosters through this shared frame.
["src/components/auth/auth-page-frame.tsx", 1],
["src/app/(site)/friends/page.tsx", 1],
["src/app/(site)/guilds/[id]/page.tsx", 1],
["src/app/(site)/leaderboard/page.tsx", 1],
["src/app/(site)/login/page.tsx", 2],
["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2],
["src/app/(site)/page.tsx", 2],
["src/app/(site)/radio/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/page.tsx", 1],
["src/app/(site)/register/page.tsx", 2],
["src/app/(site)/search/page.tsx", 1],
["src/app/(site)/u/[username]/page.tsx", 2],
]);
+9
View File
@@ -16,6 +16,15 @@ export function TypewriterText({
useEffect(() => {
setDisplayed(text[0] ?? "");
setDone(text.length <= 1);
// Respect "reduce motion": show the full text instead of animating the
// letters in, since the blinking cursor is pure CSS-driven decoration.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setDisplayed(text);
setDone(true);
return;
}
let i = 1;
const id = setInterval(() => {
if (i < text.length) {
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { safeRedirectPath } from "./safe-redirect";
describe("safeRedirectPath", () => {
it("keeps same-site destinations", () => {
expect(safeRedirectPath("/admin/users")).toBe("/admin/users");
expect(safeRedirectPath("/me")).toBe("/me");
expect(safeRedirectPath("/login?from=%2Fadmin")).toBe(
"/login?from=%2Fadmin",
);
expect(safeRedirectPath("/news/welcome#comments")).toBe(
"/news/welcome#comments",
);
});
it("refuses cross-origin and non-path destinations", () => {
expect(safeRedirectPath("//evil.example")).toBe("/me");
expect(safeRedirectPath("/\\evil.example")).toBe("/me");
expect(safeRedirectPath("https://evil.example")).toBe("/me");
expect(safeRedirectPath("javascript:alert(1)")).toBe("/me");
expect(safeRedirectPath("admin")).toBe("/me");
});
it("treats a missing value as the fallback", () => {
expect(safeRedirectPath(undefined)).toBe("/me");
expect(safeRedirectPath(null)).toBe("/me");
expect(safeRedirectPath("")).toBe("/me");
expect(safeRedirectPath(undefined, "/admin")).toBe("/admin");
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Validate a post-login destination taken from the query string.
*
* The value ends up in `window.location.href`, so an unvalidated one turns the
* sign-in form into an open redirect. Only same-site absolute paths pass:
* `//host` and `/\host` are cross-origin in every browser, and anything without
* a leading slash is not a path at all.
*/
export function safeRedirectPath(
raw: string | null | undefined,
fallback = "/me",
): string {
if (!raw) return fallback;
if (!raw.startsWith("/")) return fallback;
if (raw.startsWith("//") || raw.startsWith("/\\")) return fallback;
return raw;
}
+6
View File
@@ -7,6 +7,7 @@ import { logger } from "@/lib/logger";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
import {
countArticles,
countOnline,
countPhotos,
countRooms,
@@ -65,6 +66,11 @@ export async function warmPublicCaches(): Promise<void> {
staleMs: COUNTER_TTL_MS,
}),
);
await warm("total_articles", () =>
cached("total_articles", COUNTER_TTL_MS, countArticles, {
staleMs: COUNTER_TTL_MS,
}),
);
await warm("online_users", () =>
cached("online_users", ONLINE_TTL_MS, listOnlineUsers, {
staleMs: 15_000,
+10
View File
@@ -14,6 +14,7 @@ const tables = vi.hoisted(() => ({
User: { name: "users" },
Rooms: { name: "rooms" },
CameraWeb: { name: "camera_web" },
WebsiteArticles: { name: "website_articles" },
}));
vi.mock("@/lib/db", () => {
@@ -48,6 +49,7 @@ vi.mock("@/lib/db", () => {
});
import {
countArticles,
countOnline,
countPhotos,
countRooms,
@@ -98,3 +100,11 @@ it("keeps the online count exact", async () => {
state.exact = 12;
expect(await countOnline()).toBe(12);
});
it("counts the articles table, not the news preview it renders from", async () => {
// The homepage shows a total article count while the news panel beside it
// only ever receives the four latest rows — the two must not be conflated.
state.perTable = { website_articles: 42, users: 7 };
expect(await countArticles()).toBe(42);
expect(await countUsers()).toBe(7);
});
+23 -2
View File
@@ -1,7 +1,7 @@
import "server-only";
import { count, eq } from "drizzle-orm";
import { CameraWeb, db, Rooms, User } from "@/lib/db";
import { and, count, eq, or, sql } from "drizzle-orm";
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
/**
* Row counters for the public homepage.
@@ -40,6 +40,27 @@ export async function countPhotos(): Promise<number> {
return row?.total ?? 0;
}
/**
* Published articles only — the same filter `getNewsList` renders from, so the
* "Articles" figure on the homepage can never disagree with the news section
* that sits next to it.
*/
export async function countArticles(): Promise<number> {
const [row] = await db
.select({ total: count() })
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
);
return row?.total ?? 0;
}
/**
* Online users is deliberately *not* estimated: it is read from an index over a
* small subset of rows, it is the one counter people watch closely, and being a
+8 -2
View File
@@ -6092,7 +6092,9 @@
"username": "اسم المستخدم",
"password": "كلمة المرور",
"showPassword": "إظهار",
"hidePassword": "إخفاء"
"hidePassword": "إخفاء",
"registeredSuccess": "تم إنشاء الحساب — تحقق من بريدك الوارد للحصول على رابط التحقق، ثم سجّل الدخول.",
"verifyEmailCta": "طلب رابط تحقق جديد"
},
"register": {
"title": "إنشاء حساب",
@@ -6192,7 +6194,11 @@
"invalidTitle": "Email verification",
"invalidSubtitle": "Invalid or expired link",
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
"backToLogin": "Back to login"
"backToLogin": "Back to login",
"resendEmail": "البريد الإلكتروني",
"resendButton": "إرسال رابط جديد",
"resendSent": "إذا كان لهذا العنوان حساب، فسيصل رابط تحقق جديد قريبًا — تحقق من بريدك الوارد.",
"resendFailed": "تعذّر إرسال رابط جديد. حاول مرة أخرى بعد بضع دقائق."
},
"banned": {
"title": "You are banned",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Потребителско име",
"password": "Парола",
"showPassword": "Покажи",
"hidePassword": "Скрий"
"hidePassword": "Скрий",
"registeredSuccess": "Акаунтът е създаден — проверете пощата си за връзката за потвърждение и влезете.",
"verifyEmailCta": "Заявка за нова вързка за потвърждение"
},
"register": {
"title": "Създаване на акаунт",
@@ -947,7 +949,11 @@
"invalidTitle": "Проверка на имейл",
"invalidSubtitle": "Невалидна или изтекла връзка",
"invalidBody": "Тази връзка за потвърждение не е валидна. Уверете се, че сте отворили пълната връзка от имейла си или поискайте нова.",
"backToLogin": "Назад към входа"
"backToLogin": "Назад към входа",
"resendEmail": "Имейл адрес",
"resendButton": "Изпрати нова вързка",
"resendSent": "Ако този адрес има акаунт, нова вързка за потвърждение е на път — проверете пощата си.",
"resendFailed": "Не можахме да изпратим нова вързка. Опитайте отново след няколко минути."
},
"banned": {
"title": "Вие сте забранени",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Uživatelské jméno",
"password": "Heslo",
"showPassword": "Zobrazit",
"hidePassword": "Skrýt"
"hidePassword": "Skrýt",
"registeredSuccess": "Úter vytvořen — zkontrolujte svou schránku a najděte ověřovací odkaz, poté se přihlaste.",
"verifyEmailCta": "Vyžádat nový ověřovací odkaz"
},
"register": {
"title": "Vytvořit účet",
@@ -947,7 +949,11 @@
"invalidTitle": "Ověření e-mailem",
"invalidSubtitle": "Neplatný odkaz nebo odkaz, jehož platnost vypršela",
"invalidBody": "Tento ověřovací odkaz není platný. Ujistěte se, že jste otevřeli celý odkaz ze svého e-mailu, nebo požádejte o nový.",
"backToLogin": "Zpět k přihlášení"
"backToLogin": "Zpět k přihlášení",
"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."
},
"banned": {
"title": "Máte zákaz",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Brugernavn",
"password": "Adgangskode",
"showPassword": "Vis",
"hidePassword": "Skjul"
"hidePassword": "Skjul",
"registeredSuccess": "Konto oprettet — tjek din indbakke for bekræftelseslinket, og log derefter ind.",
"verifyEmailCta": "Anmod om en ny bekræftelseslink"
},
"register": {
"title": "Opret konto",
@@ -947,7 +949,11 @@
"invalidTitle": "E-mailbekræftelse",
"invalidSubtitle": "Ugyldigt eller udløbet link",
"invalidBody": "Dette bekræftelseslink er ikke gyldigt. Sørg for, at du har åbnet hele linket fra din e-mail, eller anmod om et nyt.",
"backToLogin": "Tilbage til login"
"backToLogin": "Tilbage til login",
"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."
},
"banned": {
"title": "Du er bandlyst",
+8 -2
View File
@@ -817,7 +817,9 @@
"username": "Benutzername",
"password": "Passwort",
"showPassword": "Anzeigen",
"hidePassword": "Verbergen"
"hidePassword": "Verbergen",
"registeredSuccess": "Konto erstellt — prüf dein Postfach für den Bestätigungslink und melde dich dann an.",
"verifyEmailCta": "Neuen Bestätigungslink anfordern"
},
"register": {
"title": "Konto erstellen",
@@ -917,7 +919,11 @@
"invalidTitle": "E-Mail-Verifizierung",
"invalidSubtitle": "Ungültiger oder abgelaufener Link",
"invalidBody": "Dieser Verifizierungslink ist nicht gültig. Stelle sicher, dass du den vollständigen Link aus deiner E-Mail geöffnet hast, oder fordere einen neuen an.",
"backToLogin": "Zurück zur Anmeldung"
"backToLogin": "Zurück zur Anmeldung",
"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."
},
"banned": {
"title": "Du bist gebannt",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Όνομα χρήστη",
"password": "Κωδικός πρόσβασης",
"showPassword": "Εμφάνιση",
"hidePassword": "Απόκρυψη"
"hidePassword": "Απόκρυψη",
"registeredSuccess": "Ο λογαριασμός δημιουργήθηκε — ελέγξτε τα εισερχόμενά σας για τον σύνδεσμο επαλήθευσης και συνδεθείτε.",
"verifyEmailCta": "Ζητήστε νέο σύνδεσμο επαλήθευσης"
},
"register": {
"title": "Δημιουργία λογαριασμού",
@@ -947,7 +949,11 @@
"invalidTitle": "Επαλήθευση μέσω email",
"invalidSubtitle": "Μη έγκυρος ή ληγμένος σύνδεσμος",
"invalidBody": "Αυτός ο σύνδεσμος επαλήθευσης δεν είναι έγκυρος. Βεβαιωθείτε ότι έχετε ανοίξει τον πλήρη σύνδεσμο από το email σας ή ζητήστε νέο.",
"backToLogin": "Επιστροφή στην είσοδο"
"backToLogin": "Επιστροφή στην είσοδο",
"resendEmail": "Διεύθυνση email",
"resendButton": "Αποστολή νέου συνδέσμου",
"resendSent": "Αν αυτή η διεύθυνση έχει λογαριασμό, ένας νέος σύνδεσμος επαλήθευσης είναι καθ' οδόν — ελέγξτε τα εισερχόμενά σας.",
"resendFailed": "Δεν ήταν δυνατή η αποστολή νέου συνδέσμου. Δοκιμάστε ξανά σε λίγα λεπτά."
},
"banned": {
"title": "Είστε απαγορευμένοι",
+8 -2
View File
@@ -1019,7 +1019,9 @@
"showPassword": "Show",
"hidePassword": "Hide",
"username": "Username",
"password": "Password"
"password": "Password",
"registeredSuccess": "Account created — check your inbox for the verification link, then sign in.",
"verifyEmailCta": "Request a new verification link"
},
"register": {
"title": "Create account",
@@ -1119,7 +1121,11 @@
"invalidTitle": "Email verification",
"invalidSubtitle": "Invalid or expired link",
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
"backToLogin": "Back to login"
"backToLogin": "Back to login",
"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."
},
"banned": {
"title": "You are banned",
+8 -2
View File
@@ -817,7 +817,9 @@
"username": "Usuario",
"password": "Contraseña",
"showPassword": "Mostrar",
"hidePassword": "Ocultar"
"hidePassword": "Ocultar",
"registeredSuccess": "Cuenta creada: revisa tu bandeja de entrada para el enlace de verificación e inicia sesión.",
"verifyEmailCta": "Solicitar un nuevo enlace de verificación"
},
"register": {
"title": "Crear cuenta",
@@ -917,7 +919,11 @@
"invalidTitle": "Verificación de correo electrónico",
"invalidSubtitle": "Enlace inválido o expirado",
"invalidBody": "Este enlace de verificación no es válido. Asegúrate de haber abierto el enlace completo de tu correo, o solicita uno nuevo.",
"backToLogin": "Volver al inicio de sesión"
"backToLogin": "Volver al inicio de sesión",
"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."
},
"banned": {
"title": "Has sido baneado",
+8 -2
View File
@@ -6092,7 +6092,9 @@
"username": "Käyttäjätunnus",
"password": "Salasana",
"showPassword": "Näytä",
"hidePassword": "Piilota"
"hidePassword": "Piilota",
"registeredSuccess": "Tili luotu — tarkista sähköpostisi ja avaa vahvistuslinkki, sitten kirjaudu sisään.",
"verifyEmailCta": "Pyydä uusi vahvistuslinkki"
},
"register": {
"title": "Luo tili",
@@ -6192,7 +6194,11 @@
"invalidTitle": "Email verification",
"invalidSubtitle": "Invalid or expired link",
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
"backToLogin": "Back to login"
"backToLogin": "Back to login",
"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."
},
"banned": {
"title": "You are banned",
+8 -2
View File
@@ -817,7 +817,9 @@
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"showPassword": "Afficher",
"hidePassword": "Masquer"
"hidePassword": "Masquer",
"registeredSuccess": "Compte créé — vérifiez votre boîte mail pour le lien de confirmation, puis connectez-vous.",
"verifyEmailCta": "Demander un nouveau lien de vérification"
},
"register": {
"title": "Créer un compte",
@@ -917,7 +919,11 @@
"invalidTitle": "Vérification d'e-mail",
"invalidSubtitle": "Lien invalide ou expiré",
"invalidBody": "Ce lien de vérification n'est pas valide. Assurez-vous d'avoir ouvert le lien complet depuis votre e-mail, ou demandez-en un nouveau.",
"backToLogin": "Retour à la connexion"
"backToLogin": "Retour à la connexion",
"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."
},
"banned": {
"title": "Vous êtes banni",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Korisničko ime",
"password": "Lozinka",
"showPassword": "Prikaži",
"hidePassword": "Sakrij"
"hidePassword": "Sakrij",
"registeredSuccess": "Račun je stvoren — provjerite poštanski sandučić za poveznicu za verifikaciju, zatim se prijavite.",
"verifyEmailCta": "Zatraži novu poveznicu za verifikaciju"
},
"register": {
"title": "Napravi račun",
@@ -947,7 +949,11 @@
"invalidTitle": "Potvrda e-pošte",
"invalidSubtitle": "Veza je nevažeća ili je istekla",
"invalidBody": "Ova veza za potvrdu nije važeća. Provjerite jeste li otvorili cijelu vezu iz svoje e-pošte ili zatražite novu.",
"backToLogin": "Natrag na prijavu"
"backToLogin": "Natrag na prijavu",
"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."
},
"banned": {
"title": "Zabranjeni ste",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Felhasználónév",
"password": "Jelszó",
"showPassword": "Megjelenítés",
"hidePassword": "Elrejtés"
"hidePassword": "Elrejtés",
"registeredSuccess": "A fiók létrehozva — ellenőrizze a postaládáját a megerősítő linkért, majd jelentkezzen be.",
"verifyEmailCta": "Új megerősítő link kérése"
},
"register": {
"title": "Hozzon létre fiókot",
@@ -947,7 +949,11 @@
"invalidTitle": "E-mail ellenőrzés",
"invalidSubtitle": "Érvénytelen vagy lejárt link",
"invalidBody": "Ez az ellenőrző link nem érvényes. Győződjön meg arról, hogy a teljes linket az e-mailből nyitotta meg, vagy kérjen újat.",
"backToLogin": "Vissza a bejelentkezéshez"
"backToLogin": "Vissza a bejelentkezéshez",
"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."
},
"banned": {
"title": "Ki vagy tiltva",
+8 -2
View File
@@ -989,7 +989,9 @@
"username": "Nome utente",
"password": "Password",
"showPassword": "Mostra",
"hidePassword": "Nascondi"
"hidePassword": "Nascondi",
"registeredSuccess": "Account creato: controlla la tua casella per il link di verifica, poi accedi.",
"verifyEmailCta": "Richiedi un nuovo link di verifica"
},
"register": {
"title": "Crea un account",
@@ -1089,7 +1091,11 @@
"invalidTitle": "Verifica email",
"invalidSubtitle": "Link non valido o scaduto",
"invalidBody": "Questo link di verifica non è valido. Assicurati di aver aperto il link completo della tua email, oppure richiedine uno nuovo.",
"backToLogin": "Torna all'accesso"
"backToLogin": "Torna all'accesso",
"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."
},
"banned": {
"title": "Sei stato bannato",
+8 -2
View File
@@ -6092,7 +6092,9 @@
"username": "ユーザー名",
"password": "パスワード",
"showPassword": "表示",
"hidePassword": "非表示"
"hidePassword": "非表示",
"registeredSuccess": "アカウントを作成しました。確認用リンクをメールでお確かめのうえ、ログインしてください。",
"verifyEmailCta": "新しい確認リンクをリクエスト"
},
"register": {
"title": "アカウント作成",
@@ -6192,7 +6194,11 @@
"invalidTitle": "Email verification",
"invalidSubtitle": "Invalid or expired link",
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
"backToLogin": "Back to login"
"backToLogin": "Back to login",
"resendEmail": "メールアドレス",
"resendButton": "新しいリンクを送信",
"resendSent": "そのアドレスにアカウントがあれば、新しい確認リンクが届くはずです。受信トレイをご確認ください。",
"resendFailed": "新しいリンクを送信できませんでした。数分後にもう一度お試しください。"
},
"banned": {
"title": "You are banned",
+8 -2
View File
@@ -1019,7 +1019,9 @@
"showPassword": "Toon",
"hidePassword": "Verberg",
"username": "Gebruikersnaam",
"password": "Wachtwoord"
"password": "Wachtwoord",
"registeredSuccess": "Account aangemaakt — check je inbox voor de verificatielink en log daarna in.",
"verifyEmailCta": "Een nieuwe verificatielink aanvragen"
},
"register": {
"title": "Account aanmaken",
@@ -1119,7 +1121,11 @@
"invalidTitle": "E-mailverificatie",
"invalidSubtitle": "Ongeldige of verlopen link",
"invalidBody": "Deze verificatielink is niet geldig. Zorg ervoor dat je de volledige link uit je e-mail hebt geopend of vraag een nieuwe aan.",
"backToLogin": "Terug naar inloggen"
"backToLogin": "Terug naar inloggen",
"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."
},
"banned": {
"title": "Je bent verbannen",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Brukernavn",
"password": "Passord",
"showPassword": "Vis",
"hidePassword": "Skjul"
"hidePassword": "Skjul",
"registeredSuccess": "Konto opprettet — sjekk innboksen for verifiseringslenken, og logg så inn.",
"verifyEmailCta": "Be om en ny verifiseringslenke"
},
"register": {
"title": "Opprett konto",
@@ -947,7 +949,11 @@
"invalidTitle": "E-postbekreftelse",
"invalidSubtitle": "Ugyldig eller utløpt lenke",
"invalidBody": "Denne bekreftelseslenken er ikke gyldig. Sørg for at du åpnet hele lenken fra e-posten din, eller be om en ny.",
"backToLogin": "Tilbake til innlogging"
"backToLogin": "Tilbake til innlogging",
"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."
},
"banned": {
"title": "Du er utestengt",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Nazwa użytkownika",
"password": "Hasło",
"showPassword": "Pokaż",
"hidePassword": "Ukryj"
"hidePassword": "Ukryj",
"registeredSuccess": "Konto utworzone — sprawdź skrzynkę odbiorczą, aby znaleźć link weryfikacyjny, a następnie zaloguj się.",
"verifyEmailCta": "Poproś o nowy link weryfikacyjny"
},
"register": {
"title": "Utwórz konto",
@@ -947,7 +949,11 @@
"invalidTitle": "Weryfikacja e-mailowa",
"invalidSubtitle": "Nieprawidłowy lub wygasły link",
"invalidBody": "Ten link weryfikacyjny jest nieprawidłowy. Upewnij się, że otworzyłeś cały link z e-maila lub poproś o nowy.",
"backToLogin": "Powrót do logowania"
"backToLogin": "Powrót do logowania",
"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."
},
"banned": {
"title": "Masz zakaz",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Nome de usuário",
"password": "Senha",
"showPassword": "Mostrar",
"hidePassword": "Ocultar"
"hidePassword": "Ocultar",
"registeredSuccess": "Conta criada — verifique a sua caixa de entrada para o link de confirmação e inicie sessão.",
"verifyEmailCta": "Pedir um novo link de verificação"
},
"register": {
"title": "Criar conta",
@@ -947,7 +949,11 @@
"invalidTitle": "Verificação de e-mail",
"invalidSubtitle": "Link inválido ou expirado",
"invalidBody": "Este link de verificação não é válido. Certifique-se de abrir o link completo do seu e-mail ou solicite um novo.",
"backToLogin": "Voltar ao login"
"backToLogin": "Voltar ao login",
"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."
},
"banned": {
"title": "Você está banido",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Nume de utilizator",
"password": "Parolă",
"showPassword": "Arată",
"hidePassword": "Ascunde"
"hidePassword": "Ascunde",
"registeredSuccess": "Cont creat — verifică căsuța pentru linkul de confirmare, apoi autentifică-te.",
"verifyEmailCta": "Solicită un nou link de verificare"
},
"register": {
"title": "Creați cont",
@@ -947,7 +949,11 @@
"invalidTitle": "Verificare e-mail",
"invalidSubtitle": "Link nevalid sau expirat",
"invalidBody": "Acest link de verificare nu este valid. Asigurați-vă că ați deschis linkul complet din e-mailul dvs. sau solicitați unul nou.",
"backToLogin": "Înapoi la autentificare"
"backToLogin": "Înapoi la autentificare",
"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."
},
"banned": {
"title": "Esti interzis",
+8 -2
View File
@@ -845,7 +845,9 @@
"username": "Имя пользователя",
"password": "Пароль",
"showPassword": "Показать",
"hidePassword": "Скрыть"
"hidePassword": "Скрыть",
"registeredSuccess": "Аккаунт создан — проверьте почту: там ссылка для подтверждения. После этого войдите.",
"verifyEmailCta": "Запросить новую ссылку для подтверждения"
},
"register": {
"title": "Создать аккаунт",
@@ -945,7 +947,11 @@
"invalidTitle": "Подтверждение адреса электронной почты",
"invalidSubtitle": "Недействительная или просроченная ссылка",
"invalidBody": "Эта ссылка для подтверждения недействительна. Убедитесь, что вы открыли полную ссылку из письма, или запросите новую.",
"backToLogin": "Назад к входу"
"backToLogin": "Назад к входу",
"resendEmail": "Адрес электронной почты",
"resendButton": "Отправить новую ссылку",
"resendSent": "Если к этому адресу привязан аккаунт, новая ссылка уже в пути — проверьте почту.",
"resendFailed": "Не удалось отправить новую ссылку. Попробуйте еще раз через несколько минут."
},
"banned": {
"title": "Вы заблокированы",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Používateľské meno",
"password": "heslo",
"showPassword": "Zobraziť",
"hidePassword": "Skryť"
"hidePassword": "Skryť",
"registeredSuccess": "Úter vytvorený — skontrolujte svoju schránku a nájdite overovací odkaz, potom sa prihláste.",
"verifyEmailCta": "Vyžiadať nový overovací odkaz"
},
"register": {
"title": "Vytvoriť účet",
@@ -947,7 +949,11 @@
"invalidTitle": "Overenie e-mailom",
"invalidSubtitle": "Neplatný odkaz alebo odkaz, ktorého platnosť vypršala",
"invalidBody": "Tento overovací odkaz nie je platný. Uistite sa, že ste otvorili celý odkaz zo svojho e-mailu, alebo požiadajte o nový.",
"backToLogin": "Späť na prihlásenie"
"backToLogin": "Späť na prihlásenie",
"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."
},
"banned": {
"title": "Máte zákaz",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Корисничко име",
"password": "Лозинка",
"showPassword": "Прикажи",
"hidePassword": "Сакриј"
"hidePassword": "Сакриј",
"registeredSuccess": "Налог је креиран — проверите пошту за везу за верификацију, а затим се пријавите.",
"verifyEmailCta": "Затражи нову везу за верификацију"
},
"register": {
"title": "Креирајте налог",
@@ -947,7 +949,11 @@
"invalidTitle": "Верификација е-поште",
"invalidSubtitle": "Неважећа или истекла веза",
"invalidBody": "Ова веза за верификацију није важећа. Уверите се да сте отворили целу везу из своје е-поште или затражите нову.",
"backToLogin": "Назад на пријаву"
"backToLogin": "Назад на пријаву",
"resendEmail": "Имејл адреса",
"resendButton": "Пошаљи нову везу",
"resendSent": "Ако за ту адресу постоји налог, нова веза за верификацију је на путу — проверите пошту.",
"resendFailed": "Нова веза није могла бити послата. Покушајте поново за неколико минута."
},
"banned": {
"title": "Забрањени сте",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Användarnamn",
"password": "Lösenord",
"showPassword": "Visa",
"hidePassword": "Dölj"
"hidePassword": "Dölj",
"registeredSuccess": "Konto skapad — kontrollera din inkorg för verifieringslänken och logga sedan in.",
"verifyEmailCta": "Begär en ny verifieringslänk"
},
"register": {
"title": "Skapa konto",
@@ -947,7 +949,11 @@
"invalidTitle": "E-postverifiering",
"invalidSubtitle": "Ogiltig eller utgången länk",
"invalidBody": "Denna verifieringslänk är inte giltig. Se till att du öppnade hela länken från din e-post, eller begär en ny.",
"backToLogin": "Tillbaka till inloggning"
"backToLogin": "Tillbaka till inloggning",
"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."
},
"banned": {
"title": "Du är förbjuden",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Kullanıcı adı",
"password": "Şifre",
"showPassword": "Göster",
"hidePassword": "Gizle"
"hidePassword": "Gizle",
"registeredSuccess": "Hesap oluşturuldu — doğrulama bağlantısı için gelen kutunuzu kontrol edin, ardından giriş yapın.",
"verifyEmailCta": "Yeni doğrulama bağlantısı iste"
},
"register": {
"title": "Hesap oluştur",
@@ -947,7 +949,11 @@
"invalidTitle": "E-posta doğrulaması",
"invalidSubtitle": "Geçersiz veya süresi dolmuş bağlantı",
"invalidBody": "Bu doğrulama bağlantısı geçerli değil. E-postanızdaki bağlantının tamamını açtığınızdan emin olun veya yeni bir bağlantı isteyin.",
"backToLogin": "Girişe geri dön"
"backToLogin": "Girişe geri dön",
"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."
},
"banned": {
"title": "Yasaklandın",
+8 -2
View File
@@ -847,7 +847,9 @@
"username": "Ім'я користувача",
"password": "Пароль",
"showPassword": "Показати",
"hidePassword": "Сховати"
"hidePassword": "Сховати",
"registeredSuccess": "Акаунт створено — перевірте пошту: там посилання для підтвердження. Після цього увійдіть.",
"verifyEmailCta": "Запросити нове посилання для підтвердження"
},
"register": {
"title": "Створити акаунт",
@@ -947,7 +949,11 @@
"invalidTitle": "Підтвердження електронної пошти",
"invalidSubtitle": "Недійсне або прострочене посилання",
"invalidBody": "Це посилання для підтвердження недійсне. Переконайтеся, що ви відкрили повне посилання зі свого електронного листа, або надішліть запит на нове.",
"backToLogin": "Назад до входу"
"backToLogin": "Назад до входу",
"resendEmail": "Адреса електронної пошти",
"resendButton": "Надіслати нове посилання",
"resendSent": "Якщо до цієї адреси прив'язаний аккаунт, нове посилання вже в дорозі — перевірте пошту.",
"resendFailed": "Не вдалося надіслати нове посилання. Спробуйте ще раз через кілька хвилин."
},
"banned": {
"title": "Ви забанені",
+5 -1
View File
@@ -40,7 +40,11 @@ export const proxy = async (req: import("next/server").NextRequest) => {
: null;
if (shouldRedirectAdminRequest(pathname, token)) {
return NextResponse.redirect(new URL("/login", req.url));
// Carry the destination along so the sign-in form can send the visitor
// back to the page they asked for instead of dumping them on /me.
const login = new URL("/login", req.url);
login.searchParams.set("from", `${pathname}${req.nextUrl.search}`);
return NextResponse.redirect(login);
}
const nonce = createCspNonce();