feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m55s

Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:08:33 +02:00
1 parent 2ff08e5127
commit ed7db6e048
76 files changed
+4834 -1376

No files matched your search

File diff suppressed because it is too large. Load diff
+37 -5
View File
@@ -4,16 +4,25 @@ import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password"; import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
export type PrecheckResult = "ok" | "invalid" | "twofactor"; export type PrecheckResult =
| "ok"
| "invalid"
| "twofactor"
| "unverified"
| "captcha";
/** /**
* Validates username+password WITHOUT creating a session, and reports whether a * Validates username+password WITHOUT creating a session, and reports whether a
* TOTP code is still required. Lets the login form do the two-step 2FA flow. * TOTP code is still required. Lets the login form do the two-step 2FA flow.
* Also enforces captcha + optional email-verification when configured.
*/ */
export async function precheckLogin( export async function precheckLogin(
username: string, username: string,
password: string, password: string,
captchaToken?: string | null,
): Promise<PrecheckResult> { ): Promise<PrecheckResult> {
const u = String(username ?? "") const u = String(username ?? "")
.normalize("NFC") .normalize("NFC")
@@ -21,14 +30,29 @@ export async function precheckLogin(
const p = String(password ?? ""); const p = String(password ?? "");
if (!u || !p) return "invalid"; if (!u || !p) return "invalid";
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok) const ip = await clientIp();
return "invalid"; if (!(await rateLimit(`precheck:${ip}`, 10, 5 * 60_000)).ok) return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null; const cfg = await captchaConfig();
if (cfg.provider !== "none") {
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
}
let user: {
password: string;
twoFactorConfirmedAt: Date | null;
mail: string | null;
mailVerified: string;
} | null;
try { try {
user = await prisma.user.findUnique({ user = await prisma.user.findUnique({
where: { username: u }, where: { username: u },
select: { password: true, twoFactorConfirmedAt: true }, select: {
password: true,
twoFactorConfirmedAt: true,
mail: true,
mailVerified: true,
},
}); });
} catch { } catch {
return "invalid"; return "invalid";
@@ -50,5 +74,13 @@ export async function precheckLogin(
}); });
if (!res.valid) return "invalid"; if (!res.valid) return "invalid";
if (
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
return "unverified";
}
return user.twoFactorConfirmedAt ? "twofactor" : "ok"; return user.twoFactorConfirmedAt ? "twofactor" : "ok";
} }
File diff suppressed because it is too large. Load diff
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockGet = vi.hoisted(() => vi.fn());
const mockSendMail = vi.hoisted(() => vi.fn());
const mockGetTranslations = vi.hoisted(() => vi.fn());
vi.mock("@/env", () => ({
env: {
APP_KEY: "test-app-key-for-hmac",
AUTH_SECRET: "",
APP_URL: "http://localhost:3000",
HOTEL_NAME: "TestHotel",
},
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: mockGet },
}));
vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail,
}));
vi.mock("next-intl/server", () => ({
getTranslations: mockGetTranslations,
}));
import {
isValidVerificationToken,
sendVerification,
verificationToken,
} from "./email-verify";
beforeEach(() => {
vi.clearAllMocks();
mockGet.mockResolvedValue("TestHotel");
mockSendMail.mockResolvedValue(true);
mockGetTranslations.mockRejectedValue(new Error("missing"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("email verification tokens", () => {
it("issues timestamped HMAC tokens that validate", async () => {
const token = await verificationToken("[email protected]");
expect(token).toMatch(/^\d+\.[a-f0-9]{64}$/);
expect(await isValidVerificationToken("[email protected]", token)).toBe(
true,
);
});
it("rejects legacy forever-valid digests", async () => {
const legacy = "a".repeat(64);
expect(
await isValidVerificationToken("[email protected]", legacy),
).toBe(false);
});
it("rejects expired tokens", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const token = await verificationToken("[email protected]");
vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); // > 24h
expect(await isValidVerificationToken("[email protected]", token)).toBe(
false,
);
});
it("sends mail with a verify link", async () => {
mockGetTranslations.mockResolvedValue(
((key: string, values?: { hotel?: string }) => {
const map: Record<string, string> = {
subject: `Verify your email · ${values?.hotel}`,
heading: "Verify your email",
body: `Welcome to ${values?.hotel}!`,
button: "Verify email",
fallback: "Paste this link:",
};
return map[key] ?? key;
}) as never,
);
await sendVerification("[email protected]");
expect(mockSendMail).toHaveBeenCalledWith(
"[email protected]",
expect.stringContaining("Verify your email"),
expect.stringContaining("/verify?token="),
);
});
});
File diff suppressed because it is too large. Load diff
+64 -2
View File
@@ -1,16 +1,18 @@
"use server"; "use server";
import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
createEventSchema, createEventSchema,
eventPrizeSchema, eventPrizeSchema,
eventTypeSchema, eventTypeSchema,
eventWinnerSchema, eventWinnerSchema,
registerForEventSchema,
updateEventSchema, updateEventSchema,
} from "@/lib/validators/event"; } from "@/lib/validators/event";
@@ -193,3 +195,63 @@ export const addEventWinner = adminAction(
return actionOk({ id: winner.id }); return actionOk({ id: winner.id });
}, },
); );
// ── Public site: register ───────────────────────────────────────────
export const registerForEvent = authAction(
{
schema: registerForEventSchema,
rateLimitKey: "event-register",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const userId = Number(ctx.session.user.id);
if (!Number.isInteger(userId) || userId <= 0) {
return actionError("Unauthorized");
}
const event = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.eventId },
include: {
type: true,
_count: { select: { registrations: true } },
},
});
if (!event) return actionError("Event not found");
if (event.status !== "published") {
return actionError("This event is not open for registration");
}
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
return actionError("This event has already ended");
}
if (event.type.minRank > 0) {
const rank = Number(ctx.session.user.rank ?? 0);
if (rank < event.type.minRank) {
return actionError("Your rank is too low to join this event");
}
}
if (
event.maxPlayers != null &&
event._count.registrations >= event.maxPlayers
) {
return actionError("This event is full");
}
const existing = await prisma.websiteEventRegistration.findUnique({
where: {
eventId_userId: { eventId: event.id, userId },
},
});
if (existing) return actionError("You are already registered");
await prisma.websiteEventRegistration.create({
data: { eventId: event.id, userId },
});
revalidatePath("/events");
revalidatePath(`/events/${event.id}`);
return actionOk({ eventId: event.id });
},
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+217 -43
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -40,6 +40,15 @@ vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail, sendMail: mockSendMail,
})); }));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: vi.fn().mockResolvedValue({
provider: "none",
siteKey: "",
field: "",
}),
verifyCaptcha: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/rate-limit", () => ({
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }), rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
clientIp: vi.fn().mockResolvedValue("127.0.0.1"), clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
+13 -2
View File
@@ -6,6 +6,7 @@ import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { sendMail } from "@/lib/services/email"; import { sendMail } from "@/lib/services/email";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -20,9 +21,19 @@ export async function requestReset(formData: FormData): Promise<void> {
.trim() .trim()
.toLowerCase(); .toLowerCase();
const ip = await clientIp();
// CAPTCHA when a provider is configured (mirrors register).
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip))) {
redirect("/forgot?error=captcha");
}
}
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse. // Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)) const allowed = (await rateLimit(`reset:${ip}`, 3, 15 * 60_000)).ok;
.ok;
// Always respond the same way so we don't reveal which emails exist. // Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
+113 -2
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+191 -94
View File
File diff suppressed because it is too large. Load diff
+59
View File
@@ -0,0 +1,59 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { Home, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { ErrorScreen } from "@/components/error-screen";
/**
* Site route-segment error boundary. Renders inside the site shell layout.
* Never shows the raw error to the user.
*/
export default function SiteErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("pages.error");
useEffect(() => {
console.error(error);
Sentry.captureException(error);
}, [error]);
return (
<ErrorScreen
code={t("code")}
title={t("title")}
subtitle={t("subtitle")}
body={t("body")}
actions={
<>
<button
type="button"
className="btn btn-primary"
onClick={() => reset()}
>
<RefreshCw className="error-screen-btn-icon" aria-hidden />
{t("tryAgain")}
</button>
<Link className="btn btn-outline" href="/">
<Home className="error-screen-btn-icon" aria-hidden />
{t("backHome")}
</Link>
</>
}
footer={
error.digest ? (
<p className="error-screen-digest">
{t("reference", { digest: error.digest })}
</p>
) : null
}
/>
);
}
@@ -0,0 +1,42 @@
"use client";
import { useTranslations } from "next-intl";
import { registerForEvent } from "@/actions/events";
import { useServerAction } from "@/hooks/use-server-action";
export function EventRegisterButton({
eventId,
disabled,
disabledReason,
}: {
eventId: number;
disabled?: boolean;
disabledReason?: string;
}) {
const t = useTranslations("pages.events");
const { run, isPending } = useServerAction();
if (disabled) {
return (
<p className="muted" style={{ margin: 0 }}>
{disabledReason ?? t("cannotRegister")}
</p>
);
}
return (
<button
type="button"
className="btn btn-primary"
disabled={isPending}
onClick={() =>
run(() => registerForEvent({ eventId }), {
successMessage: t("registerSuccess"),
errorMessage: t("registerError"),
})
}
>
{isPending ? t("registering") : t("register")}
</button>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+26 -2
View File
@@ -1,15 +1,20 @@
import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { requestReset } from "@/actions/password-reset"; import { requestReset } from "@/actions/password-reset";
import { CaptchaWidget } from "@/components/auth/captcha-widget";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { captchaConfig } from "@/lib/services/captcha";
export default async function ForgotPage({ export default async function ForgotPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ sent?: string }>; searchParams: Promise<{ sent?: string; error?: string }>;
}) { }) {
const t = await getTranslations("pages.forgot"); const t = await getTranslations("pages.forgot");
const { sent } = await searchParams; const { sent, error } = await searchParams;
const cfg = await captchaConfig();
const nonce = (await headers()).get("x-nonce") ?? undefined;
return ( return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
@@ -30,6 +35,25 @@ export default async function ForgotPage({
autoComplete="email" autoComplete="email"
required required
/> />
<CaptchaWidget
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
{error === "captcha" ? (
<p
style={{
color: "var(--color-danger)",
textAlign: "center",
margin: 0,
}}
>
{t("errorCaptcha")}
</p>
) : null}
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
{t("sendResetLink")} {t("sendResetLink")}
</button> </button>
+15
View File
@@ -0,0 +1,15 @@
export default function FriendsLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
+58 -4
View File
@@ -1,6 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { removeFriendship } from "@/actions/messenger";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
@@ -11,16 +13,38 @@ export const dynamic = "force-dynamic";
export const metadata = { title: "Friends" }; export const metadata = { title: "Friends" };
export default async function FriendsPage() { type SearchParams = Promise<{ removed?: string; error?: string }>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function FriendsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const t = await getTranslations("pages.friends"); const t = await getTranslations("pages.friends");
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id); const userId = Number(session.user.id);
const { removed, error } = await searchParams;
// Friendships are bidirectional and stored once: the session user can appear // Friendships are stored as TWO directional rows (user_one_id→user_two_id
// as either user_one_id or user_two_id, with the *other* column being the // and the reverse). Read both directions, then dedupe the friend ids.
// friend. Read both directions, then dedupe the friend ids.
const friendships = await prisma.messengerFriendships const friendships = await prisma.messengerFriendships
.findMany({ .findMany({
where: { where: {
@@ -60,8 +84,28 @@ export default async function FriendsPage() {
), ),
]); ]);
const errorMessage =
error === "not_found"
? t("errors.notFound")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
{removed === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.removed")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard <ContentCard
icon="🤝" icon="🤝"
title={t("title")} title={t("title")}
@@ -120,6 +164,16 @@ export default async function FriendsPage() {
</p> </p>
<OnlineBadge online={isOnline} /> <OnlineBadge online={isOnline} />
</div> </div>
<form action={removeFriendship}>
<input
type="hidden"
name="friendId"
value={String(friend.id)}
/>
<button type="submit" className="btn">
{t("remove")}
</button>
</form>
</div> </div>
); );
})} })}
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { postThread } from "@/actions/social"; import { postThread } from "@/actions/social";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -8,13 +9,34 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type SearchParams = Promise<{ error?: string }>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function NewThreadPage({ export default async function NewThreadPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: SearchParams;
}) { }) {
const t = await getTranslations("pages.guildForumNew"); const t = await getTranslations("pages.guildForumNew");
const { id } = await params; const { id } = await params;
const { error } = await searchParams;
const guildId = Number(id); const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound(); if (!Number.isInteger(guildId) || guildId <= 0) notFound();
@@ -35,8 +57,25 @@ export default async function NewThreadPage({
if (!guild) notFound(); if (!guild) notFound();
const errorMessage =
error === "invalid"
? t("errors.invalid")
: error === "not_found"
? t("errors.notFound")
: error === "ratelimit"
? t("errors.ratelimit")
: error
? t("errors.error")
: null;
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link> <Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link>
</p> </p>
+28
View File
@@ -1,11 +1,30 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const revalidate = 300; export const revalidate = 300;
type SearchParams = Promise<{ posted?: string }>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
type ThreadRow = { type ThreadRow = {
id: number; id: number;
openerId: number | null; openerId: number | null;
@@ -24,11 +43,14 @@ function formatTimestamp(ts: number | null | undefined): string {
export default async function GuildForumPage({ export default async function GuildForumPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: SearchParams;
}) { }) {
const t = await getTranslations("pages.guildForum"); const t = await getTranslations("pages.guildForum");
const { id } = await params; const { id } = await params;
const { posted } = await searchParams;
const guildId = Number(id); const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound(); if (!Number.isInteger(guildId) || guildId <= 0) notFound();
@@ -89,6 +111,12 @@ export default async function GuildForumPage({
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
{posted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
</div>
) : null}
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}`}>{t("backToGuild")}</Link> <Link href={`/guilds/${guild.id}`}>{t("backToGuild")}</Link>
</p> </p>
+50 -3
View File
@@ -1,19 +1,42 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { formatDate } from "@/lib/format-date";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { createTicket } from "@/actions/help-tickets"; import { createTicket } from "@/actions/help-tickets";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type SearchParams = Promise<{ created?: string; error?: string }>;
export default async function HelpTicketsPage() { function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function HelpTicketsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.helpTickets"); const t = await getTranslations("pages.helpTickets");
const { created, error } = await searchParams;
const userId = Number(session.user.id); const userId = Number(session.user.id);
@@ -33,8 +56,30 @@ export default async function HelpTicketsPage() {
const openCount = tickets.filter((t) => t.open).length; const openCount = tickets.filter((t) => t.open).length;
const closedCount = tickets.length - openCount; const closedCount = tickets.length - openCount;
const errorMessage =
error === "invalid"
? t("errors.invalid")
: error === "moderated"
? t("errors.moderated")
: error === "ratelimit"
? t("errors.ratelimit")
: error
? t("errors.error")
: null;
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
{created === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.created")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard icon="🎫" title={t("title")} subtitle={t("subtitle")}> <ContentCard icon="🎫" title={t("title")} subtitle={t("subtitle")}>
<div className="stat-grid"> <div className="stat-grid">
<StatBlock icon="🎫" value={tickets.length} label={t("statTotal")} /> <StatBlock icon="🎫" value={tickets.length} label={t("statTotal")} />
@@ -99,7 +144,9 @@ export default async function HelpTicketsPage() {
{t2.open ? t("statusOpen") : t("statusClosed")} {t2.open ? t("statusOpen") : t("statusClosed")}
</span> </span>
</td> </td>
<td className="muted">{formatDate(t2.createdAt, "datetime", "")}</td> <td className="muted">
{formatDate(t2.createdAt, "datetime", "")}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
export default function MessagesLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
File diff suppressed because it is too large. Load diff
+51 -2
View File
@@ -1,20 +1,39 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { formatDate } from "@/lib/format-date";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { postComment } from "@/actions/article-comments"; import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions"; import { toggleReaction } from "@/actions/article-reactions";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { cacheQuery } from "@/lib/prisma-cache"; import { cacheQuery } from "@/lib/prisma-cache";
import { sanitize } from "@/lib/sanitize"; import { sanitize } from "@/lib/sanitize";
import { SanitizedHtml } from "@/components/shared/sanitized-html";
export const revalidate = 60; export const revalidate = 60;
type SearchParams = Promise<{ comment?: string; error?: string }>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export async function generateStaticParams() { export async function generateStaticParams() {
const slugs = await prisma.websiteArticles const slugs = await prisma.websiteArticles
.findMany({ .findMany({
@@ -62,10 +81,13 @@ const REACTIONS: { key: string; emoji: string }[] = [
export default async function ArticlePage({ export default async function ArticlePage({
params, params,
searchParams,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
searchParams: SearchParams;
}) { }) {
const { slug } = await params; const { slug } = await params;
const { comment, error } = await searchParams;
const t = await getTranslations("pages.article"); const t = await getTranslations("pages.article");
const article = await cacheQuery( const article = await cacheQuery(
@@ -145,8 +167,34 @@ export default async function ArticlePage({
} }
} }
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "moderated"
? t("errors.moderated")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error === "not_found"
? t("errors.notFound")
: error
? t("errors.error")
: null;
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
{comment === "posted" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard <ContentCard
icon="📰" icon="📰"
title={article.title} title={article.title}
@@ -290,6 +338,7 @@ export default async function ArticlePage({
{loggedIn ? ( {loggedIn ? (
<form action={postComment} className="card"> <form action={postComment} className="card">
<input type="hidden" name="articleId" value={String(articleId)} /> <input type="hidden" name="articleId" value={String(articleId)} />
<input type="hidden" name="slug" value={slug} />
<label <label
htmlFor="comment" htmlFor="comment"
style={{ style={{
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma";
export const revalidate = 60;
export const metadata = { title: "Polls" };
export default async function PollsPage() {
const t = await getTranslations("pages.polls");
const now = new Date();
const polls = await prisma.websitePoll
.findMany({
where: {
status: { in: ["active", "closed"] },
OR: [{ startsAt: null }, { startsAt: { lte: now } }],
},
orderBy: { createdAt: "desc" },
take: 50,
include: {
_count: { select: { questions: true } },
},
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="📊" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={polls.length === 0}>
{polls.length === 0 ? (
<EmptyState icon="📊">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{polls.map((poll) => {
const ended =
poll.status === "closed" ||
(poll.endsAt != null && poll.endsAt.getTime() < Date.now());
return (
<Link
key={poll.id}
href={`/polls/${poll.id}`}
className="card hover"
style={{
color: "inherit",
textDecoration: "none",
display: "grid",
gap: "0.35rem",
}}
>
<p
className="muted"
style={{ margin: 0, fontSize: "0.75rem" }}
>
{ended ? t("statusClosed") : t("statusActive")}
{" · "}
{t("questionsCount", { count: poll._count.questions })}
</p>
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
{poll.title}
</h3>
{poll.description ? (
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
{excerpt(poll.description, 120)}
</p>
) : null}
<p
className="muted"
style={{ margin: 0, fontSize: "0.85rem" }}
>
{poll.endsAt
? t("ends", {
date: formatDate(poll.endsAt, "datetime", ""),
})
: formatDate(poll.createdAt, "date", "")}
</p>
</Link>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+50 -3
View File
@@ -1,17 +1,40 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { formatDate } from "@/lib/format-date"; import type { CSSProperties } from "react";
import { postShout } from "@/actions/radio-shouts"; import { postShout } from "@/actions/radio-shouts";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type SearchParams = Promise<{ posted?: string; error?: string }>;
export default async function RadioShoutsPage() { function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function RadioShoutsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const t = await getTranslations("pages.radioShouts"); const t = await getTranslations("pages.radioShouts");
const { posted, error } = await searchParams;
const [shouts, imagerBase, session] = await Promise.all([ const [shouts, imagerBase, session] = await Promise.all([
prisma.radioShouts prisma.radioShouts
@@ -41,8 +64,30 @@ export default async function RadioShoutsPage() {
: []; : [];
const authorById = new Map(authors.map((a) => [a.id, a])); const authorById = new Map(authors.map((a) => [a.id, a]));
const errorMessage =
error === "invalid"
? t("errors.invalid")
: error === "moderated"
? t("errors.moderated")
: error === "ratelimit"
? t("errors.ratelimit")
: error
? t("errors.error")
: null;
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
{posted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard icon="📣" title={t("title")} subtitle={t("subtitle")} /> <ContentCard icon="📣" title={t("title")} subtitle={t("subtitle")} />
{isLoggedIn ? ( {isLoggedIn ? (
@@ -122,7 +167,9 @@ export default async function RadioShoutsPage() {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: "0 0 0.25rem" }}> <p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "} <strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
<span className="muted">{formatDate(s.createdAt, "datetime", "")}</span> <span className="muted">
{formatDate(s.createdAt, "datetime", "")}
</span>
</p> </p>
<p style={{ margin: 0 }}>{s.message}</p> <p style={{ margin: 0 }}>{s.message}</p>
</div> </div>
File diff suppressed because it is too large. Load diff
-2
View File
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type Achievement = { type Achievement = {
name: string; name: string;
category: string; category: string;
-2
View File
@@ -5,8 +5,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
const CRITICAL = new Set(["critical", "error", "danger"]); const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]); const WARNING = new Set(["warning", "warn"]);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-2
View File
@@ -8,8 +8,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
export default async function AdminEmailTemplates() { export default async function AdminEmailTemplates() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
+44 -11
View File
@@ -1,11 +1,14 @@
"use client"; "use client";
import { Pencil } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { deleteEvent } from "@/actions/events";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { DataTable } from "@/components/admin/data-table"; import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types"; import type { DataTableColumn, PaginatedResult } from "@/types";
interface EventRow { interface EventRow {
@@ -35,6 +38,8 @@ interface EventsTableProps {
export function EventsTable({ data }: EventsTableProps) { export function EventsTable({ data }: EventsTableProps) {
const t = useTranslations("pages.admin.events"); const t = useTranslations("pages.admin.events");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
published: t("statusPublished"), published: t("statusPublished"),
@@ -43,6 +48,20 @@ export function EventsTable({ data }: EventsTableProps) {
completed: t("statusCompleted"), completed: t("statusCompleted"),
}; };
async function handleDelete(row: EventRow) {
const ok = await confirm({
title: "Delete event",
description: `Delete "${row.title}"? This cannot be undone.`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deleteEvent({ id: row.id }), {
successMessage: "Event deleted!",
errorMessage: "Failed to delete event.",
});
}
const columns: DataTableColumn<EventRow>[] = [ const columns: DataTableColumn<EventRow>[] = [
{ key: "id", label: t("colId"), sortable: true }, { key: "id", label: t("colId"), sortable: true },
{ {
@@ -88,20 +107,34 @@ export function EventsTable({ data }: EventsTableProps) {
key: "actions", key: "actions",
label: t("colActions"), label: t("colActions"),
render: (_, row) => ( render: (_, row) => (
<Button variant="ghost" size="icon" asChild> <div className="flex items-center gap-1">
<Link href={`/admin/events/${row.id}`}> <Button variant="ghost" size="icon" asChild>
<Pencil className="h-4 w-4" /> <Link href={`/admin/events/${row.id}`}>
</Link> <Pencil className="h-4 w-4" />
</Button> </Link>
</Button>
<Button
variant="ghost"
size="icon"
disabled={isPending}
onClick={() => handleDelete(row)}
aria-label="Delete event"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
), ),
}, },
]; ];
return ( return (
<DataTable <>
data={data} <DataTable
columns={columns} data={data}
searchPlaceholder={t("searchPlaceholder")} columns={columns}
/> searchPlaceholder={t("searchPlaceholder")}
/>
{confirmDialog}
</>
); );
} }
-2
View File
@@ -3,8 +3,6 @@ import { FaviconForm } from "./favicon-form";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
export default async function AdminFaviconPage() { export default async function AdminFaviconPage() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -55,52 +56,6 @@ interface PageMeta {
total: number; total: number;
} }
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
async function runSseImport(
url: string,
body: unknown,
onDone: (classname: string) => void,
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.body) {
toast.error("No response stream");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
onDone(evt.classname);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
onComplete(succeeded, failed);
}
// ── Source form ─────────────────────────────────────────────────────────────── // ── Source form ───────────────────────────────────────────────────────────────
interface SourceFormProps { interface SourceFormProps {
@@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatarUrl } from "@/lib/imager"; import { getAvatarUrl } from "@/lib/imager";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
// ── Shared types ───────────────────────────────────────────────────────────── // ── Shared types ─────────────────────────────────────────────────────────────
@@ -43,52 +44,6 @@ type Mode = "libs" | "sets";
const PER_PAGE = 100; const PER_PAGE = 100;
// ── Shared SSE reader ─────────────────────────────────────────────────────────
async function runSseImport(
url: string,
body: unknown,
onDone: (label: string) => void,
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.body) {
toast.error("No response stream");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
onDone(evt.classname);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
onComplete(succeeded, failed);
}
// ── Avatar preview (renders a mini avatar wearing the item) ─────────────────── // ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
// A default dressed avatar; the previewed part overrides/adds its slot so the // A default dressed avatar; the previewed part overrides/adds its slot so the
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { readSseStream } from "@/lib/sse-client";
interface PetItem { interface PetItem {
lib: string; lib: string;
@@ -109,34 +110,22 @@ export function ImportPetsClient() {
setBatchProgress(null); setBatchProgress(null);
return; return;
} }
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0; let done = 0;
let succeeded = 0; let succeeded = 0;
let failed = 0; let failed = 0;
while (true) { await readSseStream(res.body, (evt) => {
const { value, done: streamDone } = await reader.read(); if (evt.type === "item_progress" && evt.status === "done") {
if (streamDone) break; done++;
buf += decoder.decode(value, { stream: true }); markDone(String(evt.classname ?? ""));
const parts = buf.split("\n\n"); setBatchProgress({ done, total: items.length });
buf = parts.pop() ?? ""; } else if (evt.type === "item_progress" && evt.status === "failed") {
for (const part of parts) { done++;
if (!part.startsWith("data: ")) continue; setBatchProgress({ done, total: items.length });
const evt = JSON.parse(part.slice(6)); } else if (evt.type === "batch_complete") {
if (evt.type === "item_progress" && evt.status === "done") { succeeded = Number(evt.succeeded ?? 0);
done++; failed = Number(evt.failed ?? 0);
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
} }
} });
setBatchProgress(null); setBatchProgress(null);
setSelected(new Set()); setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`); if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
+2
View File
@@ -14,6 +14,8 @@ import { setCsrfCookie } from "@/lib/foundation/security";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth: requireStaff, CSRF cookie, and optional 2FA gate cannot be
// statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminLayout({ export default async function AdminLayout({
-2
View File
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the // navigator_flatcats — emulator-owned flat (private-room) categories shown in the
// navigator. The Prisma delegate exists because the table has a primary key. // navigator. The Prisma delegate exists because the table has a primary key.
type Flatcat = { type Flatcat = {
+44 -11
View File
@@ -1,11 +1,14 @@
"use client"; "use client";
import { Pencil } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { deletePoll } from "@/actions/polls";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { DataTable } from "@/components/admin/data-table"; import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types"; import type { DataTableColumn, PaginatedResult } from "@/types";
interface PollRow { interface PollRow {
@@ -31,6 +34,8 @@ interface PollsTableProps {
export function PollsTable({ data }: PollsTableProps) { export function PollsTable({ data }: PollsTableProps) {
const t = useTranslations("pages.admin.polls"); const t = useTranslations("pages.admin.polls");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
active: t("statusActive"), active: t("statusActive"),
@@ -38,6 +43,20 @@ export function PollsTable({ data }: PollsTableProps) {
closed: t("statusClosed"), closed: t("statusClosed"),
}; };
async function handleDelete(row: PollRow) {
const ok = await confirm({
title: "Delete poll",
description: `Delete "${row.title}"? This cannot be undone.`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deletePoll({ id: row.id }), {
successMessage: "Poll deleted!",
errorMessage: "Failed to delete poll.",
});
}
const columns: DataTableColumn<PollRow>[] = [ const columns: DataTableColumn<PollRow>[] = [
{ key: "id", label: t("colId"), sortable: true }, { key: "id", label: t("colId"), sortable: true },
{ {
@@ -82,20 +101,34 @@ export function PollsTable({ data }: PollsTableProps) {
key: "actions", key: "actions",
label: t("colActions"), label: t("colActions"),
render: (_, row) => ( render: (_, row) => (
<Button variant="ghost" size="icon" asChild> <div className="flex items-center gap-1">
<Link href={`/admin/polls/${row.id}`}> <Button variant="ghost" size="icon" asChild>
<Pencil className="h-4 w-4" /> <Link href={`/admin/polls/${row.id}`}>
</Link> <Pencil className="h-4 w-4" />
</Button> </Link>
</Button>
<Button
variant="ghost"
size="icon"
disabled={isPending}
onClick={() => handleDelete(row)}
aria-label="Delete poll"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
), ),
}, },
]; ];
return ( return (
<DataTable <>
data={data} <DataTable
columns={columns} data={data}
searchPlaceholder={t("searchPlaceholder")} columns={columns}
/> searchPlaceholder={t("searchPlaceholder")}
/>
{confirmDialog}
</>
); );
} }
@@ -241,6 +241,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
type: "boolean", type: "boolean",
defaultValue: "0", defaultValue: "0",
}, },
{
key: "require_email_verification",
label: "Require email verification",
description:
"Block login until the account email is verified (accounts without email are unaffected).",
type: "boolean",
defaultValue: "0",
},
{ {
key: "max_accounts_per_ip", key: "max_accounts_per_ip",
label: "Max accounts per IP", label: "Max accounts per IP",
-2
View File
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type SubRow = { type SubRow = {
id: number; id: number;
userId: number | null; userId: number | null;
-2
View File
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type Tag = { type Tag = {
id: bigint; id: bigint;
name: string; name: string;
-2
View File
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { VouchersClient, type VoucherRow } from "./vouchers-client"; import { VouchersClient, type VoucherRow } from "./vouchers-client";
export const dynamic = "force-dynamic";
export default async function AdminVouchers() { export default async function AdminVouchers() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
-2
View File
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { WordFilterClient } from "./wordfilter-client"; import { WordFilterClient } from "./wordfilter-client";
export const dynamic = "force-dynamic";
export default async function AdminWordFilter() { export default async function AdminWordFilter() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) {
@@ -56,6 +56,7 @@ export const POST = withAdmin(
return runSseBatch<BatchItem>({ return runSseBatch<BatchItem>({
items, items,
concurrency: body.concurrency || 2, concurrency: body.concurrency || 2,
signal: request.signal,
labelOf: (it) => it.classname, labelOf: (it) => it.classname,
worker: async (it, _index, report) => { worker: async (it, _index, report) => {
const entry = entryMap.get(it.classname); const entry = entryMap.get(it.classname);
@@ -95,6 +95,7 @@ export const POST = withAdmin(
return runSseBatch({ return runSseBatch({
items: allItems, items: allItems,
concurrency: 2, concurrency: 2,
signal: request.signal,
labelOf: (it) => `${it.sourceName}/${it.classname}`, labelOf: (it) => `${it.sourceName}/${it.classname}`,
worker: async (it, _index, report) => { worker: async (it, _index, report) => {
const allSrc = await listSources(); const allSrc = await listSources();
@@ -32,6 +32,7 @@ export const POST = withAdmin(
return runSseBatch<FigureBatchItem>({ return runSseBatch<FigureBatchItem>({
items, items,
concurrency, concurrency,
signal: request.signal,
labelOf: (it) => it.lib, labelOf: (it) => it.lib,
worker: async (it, _index, report) => { worker: async (it, _index, report) => {
const r = await importSingleFigure({ lib: it.lib, onProgress: report }); const r = await importSingleFigure({ lib: it.lib, onProgress: report });
@@ -30,6 +30,7 @@ export const POST = withAdmin(
return runSseBatch<ClothingSetBatchItem>({ return runSseBatch<ClothingSetBatchItem>({
items, items,
concurrency, concurrency,
signal: request.signal,
labelOf: (it) => `${it.setType}-${it.setId}`, labelOf: (it) => `${it.setType}-${it.setId}`,
worker: async (it, _index, report) => { worker: async (it, _index, report) => {
const r = await importClothingSet({ const r = await importClothingSet({
@@ -30,6 +30,7 @@ export const POST = withAdmin(
return runSseBatch<EffectMapEntry>({ return runSseBatch<EffectMapEntry>({
items, items,
concurrency, concurrency,
signal: request.signal,
labelOf: (it) => it.lib, labelOf: (it) => it.lib,
worker: async (it, _index, report) => { worker: async (it, _index, report) => {
const r = await importSingleEffect({ const r = await importSingleEffect({
@@ -29,6 +29,7 @@ export const POST = withAdmin(
return runSseBatch<PetBatchItem>({ return runSseBatch<PetBatchItem>({
items, items,
concurrency, concurrency,
signal: request.signal,
labelOf: (it) => it.lib, labelOf: (it) => it.lib,
worker: async (it, _index, report) => { worker: async (it, _index, report) => {
const r = await importSinglePet({ lib: it.lib, onProgress: report }); const r = await importSinglePet({ lib: it.lib, onProgress: report });
-1
View File
@@ -64,7 +64,6 @@ export async function GET(req: Request) {
"content-type": "text/event-stream; charset=utf-8", "content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform", "cache-control": "no-store, no-transform",
connection: "keep-alive", connection: "keep-alive",
"access-control-allow-origin": "*",
}, },
}); });
} }
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Script from "next/script";
export type CaptchaPublicConfig = {
provider: string;
siteKey?: string;
field?: string;
};
/** Renders Turnstile / reCAPTCHA widget + script when a provider is configured. */
export function CaptchaWidget({
captcha,
nonce,
className,
}: {
captcha: CaptchaPublicConfig;
nonce?: string;
className?: string;
}) {
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
if (!showCaptcha) return null;
return (
<div className={className}>
{captcha.provider === "turnstile" ? (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
nonce={nonce}
/>
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
</>
) : null}
{captcha.provider === "recaptcha" ? (
<>
<Script
src="https://www.google.com/recaptcha/api.js"
async
defer
nonce={nonce}
/>
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
</>
) : null}
</div>
);
}
/** Read the provider token from a form (widget injects a hidden input). */
export function readCaptchaToken(
form: HTMLFormElement,
captcha: CaptchaPublicConfig,
): string {
if (captcha.provider === "none" || !captcha.field) return "";
const fd = new FormData(form);
return String(fd.get(captcha.field) ?? "").normalize("NFC");
}
+38 -7
View File
@@ -3,8 +3,19 @@
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck"; import { precheckLogin } from "@/actions/auth-precheck";
import {
CaptchaWidget,
type CaptchaPublicConfig,
readCaptchaToken,
} from "@/components/auth/captcha-widget";
export function HomeLoginForm() { export function HomeLoginForm({
captcha = { provider: "none" },
nonce,
}: {
captcha?: CaptchaPublicConfig;
nonce?: string;
} = {}) {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
@@ -12,17 +23,26 @@ export function HomeLoginForm() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setPending(true); setPending(true);
try { try {
if (!needs2fa) { if (!needs2fa) {
const pre = await precheckLogin(username, password); const captchaToken = readCaptchaToken(e.currentTarget, captcha);
const pre = await precheckLogin(username, password, captchaToken);
if (pre === "invalid") { if (pre === "invalid") {
setError("Invalid username or password"); setError("Invalid username or password");
return; return;
} }
if (pre === "captcha") {
setError("Captcha verification failed. Please try again.");
return;
}
if (pre === "unverified") {
setError("Please verify your email before signing in.");
return;
}
if (pre === "twofactor") { if (pre === "twofactor") {
setNeeds2fa(true); setNeeds2fa(true);
return; return;
@@ -52,7 +72,10 @@ export function HomeLoginForm() {
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800" className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
> >
<fieldset className="flex w-full flex-col gap-1"> <fieldset className="flex w-full flex-col gap-1">
<label htmlFor="login-username" className="block font-semibold text-gray-700 dark:text-gray-200"> <label
htmlFor="login-username"
className="block font-semibold text-gray-700 dark:text-gray-200"
>
Username Username
</label> </label>
<input <input
@@ -68,7 +91,10 @@ export function HomeLoginForm() {
/> />
</fieldset> </fieldset>
<fieldset className="flex w-full flex-col gap-1"> <fieldset className="flex w-full flex-col gap-1">
<label htmlFor="login-password" className="block font-semibold text-gray-700 dark:text-gray-200"> <label
htmlFor="login-password"
className="block font-semibold text-gray-700 dark:text-gray-200"
>
Password Password
</label> </label>
<input <input
@@ -85,7 +111,10 @@ export function HomeLoginForm() {
</fieldset> </fieldset>
{needs2fa ? ( {needs2fa ? (
<fieldset className="flex w-full flex-col gap-1"> <fieldset className="flex w-full flex-col gap-1">
<label htmlFor="login-2fa" className="block font-semibold text-gray-700 dark:text-gray-200"> <label
htmlFor="login-2fa"
className="block font-semibold text-gray-700 dark:text-gray-200"
>
2FA Code 2FA Code
</label> </label>
<input <input
@@ -99,7 +128,9 @@ export function HomeLoginForm() {
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }} style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
/> />
</fieldset> </fieldset>
) : null} ) : (
<CaptchaWidget captcha={captcha} nonce={nonce} />
)}
{error ? ( {error ? (
<p className="m-0 text-center text-sm text-red-600 dark:text-red-400"> <p className="m-0 text-center text-sm text-red-600 dark:text-red-400">
{error} {error}
File diff suppressed because it is too large. Load diff
+12 -1
View File
@@ -1,10 +1,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { HomeLoginForm } from "@/components/auth/home-login-form"; import { HomeLoginForm } from "@/components/auth/home-login-form";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { avatarImageUrl, excerpt } from "@/lib/format"; import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export default async function GuestView() { export default async function GuestView() {
@@ -15,6 +17,8 @@ export default async function GuestView() {
"habbo_imaging_url", "habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage", "https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? ""; )) ?? "";
const cfg = await captchaConfig();
const nonce = (await headers()).get("x-nonce") ?? undefined;
let articles: Array<{ let articles: Array<{
slug: string; slug: string;
@@ -75,7 +79,14 @@ export default async function GuestView() {
className="p-3" className="p-3"
style={{ backgroundColor: "var(--color-surface)" }} style={{ backgroundColor: "var(--color-surface)" }}
> >
<HomeLoginForm /> <HomeLoginForm
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
</div> </div>
</div> </div>
+12
View File
@@ -59,6 +59,12 @@ export async function Navigation() {
<Link href="/guilds" className="dropdown-item" role="menuitem"> <Link href="/guilds" className="dropdown-item" role="menuitem">
{t("guilds")} {t("guilds")}
</Link> </Link>
<Link href="/events" className="dropdown-item" role="menuitem">
{t("events")}
</Link>
<Link href="/polls" className="dropdown-item" role="menuitem">
{t("polls")}
</Link>
</NavDropdown> </NavDropdown>
<NavDropdown <NavDropdown
@@ -196,6 +202,12 @@ export async function Navigation() {
<Link href="/guilds" className="dropdown-item" role="menuitem"> <Link href="/guilds" className="dropdown-item" role="menuitem">
{t("guilds")} {t("guilds")}
</Link> </Link>
<Link href="/events" className="dropdown-item" role="menuitem">
{t("events")}
</Link>
<Link href="/polls" className="dropdown-item" role="menuitem">
{t("polls")}
</Link>
</NavDropdown> </NavDropdown>
<NavDropdown <NavDropdown
+12 -3
View File
@@ -67,9 +67,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
const password = String(credentials?.password ?? ""); const password = String(credentials?.password ?? "");
if (!username || !password) return null; if (!username || !password) return null;
const ip = await clientIp();
// Throttle login attempts per IP (10 per 5 min) against credential stuffing. // Throttle login attempts per IP (10 per 5 min) against credential stuffing.
if (!(await rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000)).ok) if (!(await rateLimit(`login:${ip}`, 10, 5 * 60_000)).ok) return null;
return null;
const user = await prisma.user.findUnique({ where: { username } }); const user = await prisma.user.findUnique({ where: { username } });
if (!user) { if (!user) {
@@ -90,6 +91,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}); });
if (!res.valid) return null; if (!res.valid) return null;
if (
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
return null;
}
if (res.upgradedHash) { if (res.upgradedHash) {
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
@@ -117,7 +126,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
await prisma.websiteLoginLogs.create({ await prisma.websiteLoginLogs.create({
data: { data: {
userId: user.id, userId: user.id,
ip: await clientIp(), ip,
userAgent: ua, userAgent: ua,
createdAt: new Date(), createdAt: new Date(),
}, },
+3
View File
@@ -242,6 +242,9 @@ export function handleActionError(error: unknown): ActionFailure {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
return fail("A database error occurred"); return fail("A database error occurred");
} }
if (error instanceof Error && error.name === "ActionError") {
return fail(error.message);
}
if (error instanceof Error && error.name === "ZodError") { if (error instanceof Error && error.name === "ZodError") {
return fail("Validation failed"); return fail("Validation failed");
} }
+75
View File
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGet = vi.hoisted(() => vi.fn());
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: mockGet },
}));
import { captchaConfig, verifyCaptcha } from "./captcha";
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe("verifyCaptcha", () => {
it("allows when provider is none", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "none";
return fallback ?? "";
});
expect(await verifyCaptcha(null)).toBe(true);
});
it("fails closed when provider set but secret missing", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "turnstile";
if (key === "turnstile_site_key") return "site-key";
if (key === "turnstile_secret") return "";
return fallback ?? "";
});
expect(await verifyCaptcha("tok")).toBe(false);
});
it("fails closed on provider API/network errors", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "turnstile";
if (key === "turnstile_site_key") return "site-key";
if (key === "turnstile_secret") return "secret";
return fallback ?? "";
});
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("network down")),
);
expect(await verifyCaptcha("tok", "1.2.3.4")).toBe(false);
});
it("accepts a successful provider response", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "recaptcha";
if (key === "recaptcha_site_key") return "site-key";
if (key === "recaptcha_secret") return "secret";
return fallback ?? "";
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
}),
);
expect(await verifyCaptcha("good-token")).toBe(true);
});
});
describe("captchaConfig", () => {
it("returns none by default", async () => {
mockGet.mockImplementation(async (_key: string, fallback?: string) => {
return fallback ?? "none";
});
const cfg = await captchaConfig();
expect(cfg.provider).toBe("none");
});
});
+15 -9
View File
@@ -3,9 +3,12 @@ import { siteSettings } from "@/lib/services/site-settings";
/** /**
* Server-side CAPTCHA verification, driven by website_settings so staff pick the * Server-side CAPTCHA verification, driven by website_settings so staff pick the
* provider in housekeeping. Supports Cloudflare Turnstile and Google reCAPTCHA * provider in housekeeping. Supports Cloudflare Turnstile and Google reCAPTCHA
* (the two AtomCMS offers, mutually exclusive). FAIL-OPEN by configuration: when * (the two AtomCMS offers, mutually exclusive).
* no provider/secret is set, registration isn't blocked; only an explicitly *
* configured provider with a failing/absent token blocks. * Behaviour:
* - provider "none" (or unset) → fail-open (allow)
* - provider configured but site key / secret missing, token absent, provider
* API error, or network failure → fail-closed (deny)
* *
* Settings keys: * Settings keys:
* captcha_provider = "turnstile" | "recaptcha" | "none" (default none) * captcha_provider = "turnstile" | "recaptcha" | "none" (default none)
@@ -23,7 +26,7 @@ const TURNSTILE_URL =
"https://challenges.cloudflare.com/turnstile/v0/siteverify"; "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify"; const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
/** Public config the register page needs to render the widget (no secrets). */ /** Public config the register/login pages need to render the widget (no secrets). */
export async function captchaConfig(): Promise<CaptchaConfig> { export async function captchaConfig(): Promise<CaptchaConfig> {
const provider = ( const provider = (
(await siteSettings.get("captcha_provider", "none")) ?? "none" (await siteSettings.get("captcha_provider", "none")) ?? "none"
@@ -45,18 +48,20 @@ export async function captchaConfig(): Promise<CaptchaConfig> {
return { provider: "none", siteKey: "", field: "" }; return { provider: "none", siteKey: "", field: "" };
} }
/** Verify a submitted token. Returns true when allowed (incl. fail-open). */ /** Verify a submitted token. Fail-closed when a provider is configured. */
export async function verifyCaptcha( export async function verifyCaptcha(
token: string | null, token: string | null,
remoteIp?: string, remoteIp?: string,
): Promise<boolean> { ): Promise<boolean> {
const cfg = await captchaConfig(); const cfg = await captchaConfig();
if (cfg.provider === "none" || !cfg.siteKey) return true; if (cfg.provider === "none") return true;
if (!cfg.siteKey) return false;
const secretKey = const secretKey =
cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret"; cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
const secret = (await siteSettings.get(secretKey, "")) ?? ""; const secret = (await siteSettings.get(secretKey, "")) ?? "";
if (!secret) return true; // configured but no secret — don't hard-block if (!secret) return false;
if (!token) return false; if (!token) return false;
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL; const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
@@ -74,10 +79,11 @@ export async function verifyCaptcha(
cache: "no-store", cache: "no-store",
}); });
clearTimeout(timer); clearTimeout(timer);
if (!res.ok) return false;
const data = (await res.json()) as { success?: boolean }; const data = (await res.json()) as { success?: boolean };
return data?.success === true; return data?.success === true;
} catch { } catch {
// Network/timeout — fail-open so a provider outage can't lock out signups. // Network/timeout/misconfig — fail-closed so captcha can't be bypassed.
return true; return false;
} }
} }
@@ -40,4 +40,26 @@ describe("import/core/sse-batch", () => {
failed: 1, failed: 1,
}); });
}); });
it("stops starting new chunks when AbortSignal fires", async () => {
const ac = new AbortController();
let started = 0;
const res = runSseBatch({
items: ["a", "b", "c", "d"],
concurrency: 1,
signal: ac.signal,
labelOf: (item) => item,
worker: async (item) => {
started++;
if (item === "a") ac.abort();
// Slow enough that abort lands before the next chunk starts.
await new Promise((r) => setTimeout(r, 20));
return { ok: true };
},
});
const events = await collect(res);
expect(started).toBeLessThan(4);
expect(events.some((e) => e.type === "batch_complete")).toBe(false);
expect(events[0]).toMatchObject({ type: "batch_start", total: 4 });
});
}); });
+34 -9
View File
@@ -7,6 +7,8 @@ export interface SseWorkerResult {
export interface RunSseBatchOptions<T> { export interface RunSseBatchOptions<T> {
items: T[]; items: T[];
concurrency: number; concurrency: number;
/** Abort when the client disconnects (e.g. `request.signal`). */
signal?: AbortSignal;
/** Label used as the `classname` field on item_progress (kept for the existing client parser). */ /** Label used as the `classname` field on item_progress (kept for the existing client parser). */
labelOf: (item: T) => string; labelOf: (item: T) => string;
/** Per-item worker. `report(status)` streams intermediate progress (e.g. 'downloading'). */ /** Per-item worker. `report(status)` streams intermediate progress (e.g. 'downloading'). */
@@ -20,21 +22,30 @@ export interface RunSseBatchOptions<T> {
/** /**
* Generic SSE batch runner. Emits the same event shape the furni client * Generic SSE batch runner. Emits the same event shape the furni client
* parser consumes: batch_start / item_progress / batch_complete. * parser consumes: batch_start / item_progress / batch_complete.
* Stops starting new chunks when `signal` aborts or the client cancels the stream.
*/ */
export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response { export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
const { items, labelOf, worker } = opts; const { items, labelOf, worker, signal } = opts;
const concurrency = Math.min(Math.max(opts.concurrency || 3, 1), 5); const concurrency = Math.min(Math.max(opts.concurrency || 3, 1), 5);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const ac = new AbortController();
if (signal) {
if (signal.aborted) ac.abort();
else signal.addEventListener("abort", () => ac.abort(), { once: true });
}
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
const send = (data: unknown) => { const send = (data: unknown) => {
if (ac.signal.aborted) return;
try { try {
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`), encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
); );
} catch { } catch {
/* stream closed by client */ /* stream closed by client */
ac.abort();
} }
}; };
@@ -46,9 +57,12 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
let withWarnings = 0; let withWarnings = 0;
for (let i = 0; i < items.length; i += concurrency) { for (let i = 0; i < items.length; i += concurrency) {
if (ac.signal.aborted) break;
const chunk = items.slice(i, i + concurrency); const chunk = items.slice(i, i + concurrency);
await Promise.allSettled( await Promise.allSettled(
chunk.map(async (item, chunkIdx) => { chunk.map(async (item, chunkIdx) => {
if (ac.signal.aborted) return;
const index = i + chunkIdx; const index = i + chunkIdx;
const classname = labelOf(item); const classname = labelOf(item);
send({ send({
@@ -61,6 +75,7 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
const result = await worker(item, index, (status) => const result = await worker(item, index, (status) =>
send({ type: "item_progress", classname, status, index }), send({ type: "item_progress", classname, status, index }),
); );
if (ac.signal.aborted) return;
if (result.ok) { if (result.ok) {
succeeded++; succeeded++;
if (result.warnings?.length) withWarnings++; if (result.warnings?.length) withWarnings++;
@@ -84,6 +99,7 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
}); });
} }
} catch (err) { } catch (err) {
if (ac.signal.aborted) return;
failed++; failed++;
send({ send({
type: "item_progress", type: "item_progress",
@@ -97,14 +113,23 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
); );
} }
send({ if (!ac.signal.aborted) {
type: "batch_complete", send({
succeeded, type: "batch_complete",
failed, succeeded,
warnings: withWarnings, failed,
duration: Date.now() - startTime, warnings: withWarnings,
}); duration: Date.now() - startTime,
controller.close(); });
}
try {
controller.close();
} catch {
/* already closed */
}
},
cancel() {
ac.abort();
}, },
}); });
+86
View File
@@ -0,0 +1,86 @@
import { toast } from "sonner";
import { adminFetch } from "@/lib/admin-fetch";
export type SseEvent = Record<string, unknown>;
/**
* Read an SSE response body and invoke `onEvent` for each `data:` JSON payload.
*/
export async function readSseStream(
body: ReadableStream<Uint8Array>,
onEvent: (event: SseEvent) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = "";
try {
while (true) {
if (signal?.aborted) {
await reader.cancel();
break;
}
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
onEvent(JSON.parse(part.slice(6)) as SseEvent);
} catch {
/* skip malformed events */
}
}
}
} finally {
reader.releaseLock();
}
}
/**
* POST JSON to an admin SSE import endpoint and drive the standard
* item_progress / batch_complete callbacks used by clothing & clone clients.
*/
export async function runSseImport(
url: string,
body: unknown,
onDone: (classname: string) => void,
onComplete: (succeeded: number, failed: number) => void,
signal?: AbortSignal,
): Promise<void> {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
if (!res.body) {
toast.error("No response stream");
return;
}
let succeeded = 0;
let failed = 0;
await readSseStream(
res.body,
(evt) => {
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
onDone(String(evt.classname ?? ""));
}
if (evt.type === "batch_complete") {
succeeded = Number(evt.succeeded ?? 0);
failed = Number(evt.failed ?? 0);
}
},
signal,
);
onComplete(succeeded, failed);
}
+33 -7
View File
@@ -6,17 +6,30 @@ type UseEventSourceOptions = {
onMessage?: (data: unknown) => void; onMessage?: (data: unknown) => void;
onError?: (event: Event) => void; onError?: (event: Event) => void;
enabled?: boolean; enabled?: boolean;
/** Max reconnect attempts after errors (default 8). Set 0 to disable reconnect. */
maxRetries?: number;
/** Initial reconnect delay in ms (default 1000); doubles each attempt. */
baseDelayMs?: number;
/** Cap on reconnect delay in ms (default 30000). */
maxDelayMs?: number;
}; };
/** /**
* Subscribe to a Server-Sent Events (SSE) endpoint. * Subscribe to a Server-Sent Events (SSE) endpoint.
* Reconnects automatically on connection loss. * Reconnects with exponential backoff up to `maxRetries`.
*/ */
export function useEventSource<T = unknown>( export function useEventSource<T = unknown>(
url: string | null, url: string | null,
options?: UseEventSourceOptions, options?: UseEventSourceOptions,
) { ) {
const { onMessage, onError, enabled = true } = options ?? {}; const {
onMessage,
onError,
enabled = true,
maxRetries = 8,
baseDelayMs = 1000,
maxDelayMs = 30_000,
} = options ?? {};
const [data, setData] = useState<T | null>(null); const [data, setData] = useState<T | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const onMessageRef = useRef(onMessage); const onMessageRef = useRef(onMessage);
@@ -29,12 +42,18 @@ export function useEventSource<T = unknown>(
const endpoint: string = url; const endpoint: string = url;
let es: EventSource | null = null; let es: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout>; let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let attempt = 0;
let disposed = false;
function connect() { function connect() {
if (disposed) return;
es = new EventSource(endpoint); es = new EventSource(endpoint);
es.onopen = () => setConnected(true); es.onopen = () => {
attempt = 0;
setConnected(true);
};
es.onmessage = (event) => { es.onmessage = (event) => {
try { try {
@@ -48,20 +67,27 @@ export function useEventSource<T = unknown>(
es.onerror = (event) => { es.onerror = (event) => {
es?.close(); es?.close();
es = null;
setConnected(false); setConnected(false);
onErrorRef.current?.(event); onErrorRef.current?.(event);
reconnectTimer = setTimeout(connect, 3000);
if (disposed || attempt >= maxRetries) return;
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
attempt++;
reconnectTimer = setTimeout(connect, delay);
}; };
} }
connect(); connect();
return () => { return () => {
clearTimeout(reconnectTimer); disposed = true;
if (reconnectTimer !== undefined) clearTimeout(reconnectTimer);
es?.close(); es?.close();
setConnected(false); setConnected(false);
}; };
}, [url, enabled]); }, [url, enabled, maxRetries, baseDelayMs, maxDelayMs]);
return { data, connected }; return { data, connected };
} }
+5
View File
@@ -67,8 +67,13 @@ export const eventWinnerSchema = z.object({
position: z.coerce.number().int().positive().default(1), position: z.coerce.number().int().positive().default(1),
}); });
export const registerForEventSchema = z.object({
eventId: z.coerce.number().int().positive(),
});
export type EventTypeInput = z.infer<typeof eventTypeSchema>; export type EventTypeInput = z.infer<typeof eventTypeSchema>;
export type CreateEventInput = z.infer<typeof createEventSchema>; export type CreateEventInput = z.infer<typeof createEventSchema>;
export type UpdateEventInput = z.infer<typeof updateEventSchema>; export type UpdateEventInput = z.infer<typeof updateEventSchema>;
export type EventPrizeInput = z.infer<typeof eventPrizeSchema>; export type EventPrizeInput = z.infer<typeof eventPrizeSchema>;
export type EventWinnerInput = z.infer<typeof eventWinnerSchema>; export type EventWinnerInput = z.infer<typeof eventWinnerSchema>;
export type RegisterForEventInput = z.infer<typeof registerForEventSchema>;
+6
View File
@@ -25,7 +25,13 @@ export const pollVoteSchema = z.object({
answer: z.string().min(1).max(500), answer: z.string().min(1).max(500),
}); });
export const voteOnPollSchema = z.object({
pollId: z.coerce.number().int().positive(),
votes: z.array(pollVoteSchema).min(1).max(50),
});
export type CreatePollInput = z.infer<typeof createPollSchema>; export type CreatePollInput = z.infer<typeof createPollSchema>;
export type UpdatePollInput = z.infer<typeof updatePollSchema>; export type UpdatePollInput = z.infer<typeof updatePollSchema>;
export type PollQuestionInput = z.infer<typeof pollQuestionSchema>; export type PollQuestionInput = z.infer<typeof pollQuestionSchema>;
export type PollVoteInput = z.infer<typeof pollVoteSchema>; export type PollVoteInput = z.infer<typeof pollVoteSchema>;
export type VoteOnPollInput = z.infer<typeof voteOnPollSchema>;
+153 -10
View File
File diff suppressed because it is too large. Load diff
+174 -23
View File
File diff suppressed because it is too large. Load diff
+210 -22
View File
File diff suppressed because it is too large. Load diff