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

+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function BadgesPage() {
const badges = await prisma.websiteBadges
.findMany({
orderBy: { badgeName: "asc" },
})
.catch(() => []);
return (
<main>
<div className="hero">
<h1 style={{ margin: "0 0 0.35rem" }}>Badges</h1>
<p className="muted" style={{ margin: 0 }}>
The badges you can earn and show off around the hotel.
</p>
</div>
{badges.length === 0 ? (
<p className="muted">No badges available yet.</p>
) : (
<div className="grid cols-3">
{badges.map((badge) => (
<div
key={String(badge.id)}
className="card hover"
style={{ display: "flex", gap: "0.9rem", alignItems: "flex-start" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`}
alt={badge.badgeName}
title={badge.badgeKey}
width={40}
height={40}
style={{ flexShrink: 0 }}
/>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.25rem" }}>{badge.badgeName}</h3>
<p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}>
{badge.badgeKey}
</p>
<p style={{ margin: 0, fontSize: "0.9rem" }}>{badge.badgeDescription}</p>
</div>
</div>
))}
</div>
)}
</main>
);
}