feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
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:
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
@@ -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";
|
||||||
}
|
}
|
||||||
+203
-191
File diff suppressed because it is too large.
Load diff
@@ -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="),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+60
-24
File diff suppressed because it is too large.
Load diff
+64
-2
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
+73
-31
File diff suppressed because it is too large.
Load diff
+80
-39
File diff suppressed because it is too large.
Load diff
+217
-43
File diff suppressed because it is too large.
Load diff
@@ -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"),
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+75
-36
File diff suppressed because it is too large.
Load diff
+191
-94
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+14
-153
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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)) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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": "*",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+174
-23
File diff suppressed because it is too large.
Load diff
+210
-22
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user