Add 2FA, email + password reset, and batch-7 pages

Auth (hand-built on the auth core):
- 2FA: User model gains two_factor_secret/recovery_codes/confirmed_at (+ idempotent
  MariaDB migration). authorize() requires a valid TOTP code when 2FA is confirmed
  (secret decrypted via Laravel APP_KEY, fail-closed). Two-step login (precheckLogin
  reveals the code field). /settings/2fa enable/confirm/disable flow.
- Password reset: nodemailer email service; PasswordReset model + migration;
  /forgot (request, generic response) + /reset (token sha256 + 1h TTL, sets argon2id
  hash). Login links to forgot.

Batch 7 (parallel agents): /admin/commandocentrum (RCON controls + emulator_errors),
social write actions (friend request + guild forum new thread), /help/[category],
/badges (public). env: APP_KEY, APP_URL, SMTP_*. Nav extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0 (64 page routes).
This commit is contained in:
Simo committed 2026-06-28 14:25:19 +02:00
1 parent 486ce51559
commit e668fa85ec
24 files changed
+1223 -34

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
const HELP_IMAGE_BASE = "/assets/images/help-center";
type HelpCategory = {
id: bigint;
name: string;
content: string;
position: number;
imageUrl: string | null;
buttonText: string | null;
buttonUrl: string | null;
buttonColor: string;
buttonBorderColor: string;
smallBox: boolean;
};
type HelpCategoryLink = {
id: bigint;
name: string;
position: number;
};
// Faithful to AtomCMS: name / content / button_text carry a `:hotel` placeholder
// replaced with the configured hotel name before display.
function withHotel(text: string | null | undefined, hotelName: string): string {
return (text ?? "").split(":hotel").join(hotelName);
}
export default async function HelpCategoryPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
const { category } = await params;
let categoryId: bigint;
try {
categoryId = BigInt(category);
} catch {
notFound();
}
let cat: HelpCategory | null = null;
try {
cat = await prisma.websiteHelpCenterCategories.findUnique({
where: { id: categoryId! },
select: {
id: true,
name: true,
content: true,
position: true,
imageUrl: true,
buttonText: true,
buttonUrl: true,
buttonColor: true,
buttonBorderColor: true,
smallBox: true,
},
});
} catch {
cat = null;
}
if (!cat) notFound();
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
// Sibling help topics, so the reader can jump between categories. Isolated
// query: a DB hiccup degrades this list to empty rather than 500-ing.
let siblings: HelpCategoryLink[] = [];
try {
siblings = await prisma.websiteHelpCenterCategories.findMany({
where: { id: { not: categoryId! } },
orderBy: { position: "asc" },
select: { id: true, name: true, position: true },
take: 50,
});
} catch {
siblings = [];
}
const title = withHotel(cat.name, hotelName);
const content = withHotel(cat.content, hotelName);
const buttonText = withHotel(cat.buttonText, hotelName);
const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : "";
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return (
<main>
<p style={{ margin: "0 0 0.75rem" }}>
<Link href="/help">← Help Center</Link>
</p>
<div className="hero">
<h1 style={{ margin: 0 }}>{title}</h1>
</div>
<article className="card">
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt=""
style={{
float: "right",
maxWidth: 160,
height: "auto",
margin: "0 0 0.75rem 1rem",
}}
/>
) : null}
{/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */}
<div
style={{ lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: content }}
/>
{hasButton ? (
<div style={{ clear: "both", marginTop: "1rem" }}>
<a
href={cat.buttonUrl ?? "#"}
className="btn"
style={{
backgroundColor: cat.buttonColor,
border: `2px solid ${cat.buttonBorderColor}`,
}}
>
{buttonText}
</a>
</div>
) : null}
</article>
{siblings.length > 0 ? (
<section style={{ marginTop: "2rem" }}>
<h2>More help topics</h2>
<div className="grid cols-2">
{siblings.map((s) => (
<Link
key={String(s.id)}
href={`/help/${String(s.id)}`}
className="card hover"
style={{ display: "block", color: "inherit" }}
>
<strong>{withHotel(s.name, hotelName)}</strong>
</Link>
))}
</div>
</section>
) : null}
</main>
);
}