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
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:
1 parent
8561c3f85e
commit
3933214953
43 files changed
+890
-343
No files matched your search
@@ -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 };
|
||||||
|
}
|
||||||
+87
-132
@@ -1,20 +1,41 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
import {
|
||||||
|
AuthPageFrame,
|
||||||
|
AuthUsersCards,
|
||||||
|
} from "@/components/auth/auth-page-frame";
|
||||||
import { LoginForm } from "@/components/auth/login-form";
|
import { LoginForm } from "@/components/auth/login-form";
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
|
||||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
||||||
import { SurfaceCard } from "@/components/surface-card";
|
import { SurfaceCard } from "@/components/surface-card";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.login");
|
||||||
|
const title = t("title");
|
||||||
|
const description = t("subtitle");
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
// Sign-in is a dead end for crawlers and duplicates the homepage copy.
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
openGraph: { title, description, type: "website" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ from?: string; registered?: string }>;
|
||||||
|
}) {
|
||||||
const t = await getTranslations("pages.login");
|
const t = await getTranslations("pages.login");
|
||||||
const [hotelName, cfg, logo] = await Promise.all([
|
const [hotelName, cfg, logo] = await Promise.all([
|
||||||
resolveHotelName(),
|
resolveHotelName(),
|
||||||
@@ -23,6 +44,14 @@ export default async function LoginPage() {
|
|||||||
]);
|
]);
|
||||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const redirectTo = safeRedirectPath(sp.from);
|
||||||
|
|
||||||
|
// Already signed in: the form has nothing to do here. Honour `from` first so
|
||||||
|
// an admin bounced off /admin lands back where they were heading.
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user?.id) redirect(redirectTo);
|
||||||
|
|
||||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||||
cached("online_count", 10_000, () =>
|
cached("online_count", 10_000, () =>
|
||||||
db
|
db
|
||||||
@@ -31,52 +60,63 @@ export default async function LoginPage() {
|
|||||||
.where(eq(User.online, "1"))
|
.where(eq(User.online, "1"))
|
||||||
.then((rows) => rows[0]?.total ?? 0),
|
.then((rows) => rows[0]?.total ?? 0),
|
||||||
).catch(() => 0),
|
).catch(() => 0),
|
||||||
|
cached(
|
||||||
|
"auth_online_users",
|
||||||
|
10_000,
|
||||||
|
() =>
|
||||||
db
|
db
|
||||||
.select({ username: User.username, look: User.look })
|
.select({ username: User.username, look: User.look })
|
||||||
.from(User)
|
.from(User)
|
||||||
.where(eq(User.online, "1"))
|
.where(eq(User.online, "1"))
|
||||||
.limit(8)
|
.limit(8),
|
||||||
.catch(() => []),
|
{ staleMs: 30000 },
|
||||||
|
).catch(() => []),
|
||||||
|
cached(
|
||||||
|
"auth_latest_users",
|
||||||
|
30_000,
|
||||||
|
() =>
|
||||||
db
|
db
|
||||||
.select({ username: User.username, look: User.look })
|
.select({ username: User.username, look: User.look })
|
||||||
.from(User)
|
.from(User)
|
||||||
.orderBy(desc(User.accountCreated))
|
.orderBy(desc(User.accountCreated))
|
||||||
.limit(8)
|
.limit(8),
|
||||||
.catch(() => []),
|
{ staleMs: 60000 },
|
||||||
|
).catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
// `/login?registered=1` is where the sign-up form lands when it could not
|
||||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
// auto sign-in (e-mail verification still pending). Without this notice the
|
||||||
<div className="relative">
|
// visitor would only see an empty login form and no sign their account
|
||||||
<div
|
// exists.
|
||||||
aria-hidden="true"
|
const notice =
|
||||||
className="pointer-events-none absolute -inset-x-16 -top-24 overflow-hidden"
|
sp.registered === "1" ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
||||||
>
|
>
|
||||||
<div
|
{t("registeredSuccess")}
|
||||||
className="aurora-blob -left-20 top-0 h-80 w-80"
|
</p>
|
||||||
style={{
|
) : undefined;
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 34%, transparent)",
|
|
||||||
opacity: 0.45,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="aurora-blob -right-20 top-6 h-72 w-72"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-accent) 30%, transparent)",
|
|
||||||
animationDelay: "-7s",
|
|
||||||
opacity: 0.4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Reveal>
|
return (
|
||||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
<AuthPageFrame
|
||||||
{/* Left panel */}
|
hotelName={hotelName}
|
||||||
<div className="lg:w-96 shrink-0 space-y-4">
|
logo={logo}
|
||||||
|
title={t("title")}
|
||||||
|
subtitle={t("subtitle")}
|
||||||
|
notice={notice}
|
||||||
|
form={
|
||||||
|
<LoginForm
|
||||||
|
redirectTo={redirectTo}
|
||||||
|
captcha={{
|
||||||
|
provider: cfg.provider,
|
||||||
|
siteKey: cfg.siteKey || undefined,
|
||||||
|
field: cfg.field || undefined,
|
||||||
|
}}
|
||||||
|
nonce={nonce}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<SurfaceCard
|
<SurfaceCard
|
||||||
className="card-glow relative overflow-hidden p-6 text-center"
|
className="card-glow relative overflow-hidden p-6 text-center"
|
||||||
style={{
|
style={{
|
||||||
@@ -91,10 +131,7 @@ export default async function LoginPage() {
|
|||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute inset-0 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||||
<div
|
<div
|
||||||
className="aurora-blob -left-16 top-10 h-56 w-56"
|
className="aurora-blob -left-16 top-10 h-56 w-56"
|
||||||
@@ -121,7 +158,6 @@ export default async function LoginPage() {
|
|||||||
height={170}
|
height={170}
|
||||||
className="relative object-contain mx-auto drop-shadow-xl animate-float"
|
className="relative object-contain mx-auto drop-shadow-xl animate-float"
|
||||||
unoptimized
|
unoptimized
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
|
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
|
||||||
@@ -130,8 +166,7 @@ export default async function LoginPage() {
|
|||||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||||
background:
|
background:
|
||||||
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
||||||
color:
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
"var(--color-primary-readable, var(--color-primary))",
|
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
||||||
}}
|
}}
|
||||||
@@ -166,92 +201,12 @@ export default async function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
{recentUsers.length > 0 && (
|
<AuthUsersCards
|
||||||
<SurfaceCard
|
recentUsers={recentUsers}
|
||||||
title={t("whoIsOnline")}
|
latestUsers={latestUsers}
|
||||||
icon={ICON_NAV_GOODY}
|
recentTitle={t("whoIsOnline")}
|
||||||
bodyClassName="p-4"
|
latestTitle={t("newestCitizens")}
|
||||||
className="card-glow"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
|
||||||
{recentUsers.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.username}
|
|
||||||
title={u.username}
|
|
||||||
className="avatar-tile"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<UserAvatarThumbnail
|
|
||||||
figure={u.look}
|
|
||||||
alt=""
|
|
||||||
options={{ direction: 2 }}
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
/>
|
||||||
<span className="avatar-online absolute -bottom-0.5 -right-0.5" />
|
</AuthPageFrame>
|
||||||
</div>
|
|
||||||
<span className="avatar-name">{u.username}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SurfaceCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{latestUsers.length > 0 && (
|
|
||||||
<SurfaceCard
|
|
||||||
title={t("newestCitizens")}
|
|
||||||
icon={ICON_FRIENDS}
|
|
||||||
bodyClassName="p-4"
|
|
||||||
className="card-glow"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
|
||||||
{latestUsers.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.username}
|
|
||||||
title={u.username}
|
|
||||||
className="avatar-tile"
|
|
||||||
>
|
|
||||||
<UserAvatarThumbnail
|
|
||||||
figure={u.look}
|
|
||||||
alt=""
|
|
||||||
options={{ direction: 2 }}
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
<span className="avatar-name">{u.username}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SurfaceCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: form */}
|
|
||||||
<div className="flex-1 lg:sticky lg:top-6">
|
|
||||||
<div className="gradient-border">
|
|
||||||
<SurfaceCard
|
|
||||||
title={t("title")}
|
|
||||||
icon={ICON_NAV_ME}
|
|
||||||
bodyClassName="p-6 sm:p-7"
|
|
||||||
className="card-glow"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="text-sm mb-6 leading-relaxed"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("subtitle")}
|
|
||||||
</p>
|
|
||||||
<LoginForm
|
|
||||||
captcha={{
|
|
||||||
provider: cfg.provider,
|
|
||||||
siteKey: cfg.siteKey || undefined,
|
|
||||||
field: cfg.field || undefined,
|
|
||||||
}}
|
|
||||||
nonce={nonce}
|
|
||||||
/>
|
|
||||||
</SurfaceCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+22
-24
@@ -26,6 +26,7 @@ import { resolveHotelName } from "@/lib/hotel-name";
|
|||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { getNewsList } from "@/lib/services/news-list";
|
import { getNewsList } from "@/lib/services/news-list";
|
||||||
import {
|
import {
|
||||||
|
countArticles,
|
||||||
countPhotos,
|
countPhotos,
|
||||||
countRooms,
|
countRooms,
|
||||||
countUsers,
|
countUsers,
|
||||||
@@ -42,17 +43,17 @@ import {
|
|||||||
ICON_NAV_ME,
|
ICON_NAV_ME,
|
||||||
} from "@/lib/site-icons";
|
} from "@/lib/site-icons";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Home",
|
const tn = await getTranslations("nav");
|
||||||
description:
|
const tp = await getTranslations("pages.home");
|
||||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
const title = tn("home");
|
||||||
openGraph: {
|
const description = tp("welcomeBody");
|
||||||
title: "Home",
|
return {
|
||||||
description:
|
title,
|
||||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
description,
|
||||||
type: "website",
|
openGraph: { title, description, type: "website" },
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
// ── Local visual language (theme-aware, no big white blocks) ──────────
|
// ── Local visual language (theme-aware, no big white blocks) ──────────
|
||||||
const PANEL_BG =
|
const PANEL_BG =
|
||||||
@@ -177,6 +178,7 @@ async function getHotelData() {
|
|||||||
users,
|
users,
|
||||||
rooms,
|
rooms,
|
||||||
totalPhotos,
|
totalPhotos,
|
||||||
|
articleCount,
|
||||||
articles,
|
articles,
|
||||||
recentUsers,
|
recentUsers,
|
||||||
recentPhotos,
|
recentPhotos,
|
||||||
@@ -197,6 +199,9 @@ async function getHotelData() {
|
|||||||
cached("total_photos", 300_000, countPhotos, { staleMs: 300000 }).catch(
|
cached("total_photos", 300_000, countPhotos, { staleMs: 300000 }).catch(
|
||||||
publicReadFailure("home.photos-count"),
|
publicReadFailure("home.photos-count"),
|
||||||
),
|
),
|
||||||
|
cached("total_articles", 300_000, countArticles, {
|
||||||
|
staleMs: 300000,
|
||||||
|
}).catch(publicReadFailure("home.articles-count")),
|
||||||
getNewsList(4, { throwOnError: true }).catch(
|
getNewsList(4, { throwOnError: true }).catch(
|
||||||
publicReadFailure("home.news"),
|
publicReadFailure("home.news"),
|
||||||
),
|
),
|
||||||
@@ -230,6 +235,7 @@ async function getHotelData() {
|
|||||||
users,
|
users,
|
||||||
rooms,
|
rooms,
|
||||||
totalPhotos,
|
totalPhotos,
|
||||||
|
articleCount,
|
||||||
articles,
|
articles,
|
||||||
recentUsers,
|
recentUsers,
|
||||||
recentPhotos,
|
recentPhotos,
|
||||||
@@ -251,6 +257,7 @@ export default async function Home() {
|
|||||||
users,
|
users,
|
||||||
rooms,
|
rooms,
|
||||||
totalPhotos,
|
totalPhotos,
|
||||||
|
articleCount,
|
||||||
articles,
|
articles,
|
||||||
recentUsers,
|
recentUsers,
|
||||||
recentPhotos,
|
recentPhotos,
|
||||||
@@ -344,12 +351,6 @@ export default async function Home() {
|
|||||||
>
|
>
|
||||||
{a.title}
|
{a.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
|
||||||
className="mt-0.5 text-[10px] sm:text-[11px] font-semibold opacity-80"
|
|
||||||
style={{ color: "#cbd5e1" }}
|
|
||||||
>
|
|
||||||
{formatDate(a.createdAt, "date", "")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -373,7 +374,7 @@ export default async function Home() {
|
|||||||
color: "var(--color-primary)",
|
color: "var(--color-primary)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: articles?.length ?? null,
|
value: articleCount,
|
||||||
label: tp("statsArticles"),
|
label: tp("statsArticles"),
|
||||||
color: "var(--color-accent)",
|
color: "var(--color-accent)",
|
||||||
},
|
},
|
||||||
@@ -699,7 +700,6 @@ export default async function Home() {
|
|||||||
alt=""
|
alt=""
|
||||||
width={96}
|
width={96}
|
||||||
height={128}
|
height={128}
|
||||||
priority
|
|
||||||
unoptimized
|
unoptimized
|
||||||
className="relative animate-float object-contain drop-shadow-2xl"
|
className="relative animate-float object-contain drop-shadow-2xl"
|
||||||
/>
|
/>
|
||||||
@@ -856,7 +856,7 @@ export default async function Home() {
|
|||||||
icon: ICON_CAMERA,
|
icon: ICON_CAMERA,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: articles?.length ?? null,
|
value: articleCount,
|
||||||
label: tp("statsArticles"),
|
label: tp("statsArticles"),
|
||||||
icon: ICON_ARTICLE,
|
icon: ICON_ARTICLE,
|
||||||
},
|
},
|
||||||
@@ -1025,12 +1025,10 @@ export default async function Home() {
|
|||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
<Reveal className="mx-auto w-full max-w-7xl">
|
||||||
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
||||||
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{recentPhotos.slice(0, 4).map((p) => (
|
{recentPhotos.slice(0, 4).map((p, i) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
href="/photos"
|
href="/photos"
|
||||||
title=""
|
|
||||||
aria-label={tp("recentPhotos")}
|
|
||||||
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
|
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
|
||||||
style={{
|
style={{
|
||||||
borderColor:
|
borderColor:
|
||||||
@@ -1039,7 +1037,7 @@ export default async function Home() {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={p.url}
|
src={p.url}
|
||||||
alt={tp("recentPhotos")}
|
alt={`${tp("recentPhotos")} ${i + 1}`}
|
||||||
width={280}
|
width={280}
|
||||||
height={210}
|
height={210}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
import {
|
||||||
|
AuthPageFrame,
|
||||||
|
AuthUsersCards,
|
||||||
|
} from "@/components/auth/auth-page-frame";
|
||||||
import { RegisterForm } from "@/components/auth/register-form";
|
import { RegisterForm } from "@/components/auth/register-form";
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
|
||||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
||||||
import { SurfaceCard } from "@/components/surface-card";
|
import { SurfaceCard } from "@/components/surface-card";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.register");
|
||||||
|
const title = t("title");
|
||||||
|
const description = t("subtitle");
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description, type: "website" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
export default async function RegisterPage() {
|
||||||
const tpr = await getTranslations("pages.register");
|
const tpr = await getTranslations("pages.register");
|
||||||
@@ -23,6 +37,10 @@ export default async function RegisterPage() {
|
|||||||
]);
|
]);
|
||||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
|
// An account already exists in this browser — there is nothing to sign up.
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user?.id) redirect("/me");
|
||||||
|
|
||||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||||
cached("online_count", 10_000, () =>
|
cached("online_count", 10_000, () =>
|
||||||
db
|
db
|
||||||
@@ -32,7 +50,7 @@ export default async function RegisterPage() {
|
|||||||
.then((rows) => rows[0]?.total ?? 0),
|
.then((rows) => rows[0]?.total ?? 0),
|
||||||
).catch(() => 0),
|
).catch(() => 0),
|
||||||
cached(
|
cached(
|
||||||
"register_online_users",
|
"auth_online_users",
|
||||||
10_000,
|
10_000,
|
||||||
() =>
|
() =>
|
||||||
db
|
db
|
||||||
@@ -43,7 +61,7 @@ export default async function RegisterPage() {
|
|||||||
{ staleMs: 30000 },
|
{ staleMs: 30000 },
|
||||||
).catch(() => []),
|
).catch(() => []),
|
||||||
cached(
|
cached(
|
||||||
"register_latest_users",
|
"auth_latest_users",
|
||||||
30_000,
|
30_000,
|
||||||
() =>
|
() =>
|
||||||
db
|
db
|
||||||
@@ -56,37 +74,22 @@ export default async function RegisterPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
<AuthPageFrame
|
||||||
<div className="relative">
|
hotelName={hotelName}
|
||||||
<div
|
logo={logo}
|
||||||
aria-hidden="true"
|
title={tpr("title")}
|
||||||
className="pointer-events-none absolute -inset-x-16 -top-24 overflow-hidden"
|
subtitle={tpr("subtitle")}
|
||||||
|
form={
|
||||||
|
<RegisterForm
|
||||||
|
hotelName={hotelName}
|
||||||
|
captcha={{
|
||||||
|
provider: cfg.provider,
|
||||||
|
siteKey: cfg.siteKey || undefined,
|
||||||
|
}}
|
||||||
|
nonce={nonce}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
className="aurora-blob -left-20 top-0 h-80 w-80"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 34%, transparent)",
|
|
||||||
opacity: 0.45,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="aurora-blob -right-20 top-6 h-72 w-72"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-accent) 30%, transparent)",
|
|
||||||
animationDelay: "-7s",
|
|
||||||
opacity: 0.4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Reveal>
|
|
||||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
|
||||||
{/* Left: intro panel */}
|
|
||||||
<div className="lg:w-96 shrink-0 space-y-4">
|
|
||||||
<SurfaceCard
|
<SurfaceCard
|
||||||
className="card-glow relative overflow-hidden p-6"
|
className="card-glow relative overflow-hidden p-6"
|
||||||
style={{
|
style={{
|
||||||
@@ -101,10 +104,7 @@ export default async function RegisterPage() {
|
|||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute inset-0 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||||
<div
|
<div
|
||||||
className="aurora-blob -left-16 top-6 h-56 w-56"
|
className="aurora-blob -left-16 top-6 h-56 w-56"
|
||||||
@@ -133,7 +133,6 @@ export default async function RegisterPage() {
|
|||||||
height={145}
|
height={145}
|
||||||
className="relative object-contain drop-shadow-xl"
|
className="relative object-contain drop-shadow-xl"
|
||||||
unoptimized
|
unoptimized
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -144,8 +143,7 @@ export default async function RegisterPage() {
|
|||||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||||
background:
|
background:
|
||||||
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
||||||
color:
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
"var(--color-primary-readable, var(--color-primary))",
|
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
||||||
}}
|
}}
|
||||||
@@ -183,92 +181,12 @@ export default async function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
{recentUsers.length > 0 && (
|
<AuthUsersCards
|
||||||
<SurfaceCard
|
recentUsers={recentUsers}
|
||||||
title={tpr("whoIsOnline")}
|
latestUsers={latestUsers}
|
||||||
icon={ICON_NAV_GOODY}
|
recentTitle={tpr("whoIsOnline")}
|
||||||
bodyClassName="p-4"
|
latestTitle={tpr("newestCitizens")}
|
||||||
className="card-glow"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
|
||||||
{recentUsers.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.username}
|
|
||||||
title={u.username}
|
|
||||||
className="avatar-tile"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<UserAvatarThumbnail
|
|
||||||
figure={u.look}
|
|
||||||
alt=""
|
|
||||||
options={{ direction: 2 }}
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
/>
|
||||||
<span className="avatar-online absolute -bottom-0.5 -right-0.5" />
|
</AuthPageFrame>
|
||||||
</div>
|
|
||||||
<span className="avatar-name">{u.username}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SurfaceCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{latestUsers.length > 0 && (
|
|
||||||
<SurfaceCard
|
|
||||||
title={tpr("newestCitizens")}
|
|
||||||
icon={ICON_FRIENDS}
|
|
||||||
bodyClassName="p-4"
|
|
||||||
className="card-glow"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
|
||||||
{latestUsers.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.username}
|
|
||||||
title={u.username}
|
|
||||||
className="avatar-tile"
|
|
||||||
>
|
|
||||||
<UserAvatarThumbnail
|
|
||||||
figure={u.look}
|
|
||||||
alt=""
|
|
||||||
options={{ direction: 2 }}
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
<span className="avatar-name">{u.username}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SurfaceCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: form */}
|
|
||||||
<div className="flex-1 lg:sticky lg:top-6">
|
|
||||||
<div className="gradient-border">
|
|
||||||
<SurfaceCard
|
|
||||||
title={tpr("title")}
|
|
||||||
icon={ICON_NAV_ME}
|
|
||||||
bodyClassName="p-6 sm:p-7"
|
|
||||||
className="card-glow"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="text-sm mb-6 leading-relaxed"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{tpr("subtitle")}
|
|
||||||
</p>
|
|
||||||
<RegisterForm
|
|
||||||
hotelName={hotelName}
|
|
||||||
captcha={{
|
|
||||||
provider: cfg.provider,
|
|
||||||
siteKey: cfg.siteKey || undefined,
|
|
||||||
}}
|
|
||||||
nonce={nonce}
|
|
||||||
/>
|
|
||||||
</SurfaceCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { CheckCircle2, Clock, MailX } from "lucide-react";
|
import { CheckCircle2, Clock, MailX } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ResendVerificationForm } from "@/components/auth/resend-verification-form";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { SurfaceCard } from "@/components/surface-card";
|
import { SurfaceCard } from "@/components/surface-card";
|
||||||
import { isValidVerificationToken } from "@/lib/auth/email-verification";
|
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)" }}>
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
{t("invalidBody")}
|
{t("invalidBody")}
|
||||||
</p>
|
</p>
|
||||||
|
<ResendVerificationForm />
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className={`${linkClass} !shadow-none`}
|
className={`${linkClass} !shadow-none`}
|
||||||
|
|||||||
@@ -2679,6 +2679,17 @@ html {
|
|||||||
border: 1px solid color-mix(in srgb, var(--color-danger) 32%, transparent);
|
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 ────────────────────────────── */
|
/* ── Public page polish: explore pills ────────────────────────────── */
|
||||||
.explore-pill {
|
.explore-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||||
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
|
import { SurfaceCard } from "@/components/surface-card";
|
||||||
|
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
||||||
|
|
||||||
|
export interface PublicUser {
|
||||||
|
username: string;
|
||||||
|
look: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decorative aurora blobs that sit behind the auth top bar on `/login` and
|
||||||
|
* `/register`. Purely visual, hence aria-hidden, and shared so both pages keep
|
||||||
|
* the exact same tint and delay.
|
||||||
|
*/
|
||||||
|
export function AuthAurora() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -inset-x-16 -top-24 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="aurora-blob -left-20 top-0 h-80 w-80"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 34%, transparent)",
|
||||||
|
opacity: 0.45,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="aurora-blob -right-20 top-6 h-72 w-72"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-accent) 30%, transparent)",
|
||||||
|
animationDelay: "-7s",
|
||||||
|
opacity: 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthUsersCardsProps {
|
||||||
|
recentUsers: PublicUser[];
|
||||||
|
latestUsers: PublicUser[];
|
||||||
|
/** Title of the online-users card. */
|
||||||
|
recentTitle: string;
|
||||||
|
/** Title of the newest-accounts card. */
|
||||||
|
latestTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "who is online" + "newest citizens" avatar cards both auth pages show in
|
||||||
|
* their left column. Cards with no users render nothing, so a cold database
|
||||||
|
* degrades to just the intro panel instead of an empty grid.
|
||||||
|
*/
|
||||||
|
export function AuthUsersCards({
|
||||||
|
recentUsers,
|
||||||
|
latestUsers,
|
||||||
|
recentTitle,
|
||||||
|
latestTitle,
|
||||||
|
}: AuthUsersCardsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{recentUsers.length > 0 && (
|
||||||
|
<SurfaceCard
|
||||||
|
title={recentTitle}
|
||||||
|
icon={ICON_NAV_GOODY}
|
||||||
|
bodyClassName="p-4"
|
||||||
|
className="card-glow"
|
||||||
|
>
|
||||||
|
<AvatarGrid users={recentUsers} online />
|
||||||
|
</SurfaceCard>
|
||||||
|
)}
|
||||||
|
{latestUsers.length > 0 && (
|
||||||
|
<SurfaceCard
|
||||||
|
title={latestTitle}
|
||||||
|
icon={ICON_FRIENDS}
|
||||||
|
bodyClassName="p-4"
|
||||||
|
className="card-glow"
|
||||||
|
>
|
||||||
|
<AvatarGrid users={latestUsers} />
|
||||||
|
</SurfaceCard>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGrid({
|
||||||
|
users,
|
||||||
|
online,
|
||||||
|
}: {
|
||||||
|
users: PublicUser[];
|
||||||
|
online?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.username} title={u.username} className="avatar-tile">
|
||||||
|
<div className="relative">
|
||||||
|
<UserAvatarThumbnail
|
||||||
|
figure={u.look}
|
||||||
|
alt=""
|
||||||
|
options={{ direction: 2 }}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
{online && (
|
||||||
|
<span className="avatar-online absolute -bottom-0.5 -right-0.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="avatar-name">{u.username}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthPageFrameProps {
|
||||||
|
hotelName: string;
|
||||||
|
logo?: string | null;
|
||||||
|
/** Title of the form card on the right. */
|
||||||
|
title: string;
|
||||||
|
/** One-line intro above the form. */
|
||||||
|
subtitle: ReactNode;
|
||||||
|
/** Optional notice rendered at the top of the form card (e.g. "registered"). */
|
||||||
|
notice?: ReactNode;
|
||||||
|
/** The form itself. */
|
||||||
|
form: ReactNode;
|
||||||
|
/** The left column: intro panel plus `AuthUsersCards`. */
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared skeleton for the public auth pages: aurora + top bar on top, a fixed
|
||||||
|
* left column for the promo/roster panels and a sticky right column holding the
|
||||||
|
* form. Both `/login` and `/register` used to duplicate all of it.
|
||||||
|
*/
|
||||||
|
export function AuthPageFrame({
|
||||||
|
hotelName,
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
notice,
|
||||||
|
form,
|
||||||
|
children,
|
||||||
|
}: AuthPageFrameProps) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
||||||
|
<div className="relative">
|
||||||
|
<AuthAurora />
|
||||||
|
<AuthTopBar hotelName={hotelName} logo={logo} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal>
|
||||||
|
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
||||||
|
<div className="lg:w-96 shrink-0 space-y-4">{children}</div>
|
||||||
|
|
||||||
|
<div className="flex-1 lg:sticky lg:top-6">
|
||||||
|
<div className="gradient-border">
|
||||||
|
<SurfaceCard
|
||||||
|
title={title}
|
||||||
|
icon={ICON_NAV_ME}
|
||||||
|
bodyClassName="p-6 sm:p-7"
|
||||||
|
className="card-glow"
|
||||||
|
>
|
||||||
|
{notice}
|
||||||
|
<p
|
||||||
|
className="text-sm mb-6 leading-relaxed"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
{form}
|
||||||
|
</SurfaceCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export function LoginForm({
|
|||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [needs2fa, setNeeds2fa] = useState(false);
|
const [needs2fa, setNeeds2fa] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [unverified, setUnverified] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
const showFooter = variant === "page";
|
const showFooter = variant === "page";
|
||||||
@@ -56,6 +57,7 @@ export function LoginForm({
|
|||||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setUnverified(false);
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
if (!needs2fa) {
|
if (!needs2fa) {
|
||||||
@@ -71,6 +73,7 @@ export function LoginForm({
|
|||||||
}
|
}
|
||||||
if (pre === "unverified") {
|
if (pre === "unverified") {
|
||||||
setError(t("errorUnverified"));
|
setError(t("errorUnverified"));
|
||||||
|
setUnverified(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pre === "twofactor") {
|
if (pre === "twofactor") {
|
||||||
@@ -220,6 +223,21 @@ export function LoginForm({
|
|||||||
</p>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { KeyRound, Lock, Mail, ShieldCheck, UserRound } from "lucide-react";
|
import {
|
||||||
|
Check,
|
||||||
|
KeyRound,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
ShieldCheck,
|
||||||
|
UserRound,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -73,6 +81,7 @@ export function RegisterForm({
|
|||||||
const [termsError, setTermsError] = useState(false);
|
const [termsError, setTermsError] = useState(false);
|
||||||
const termsRef = useRef<HTMLDivElement>(null);
|
const termsRef = useRef<HTMLDivElement>(null);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [signingIn, setSigningIn] = useState(false);
|
const [signingIn, setSigningIn] = useState(false);
|
||||||
@@ -83,6 +92,21 @@ export function RegisterForm({
|
|||||||
const [referralCode, setReferralCode] = useState("");
|
const [referralCode, setReferralCode] = useState("");
|
||||||
const strength = passwordStrength(password, t);
|
const strength = passwordStrength(password, t);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The password policy the server enforces, evaluated live so the visitor can
|
||||||
|
* see which rule they are still missing instead of submitting and reading a
|
||||||
|
* single error back.
|
||||||
|
*/
|
||||||
|
const requirements = [
|
||||||
|
{ label: t("passwordMinLength"), met: password.length >= 12 },
|
||||||
|
{ label: t("passwordUpper"), met: /[A-Z]/.test(password) },
|
||||||
|
{ label: t("passwordLower"), met: /[a-z]/.test(password) },
|
||||||
|
{ label: t("passwordDigit"), met: /\d/.test(password) },
|
||||||
|
{ label: t("passwordSpecial"), met: /[^A-Za-z0-9]/.test(password) },
|
||||||
|
];
|
||||||
|
/** Shown as soon as the confirmation is filled in and disagrees. */
|
||||||
|
const mismatch = confirmPassword.length > 0 && confirmPassword !== password;
|
||||||
|
|
||||||
// The invite link is `/register?ref=<username>`; read it client-side so the
|
// The invite link is `/register?ref=<username>`; read it client-side so the
|
||||||
// attribution travels with the sign-up form without server state.
|
// attribution travels with the sign-up form without server state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,9 +170,14 @@ export function RegisterForm({
|
|||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form
|
<form
|
||||||
action={(fd) => {
|
action={(fd) => {
|
||||||
|
const passwordValue = String(fd.get("password") ?? "");
|
||||||
|
const confirmation = String(fd.get("password_confirmation") ?? "");
|
||||||
|
// A mismatch is reported live under the field — no round-trip
|
||||||
|
// needed just to hear it again from the server.
|
||||||
|
if (passwordValue !== confirmation) return;
|
||||||
credentialsRef.current = {
|
credentialsRef.current = {
|
||||||
username: String(fd.get("username") ?? "").trim(),
|
username: String(fd.get("username") ?? "").trim(),
|
||||||
password: String(fd.get("password") ?? ""),
|
password: passwordValue,
|
||||||
};
|
};
|
||||||
formAction(fd);
|
formAction(fd);
|
||||||
}}
|
}}
|
||||||
@@ -368,6 +397,26 @@ export function RegisterForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ul aria-label={t("password")} className="space-y-1 pt-1">
|
||||||
|
{requirements.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.label}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-semibold transition-colors duration-200"
|
||||||
|
style={{
|
||||||
|
color: r.met
|
||||||
|
? "var(--color-success)"
|
||||||
|
: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.met ? (
|
||||||
|
<Check className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<X className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{r.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="password_confirmation" className="auth-label">
|
<label htmlFor="password_confirmation" className="auth-label">
|
||||||
@@ -385,6 +434,8 @@ export function RegisterForm({
|
|||||||
type={showConfirm ? "text" : "password"}
|
type={showConfirm ? "text" : "password"}
|
||||||
placeholder={t("confirmPasswordPlaceholder")}
|
placeholder={t("confirmPasswordPlaceholder")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
className="auth-input has-icon has-action"
|
className="auth-input has-icon has-action"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -395,6 +446,14 @@ export function RegisterForm({
|
|||||||
labelHide={t("hidePassword")}
|
labelHide={t("hidePassword")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{mismatch && (
|
||||||
|
<p
|
||||||
|
className="animate-fade-in-up text-xs font-bold"
|
||||||
|
style={{ color: "var(--color-danger)" }}
|
||||||
|
>
|
||||||
|
{t("passwordsDontMatch")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -521,8 +580,11 @@ export function RegisterForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || signingIn}
|
disabled={isPending || signingIn}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
if (!termsAccepted) {
|
if (!termsAccepted) {
|
||||||
|
// Don't fire a doomed server round-trip: shake the
|
||||||
|
// terms box and stay put until it is checked.
|
||||||
|
e.preventDefault();
|
||||||
setTermsError(true);
|
setTermsError(true);
|
||||||
termsRef.current?.scrollIntoView({
|
termsRef.current?.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
|
|||||||
@@ -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>([
|
const compactCallSites = new Map<string, number>([
|
||||||
["src/components/top-header.tsx", 1],
|
["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)/friends/page.tsx", 1],
|
||||||
["src/app/(site)/guilds/[id]/page.tsx", 1],
|
["src/app/(site)/guilds/[id]/page.tsx", 1],
|
||||||
["src/app/(site)/leaderboard/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)/me/page.tsx", 1],
|
||||||
["src/app/(site)/messages/page.tsx", 2],
|
["src/app/(site)/messages/page.tsx", 2],
|
||||||
["src/app/(site)/page.tsx", 2],
|
["src/app/(site)/page.tsx", 2],
|
||||||
["src/app/(site)/radio/leaderboard/page.tsx", 1],
|
["src/app/(site)/radio/leaderboard/page.tsx", 1],
|
||||||
["src/app/(site)/radio/shouts/page.tsx", 1],
|
["src/app/(site)/radio/shouts/page.tsx", 1],
|
||||||
["src/app/(site)/rankings/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)/search/page.tsx", 1],
|
||||||
["src/app/(site)/u/[username]/page.tsx", 2],
|
["src/app/(site)/u/[username]/page.tsx", 2],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export function TypewriterText({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayed(text[0] ?? "");
|
setDisplayed(text[0] ?? "");
|
||||||
setDone(text.length <= 1);
|
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;
|
let i = 1;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
if (i < text.length) {
|
if (i < text.length) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { logger } from "@/lib/logger";
|
|||||||
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
|
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
|
||||||
import { cacheNews } from "@/lib/services/news-cache";
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
import {
|
import {
|
||||||
|
countArticles,
|
||||||
countOnline,
|
countOnline,
|
||||||
countPhotos,
|
countPhotos,
|
||||||
countRooms,
|
countRooms,
|
||||||
@@ -65,6 +66,11 @@ export async function warmPublicCaches(): Promise<void> {
|
|||||||
staleMs: COUNTER_TTL_MS,
|
staleMs: COUNTER_TTL_MS,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
await warm("total_articles", () =>
|
||||||
|
cached("total_articles", COUNTER_TTL_MS, countArticles, {
|
||||||
|
staleMs: COUNTER_TTL_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await warm("online_users", () =>
|
await warm("online_users", () =>
|
||||||
cached("online_users", ONLINE_TTL_MS, listOnlineUsers, {
|
cached("online_users", ONLINE_TTL_MS, listOnlineUsers, {
|
||||||
staleMs: 15_000,
|
staleMs: 15_000,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const tables = vi.hoisted(() => ({
|
|||||||
User: { name: "users" },
|
User: { name: "users" },
|
||||||
Rooms: { name: "rooms" },
|
Rooms: { name: "rooms" },
|
||||||
CameraWeb: { name: "camera_web" },
|
CameraWeb: { name: "camera_web" },
|
||||||
|
WebsiteArticles: { name: "website_articles" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/db", () => {
|
vi.mock("@/lib/db", () => {
|
||||||
@@ -48,6 +49,7 @@ vi.mock("@/lib/db", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
countArticles,
|
||||||
countOnline,
|
countOnline,
|
||||||
countPhotos,
|
countPhotos,
|
||||||
countRooms,
|
countRooms,
|
||||||
@@ -98,3 +100,11 @@ it("keeps the online count exact", async () => {
|
|||||||
state.exact = 12;
|
state.exact = 12;
|
||||||
expect(await countOnline()).toBe(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);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { count, eq } from "drizzle-orm";
|
import { and, count, eq, or, sql } from "drizzle-orm";
|
||||||
import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Row counters for the public homepage.
|
* Row counters for the public homepage.
|
||||||
@@ -40,6 +40,27 @@ export async function countPhotos(): Promise<number> {
|
|||||||
return row?.total ?? 0;
|
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
|
* 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
|
* small subset of rows, it is the one counter people watch closely, and being a
|
||||||
|
|||||||
@@ -6092,7 +6092,9 @@
|
|||||||
"username": "اسم المستخدم",
|
"username": "اسم المستخدم",
|
||||||
"password": "كلمة المرور",
|
"password": "كلمة المرور",
|
||||||
"showPassword": "إظهار",
|
"showPassword": "إظهار",
|
||||||
"hidePassword": "إخفاء"
|
"hidePassword": "إخفاء",
|
||||||
|
"registeredSuccess": "تم إنشاء الحساب — تحقق من بريدك الوارد للحصول على رابط التحقق، ثم سجّل الدخول.",
|
||||||
|
"verifyEmailCta": "طلب رابط تحقق جديد"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "إنشاء حساب",
|
"title": "إنشاء حساب",
|
||||||
@@ -6192,7 +6194,11 @@
|
|||||||
"invalidTitle": "Email verification",
|
"invalidTitle": "Email verification",
|
||||||
"invalidSubtitle": "Invalid or expired link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "You are banned",
|
"title": "You are banned",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Потребителско име",
|
"username": "Потребителско име",
|
||||||
"password": "Парола",
|
"password": "Парола",
|
||||||
"showPassword": "Покажи",
|
"showPassword": "Покажи",
|
||||||
"hidePassword": "Скрий"
|
"hidePassword": "Скрий",
|
||||||
|
"registeredSuccess": "Акаунтът е създаден — проверете пощата си за връзката за потвърждение и влезете.",
|
||||||
|
"verifyEmailCta": "Заявка за нова вързка за потвърждение"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Създаване на акаунт",
|
"title": "Създаване на акаунт",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Проверка на имейл",
|
"invalidTitle": "Проверка на имейл",
|
||||||
"invalidSubtitle": "Невалидна или изтекла връзка",
|
"invalidSubtitle": "Невалидна или изтекла връзка",
|
||||||
"invalidBody": "Тази връзка за потвърждение не е валидна. Уверете се, че сте отворили пълната връзка от имейла си или поискайте нова.",
|
"invalidBody": "Тази връзка за потвърждение не е валидна. Уверете се, че сте отворили пълната връзка от имейла си или поискайте нова.",
|
||||||
"backToLogin": "Назад към входа"
|
"backToLogin": "Назад към входа",
|
||||||
|
"resendEmail": "Имейл адрес",
|
||||||
|
"resendButton": "Изпрати нова вързка",
|
||||||
|
"resendSent": "Ако този адрес има акаунт, нова вързка за потвърждение е на път — проверете пощата си.",
|
||||||
|
"resendFailed": "Не можахме да изпратим нова вързка. Опитайте отново след няколко минути."
|
||||||
},
|
},
|
||||||
"banned": {
|
"banned": {
|
||||||
"title": "Вие сте забранени",
|
"title": "Вие сте забранени",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Uživatelské jméno",
|
"username": "Uživatelské jméno",
|
||||||
"password": "Heslo",
|
"password": "Heslo",
|
||||||
"showPassword": "Zobrazit",
|
"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": {
|
"register": {
|
||||||
"title": "Vytvořit účet",
|
"title": "Vytvořit účet",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Ověření e-mailem",
|
"invalidTitle": "Ověření e-mailem",
|
||||||
"invalidSubtitle": "Neplatný odkaz nebo odkaz, jehož platnost vypršela",
|
"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ý.",
|
"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": {
|
"banned": {
|
||||||
"title": "Máte zákaz",
|
"title": "Máte zákaz",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Brugernavn",
|
"username": "Brugernavn",
|
||||||
"password": "Adgangskode",
|
"password": "Adgangskode",
|
||||||
"showPassword": "Vis",
|
"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": {
|
"register": {
|
||||||
"title": "Opret konto",
|
"title": "Opret konto",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "E-mailbekræftelse",
|
"invalidTitle": "E-mailbekræftelse",
|
||||||
"invalidSubtitle": "Ugyldigt eller udløbet link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Du er bandlyst",
|
"title": "Du er bandlyst",
|
||||||
|
|||||||
@@ -817,7 +817,9 @@
|
|||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
"showPassword": "Anzeigen",
|
"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": {
|
"register": {
|
||||||
"title": "Konto erstellen",
|
"title": "Konto erstellen",
|
||||||
@@ -917,7 +919,11 @@
|
|||||||
"invalidTitle": "E-Mail-Verifizierung",
|
"invalidTitle": "E-Mail-Verifizierung",
|
||||||
"invalidSubtitle": "Ungültiger oder abgelaufener Link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Du bist gebannt",
|
"title": "Du bist gebannt",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Όνομα χρήστη",
|
"username": "Όνομα χρήστη",
|
||||||
"password": "Κωδικός πρόσβασης",
|
"password": "Κωδικός πρόσβασης",
|
||||||
"showPassword": "Εμφάνιση",
|
"showPassword": "Εμφάνιση",
|
||||||
"hidePassword": "Απόκρυψη"
|
"hidePassword": "Απόκρυψη",
|
||||||
|
"registeredSuccess": "Ο λογαριασμός δημιουργήθηκε — ελέγξτε τα εισερχόμενά σας για τον σύνδεσμο επαλήθευσης και συνδεθείτε.",
|
||||||
|
"verifyEmailCta": "Ζητήστε νέο σύνδεσμο επαλήθευσης"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Δημιουργία λογαριασμού",
|
"title": "Δημιουργία λογαριασμού",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Επαλήθευση μέσω email",
|
"invalidTitle": "Επαλήθευση μέσω email",
|
||||||
"invalidSubtitle": "Μη έγκυρος ή ληγμένος σύνδεσμος",
|
"invalidSubtitle": "Μη έγκυρος ή ληγμένος σύνδεσμος",
|
||||||
"invalidBody": "Αυτός ο σύνδεσμος επαλήθευσης δεν είναι έγκυρος. Βεβαιωθείτε ότι έχετε ανοίξει τον πλήρη σύνδεσμο από το email σας ή ζητήστε νέο.",
|
"invalidBody": "Αυτός ο σύνδεσμος επαλήθευσης δεν είναι έγκυρος. Βεβαιωθείτε ότι έχετε ανοίξει τον πλήρη σύνδεσμο από το email σας ή ζητήστε νέο.",
|
||||||
"backToLogin": "Επιστροφή στην είσοδο"
|
"backToLogin": "Επιστροφή στην είσοδο",
|
||||||
|
"resendEmail": "Διεύθυνση email",
|
||||||
|
"resendButton": "Αποστολή νέου συνδέσμου",
|
||||||
|
"resendSent": "Αν αυτή η διεύθυνση έχει λογαριασμό, ένας νέος σύνδεσμος επαλήθευσης είναι καθ' οδόν — ελέγξτε τα εισερχόμενά σας.",
|
||||||
|
"resendFailed": "Δεν ήταν δυνατή η αποστολή νέου συνδέσμου. Δοκιμάστε ξανά σε λίγα λεπτά."
|
||||||
},
|
},
|
||||||
"banned": {
|
"banned": {
|
||||||
"title": "Είστε απαγορευμένοι",
|
"title": "Είστε απαγορευμένοι",
|
||||||
|
|||||||
@@ -1019,7 +1019,9 @@
|
|||||||
"showPassword": "Show",
|
"showPassword": "Show",
|
||||||
"hidePassword": "Hide",
|
"hidePassword": "Hide",
|
||||||
"username": "Username",
|
"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": {
|
"register": {
|
||||||
"title": "Create account",
|
"title": "Create account",
|
||||||
@@ -1119,7 +1121,11 @@
|
|||||||
"invalidTitle": "Email verification",
|
"invalidTitle": "Email verification",
|
||||||
"invalidSubtitle": "Invalid or expired link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "You are banned",
|
"title": "You are banned",
|
||||||
|
|||||||
@@ -817,7 +817,9 @@
|
|||||||
"username": "Usuario",
|
"username": "Usuario",
|
||||||
"password": "Contraseña",
|
"password": "Contraseña",
|
||||||
"showPassword": "Mostrar",
|
"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": {
|
"register": {
|
||||||
"title": "Crear cuenta",
|
"title": "Crear cuenta",
|
||||||
@@ -917,7 +919,11 @@
|
|||||||
"invalidTitle": "Verificación de correo electrónico",
|
"invalidTitle": "Verificación de correo electrónico",
|
||||||
"invalidSubtitle": "Enlace inválido o expirado",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Has sido baneado",
|
"title": "Has sido baneado",
|
||||||
|
|||||||
@@ -6092,7 +6092,9 @@
|
|||||||
"username": "Käyttäjätunnus",
|
"username": "Käyttäjätunnus",
|
||||||
"password": "Salasana",
|
"password": "Salasana",
|
||||||
"showPassword": "Näytä",
|
"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": {
|
"register": {
|
||||||
"title": "Luo tili",
|
"title": "Luo tili",
|
||||||
@@ -6192,7 +6194,11 @@
|
|||||||
"invalidTitle": "Email verification",
|
"invalidTitle": "Email verification",
|
||||||
"invalidSubtitle": "Invalid or expired link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "You are banned",
|
"title": "You are banned",
|
||||||
|
|||||||
@@ -817,7 +817,9 @@
|
|||||||
"username": "Nom d'utilisateur",
|
"username": "Nom d'utilisateur",
|
||||||
"password": "Mot de passe",
|
"password": "Mot de passe",
|
||||||
"showPassword": "Afficher",
|
"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": {
|
"register": {
|
||||||
"title": "Créer un compte",
|
"title": "Créer un compte",
|
||||||
@@ -917,7 +919,11 @@
|
|||||||
"invalidTitle": "Vérification d'e-mail",
|
"invalidTitle": "Vérification d'e-mail",
|
||||||
"invalidSubtitle": "Lien invalide ou expiré",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Vous êtes banni",
|
"title": "Vous êtes banni",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Korisničko ime",
|
"username": "Korisničko ime",
|
||||||
"password": "Lozinka",
|
"password": "Lozinka",
|
||||||
"showPassword": "Prikaži",
|
"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": {
|
"register": {
|
||||||
"title": "Napravi račun",
|
"title": "Napravi račun",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Potvrda e-pošte",
|
"invalidTitle": "Potvrda e-pošte",
|
||||||
"invalidSubtitle": "Veza je nevažeća ili je istekla",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Zabranjeni ste",
|
"title": "Zabranjeni ste",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Felhasználónév",
|
"username": "Felhasználónév",
|
||||||
"password": "Jelszó",
|
"password": "Jelszó",
|
||||||
"showPassword": "Megjelenítés",
|
"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": {
|
"register": {
|
||||||
"title": "Hozzon létre fiókot",
|
"title": "Hozzon létre fiókot",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "E-mail ellenőrzés",
|
"invalidTitle": "E-mail ellenőrzés",
|
||||||
"invalidSubtitle": "Érvénytelen vagy lejárt link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Ki vagy tiltva",
|
"title": "Ki vagy tiltva",
|
||||||
|
|||||||
@@ -989,7 +989,9 @@
|
|||||||
"username": "Nome utente",
|
"username": "Nome utente",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"showPassword": "Mostra",
|
"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": {
|
"register": {
|
||||||
"title": "Crea un account",
|
"title": "Crea un account",
|
||||||
@@ -1089,7 +1091,11 @@
|
|||||||
"invalidTitle": "Verifica email",
|
"invalidTitle": "Verifica email",
|
||||||
"invalidSubtitle": "Link non valido o scaduto",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Sei stato bannato",
|
"title": "Sei stato bannato",
|
||||||
|
|||||||
@@ -6092,7 +6092,9 @@
|
|||||||
"username": "ユーザー名",
|
"username": "ユーザー名",
|
||||||
"password": "パスワード",
|
"password": "パスワード",
|
||||||
"showPassword": "表示",
|
"showPassword": "表示",
|
||||||
"hidePassword": "非表示"
|
"hidePassword": "非表示",
|
||||||
|
"registeredSuccess": "アカウントを作成しました。確認用リンクをメールでお確かめのうえ、ログインしてください。",
|
||||||
|
"verifyEmailCta": "新しい確認リンクをリクエスト"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "アカウント作成",
|
"title": "アカウント作成",
|
||||||
@@ -6192,7 +6194,11 @@
|
|||||||
"invalidTitle": "Email verification",
|
"invalidTitle": "Email verification",
|
||||||
"invalidSubtitle": "Invalid or expired link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "You are banned",
|
"title": "You are banned",
|
||||||
|
|||||||
@@ -1019,7 +1019,9 @@
|
|||||||
"showPassword": "Toon",
|
"showPassword": "Toon",
|
||||||
"hidePassword": "Verberg",
|
"hidePassword": "Verberg",
|
||||||
"username": "Gebruikersnaam",
|
"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": {
|
"register": {
|
||||||
"title": "Account aanmaken",
|
"title": "Account aanmaken",
|
||||||
@@ -1119,7 +1121,11 @@
|
|||||||
"invalidTitle": "E-mailverificatie",
|
"invalidTitle": "E-mailverificatie",
|
||||||
"invalidSubtitle": "Ongeldige of verlopen link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Je bent verbannen",
|
"title": "Je bent verbannen",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Brukernavn",
|
"username": "Brukernavn",
|
||||||
"password": "Passord",
|
"password": "Passord",
|
||||||
"showPassword": "Vis",
|
"showPassword": "Vis",
|
||||||
"hidePassword": "Skjul"
|
"hidePassword": "Skjul",
|
||||||
|
"registeredSuccess": "Konto opprettet — sjekk innboksen for verifiseringslenken, og logg så inn.",
|
||||||
|
"verifyEmailCta": "Be om en ny verifiseringslenke"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Opprett konto",
|
"title": "Opprett konto",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "E-postbekreftelse",
|
"invalidTitle": "E-postbekreftelse",
|
||||||
"invalidSubtitle": "Ugyldig eller utløpt lenke",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Du er utestengt",
|
"title": "Du er utestengt",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Nazwa użytkownika",
|
"username": "Nazwa użytkownika",
|
||||||
"password": "Hasło",
|
"password": "Hasło",
|
||||||
"showPassword": "Pokaż",
|
"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": {
|
"register": {
|
||||||
"title": "Utwórz konto",
|
"title": "Utwórz konto",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Weryfikacja e-mailowa",
|
"invalidTitle": "Weryfikacja e-mailowa",
|
||||||
"invalidSubtitle": "Nieprawidłowy lub wygasły link",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Masz zakaz",
|
"title": "Masz zakaz",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Nome de usuário",
|
"username": "Nome de usuário",
|
||||||
"password": "Senha",
|
"password": "Senha",
|
||||||
"showPassword": "Mostrar",
|
"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": {
|
"register": {
|
||||||
"title": "Criar conta",
|
"title": "Criar conta",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Verificação de e-mail",
|
"invalidTitle": "Verificação de e-mail",
|
||||||
"invalidSubtitle": "Link inválido ou expirado",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Você está banido",
|
"title": "Você está banido",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Nume de utilizator",
|
"username": "Nume de utilizator",
|
||||||
"password": "Parolă",
|
"password": "Parolă",
|
||||||
"showPassword": "Arată",
|
"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": {
|
"register": {
|
||||||
"title": "Creați cont",
|
"title": "Creați cont",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Verificare e-mail",
|
"invalidTitle": "Verificare e-mail",
|
||||||
"invalidSubtitle": "Link nevalid sau expirat",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Esti interzis",
|
"title": "Esti interzis",
|
||||||
|
|||||||
@@ -845,7 +845,9 @@
|
|||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"showPassword": "Показать",
|
"showPassword": "Показать",
|
||||||
"hidePassword": "Скрыть"
|
"hidePassword": "Скрыть",
|
||||||
|
"registeredSuccess": "Аккаунт создан — проверьте почту: там ссылка для подтверждения. После этого войдите.",
|
||||||
|
"verifyEmailCta": "Запросить новую ссылку для подтверждения"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Создать аккаунт",
|
"title": "Создать аккаунт",
|
||||||
@@ -945,7 +947,11 @@
|
|||||||
"invalidTitle": "Подтверждение адреса электронной почты",
|
"invalidTitle": "Подтверждение адреса электронной почты",
|
||||||
"invalidSubtitle": "Недействительная или просроченная ссылка",
|
"invalidSubtitle": "Недействительная или просроченная ссылка",
|
||||||
"invalidBody": "Эта ссылка для подтверждения недействительна. Убедитесь, что вы открыли полную ссылку из письма, или запросите новую.",
|
"invalidBody": "Эта ссылка для подтверждения недействительна. Убедитесь, что вы открыли полную ссылку из письма, или запросите новую.",
|
||||||
"backToLogin": "Назад к входу"
|
"backToLogin": "Назад к входу",
|
||||||
|
"resendEmail": "Адрес электронной почты",
|
||||||
|
"resendButton": "Отправить новую ссылку",
|
||||||
|
"resendSent": "Если к этому адресу привязан аккаунт, новая ссылка уже в пути — проверьте почту.",
|
||||||
|
"resendFailed": "Не удалось отправить новую ссылку. Попробуйте еще раз через несколько минут."
|
||||||
},
|
},
|
||||||
"banned": {
|
"banned": {
|
||||||
"title": "Вы заблокированы",
|
"title": "Вы заблокированы",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Používateľské meno",
|
"username": "Používateľské meno",
|
||||||
"password": "heslo",
|
"password": "heslo",
|
||||||
"showPassword": "Zobraziť",
|
"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": {
|
"register": {
|
||||||
"title": "Vytvoriť účet",
|
"title": "Vytvoriť účet",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Overenie e-mailom",
|
"invalidTitle": "Overenie e-mailom",
|
||||||
"invalidSubtitle": "Neplatný odkaz alebo odkaz, ktorého platnosť vypršala",
|
"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ý.",
|
"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": {
|
"banned": {
|
||||||
"title": "Máte zákaz",
|
"title": "Máte zákaz",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Корисничко име",
|
"username": "Корисничко име",
|
||||||
"password": "Лозинка",
|
"password": "Лозинка",
|
||||||
"showPassword": "Прикажи",
|
"showPassword": "Прикажи",
|
||||||
"hidePassword": "Сакриј"
|
"hidePassword": "Сакриј",
|
||||||
|
"registeredSuccess": "Налог је креиран — проверите пошту за везу за верификацију, а затим се пријавите.",
|
||||||
|
"verifyEmailCta": "Затражи нову везу за верификацију"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Креирајте налог",
|
"title": "Креирајте налог",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Верификација е-поште",
|
"invalidTitle": "Верификација е-поште",
|
||||||
"invalidSubtitle": "Неважећа или истекла веза",
|
"invalidSubtitle": "Неважећа или истекла веза",
|
||||||
"invalidBody": "Ова веза за верификацију није важећа. Уверите се да сте отворили целу везу из своје е-поште или затражите нову.",
|
"invalidBody": "Ова веза за верификацију није важећа. Уверите се да сте отворили целу везу из своје е-поште или затражите нову.",
|
||||||
"backToLogin": "Назад на пријаву"
|
"backToLogin": "Назад на пријаву",
|
||||||
|
"resendEmail": "Имејл адреса",
|
||||||
|
"resendButton": "Пошаљи нову везу",
|
||||||
|
"resendSent": "Ако за ту адресу постоји налог, нова веза за верификацију је на путу — проверите пошту.",
|
||||||
|
"resendFailed": "Нова веза није могла бити послата. Покушајте поново за неколико минута."
|
||||||
},
|
},
|
||||||
"banned": {
|
"banned": {
|
||||||
"title": "Забрањени сте",
|
"title": "Забрањени сте",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Användarnamn",
|
"username": "Användarnamn",
|
||||||
"password": "Lösenord",
|
"password": "Lösenord",
|
||||||
"showPassword": "Visa",
|
"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": {
|
"register": {
|
||||||
"title": "Skapa konto",
|
"title": "Skapa konto",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "E-postverifiering",
|
"invalidTitle": "E-postverifiering",
|
||||||
"invalidSubtitle": "Ogiltig eller utgången länk",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Du är förbjuden",
|
"title": "Du är förbjuden",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Kullanıcı adı",
|
"username": "Kullanıcı adı",
|
||||||
"password": "Şifre",
|
"password": "Şifre",
|
||||||
"showPassword": "Göster",
|
"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": {
|
"register": {
|
||||||
"title": "Hesap oluştur",
|
"title": "Hesap oluştur",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "E-posta doğrulaması",
|
"invalidTitle": "E-posta doğrulaması",
|
||||||
"invalidSubtitle": "Geçersiz veya süresi dolmuş bağlantı",
|
"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.",
|
"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": {
|
"banned": {
|
||||||
"title": "Yasaklandın",
|
"title": "Yasaklandın",
|
||||||
|
|||||||
@@ -847,7 +847,9 @@
|
|||||||
"username": "Ім'я користувача",
|
"username": "Ім'я користувача",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"showPassword": "Показати",
|
"showPassword": "Показати",
|
||||||
"hidePassword": "Сховати"
|
"hidePassword": "Сховати",
|
||||||
|
"registeredSuccess": "Акаунт створено — перевірте пошту: там посилання для підтвердження. Після цього увійдіть.",
|
||||||
|
"verifyEmailCta": "Запросити нове посилання для підтвердження"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Створити акаунт",
|
"title": "Створити акаунт",
|
||||||
@@ -947,7 +949,11 @@
|
|||||||
"invalidTitle": "Підтвердження електронної пошти",
|
"invalidTitle": "Підтвердження електронної пошти",
|
||||||
"invalidSubtitle": "Недійсне або прострочене посилання",
|
"invalidSubtitle": "Недійсне або прострочене посилання",
|
||||||
"invalidBody": "Це посилання для підтвердження недійсне. Переконайтеся, що ви відкрили повне посилання зі свого електронного листа, або надішліть запит на нове.",
|
"invalidBody": "Це посилання для підтвердження недійсне. Переконайтеся, що ви відкрили повне посилання зі свого електронного листа, або надішліть запит на нове.",
|
||||||
"backToLogin": "Назад до входу"
|
"backToLogin": "Назад до входу",
|
||||||
|
"resendEmail": "Адреса електронної пошти",
|
||||||
|
"resendButton": "Надіслати нове посилання",
|
||||||
|
"resendSent": "Якщо до цієї адреси прив'язаний аккаунт, нове посилання вже в дорозі — перевірте пошту.",
|
||||||
|
"resendFailed": "Не вдалося надіслати нове посилання. Спробуйте ще раз через кілька хвилин."
|
||||||
},
|
},
|
||||||
"banned": {
|
"banned": {
|
||||||
"title": "Ви забанені",
|
"title": "Ви забанені",
|
||||||
|
|||||||
+5
-1
@@ -40,7 +40,11 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (shouldRedirectAdminRequest(pathname, token)) {
|
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();
|
const nonce = createCspNonce();
|
||||||
|
|||||||
Reference in new issue
Block a user