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

+2
View File
@@ -19,6 +19,7 @@
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.2",
"hash-wasm": "^4.12.0",
"nodemailer": "^6.9.0",
"next": "^16.2.9",
"next-auth": "5.0.0-beta.31",
"otplib": "^12.0.1",
@@ -28,6 +29,7 @@
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"dotenv": "^16.4.0",
+27 -4
View File
@@ -25,7 +25,10 @@ importers:
version: 16.2.9([email protected]([email protected]))([email protected])
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31([email protected]([email protected]([email protected]))([email protected]))([email protected])
version: 5.0.0-beta.31([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])
nodemailer:
specifier: ^6.9.0
version: 6.10.1
otplib:
specifier: ^12.0.1
version: 12.0.1
@@ -42,6 +45,9 @@ importers:
'@types/node':
specifier: ^22.10.0
version: 22.20.0
'@types/nodemailer':
specifier: ^6.4.0
version: 6.4.24
'@types/react':
specifier: ^19.2.0
version: 19.2.17
@@ -1240,6 +1246,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
'@types/[email protected]':
resolution: {integrity: sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==}
'@types/[email protected]':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1694,6 +1703,10 @@ packages:
sass:
optional: true
[email protected]:
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
engines: {node: '>=6.0.0'}
[email protected]:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
@@ -2011,13 +2024,15 @@ packages:
snapshots:
'@auth/[email protected]':
'@auth/[email protected]([email protected])':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
oauth4webapi: 3.8.6
preact: 10.24.3
preact-render-to-string: 6.5.11([email protected])
optionalDependencies:
nodemailer: 6.10.1
'@drizzle-team/[email protected]': {}
@@ -2730,6 +2745,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/[email protected]':
dependencies:
'@types/node': 22.20.0
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 19.2.17
@@ -3136,11 +3155,13 @@ snapshots:
[email protected]: {}
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected]):
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]):
dependencies:
'@auth/core': 0.41.2
'@auth/core': 0.41.2([email protected])
next: 16.2.9([email protected]([email protected]))([email protected])
react: 19.2.7
optionalDependencies:
nodemailer: 6.10.1
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
@@ -3166,6 +3187,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
[email protected]: {}
[email protected]: {}
[email protected]: {}
+6
View File
@@ -0,0 +1,6 @@
-- Fortify-style 2FA columns on the emulator users table. Idempotent (MariaDB).
-- AtomCMS installs already have these; this only adds them when missing.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS two_factor_secret TEXT NULL,
ADD COLUMN IF NOT EXISTS two_factor_recovery_codes TEXT NULL,
ADD COLUMN IF NOT EXISTS two_factor_confirmed_at TIMESTAMP NULL;
@@ -0,0 +1,7 @@
-- CMS password reset tokens (Laravel-compatible). Idempotent.
CREATE TABLE IF NOT EXISTS password_resets (
email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
created_at TIMESTAMP NULL,
PRIMARY KEY (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+14
View File
@@ -60,6 +60,10 @@ model User {
secretKey String? @map("secret_key") @db.VarChar(40)
pincode String? @db.VarChar(11)
extraRank Int? @map("extra_rank")
// CMS-added (Laravel Fortify) 2FA columns on the users table.
twoFactorSecret String? @map("two_factor_secret") @db.Text
twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0)
@@map("users")
}
@@ -101,6 +105,16 @@ model WebsiteSetting {
@@map("website_settings")
}
/// CMS-owned password reset tokens (Laravel-compatible table). Created via
/// prisma/migrations if absent.
model PasswordReset {
email String @id @db.VarChar(255)
token String @db.VarChar(255)
createdAt DateTime? @map("created_at") @db.Timestamp(0)
@@map("password_resets")
}
// === GENERATED MODELS (assembled from default.sql + Laravel migrations) ===
// 186 tables. Regenerated by assemble-schema.mjs — edit the
// generator instead of hand-editing below.
+38
View File
@@ -0,0 +1,38 @@
"use server";
import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
export type PrecheckResult = "ok" | "invalid" | "twofactor";
/**
* 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.
*/
export async function precheckLogin(
username: string,
password: string,
): Promise<PrecheckResult> {
const u = String(username ?? "").trim();
const p = String(password ?? "");
if (!u || !p) return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null;
try {
user = await prisma.user.findUnique({
where: { username: u },
select: { password: true, twoFactorConfirmedAt: true },
});
} catch {
return "invalid";
}
if (!user) return "invalid";
const res = await checkLogin(p, user.password, {
convertPasswords: env.CONVERT_PASSWORDS,
});
if (!res.valid) return "invalid";
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
}
+53
View File
@@ -0,0 +1,53 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export async function updateCatalog(): Promise<void> {
await requireStaff();
try {
await rcon.updateCatalog();
} catch {
// RCON is best-effort; a dead socket must not 500 the admin page.
}
revalidatePath(PATH);
}
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export async function updateWordFilter(): Promise<void> {
await requireStaff();
try {
await rcon.updateWordFilter();
} catch {
// best-effort
}
revalidatePath(PATH);
}
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export async function updateNavigator(): Promise<void> {
await requireStaff();
try {
await rcon.send("updatenavigator", null);
} catch {
// best-effort
}
revalidatePath(PATH);
}
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export async function hotelAlert(formData: FormData): Promise<void> {
await requireStaff();
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
if (!message) return;
try {
await rcon.send("hotelalert", { message });
} catch {
// best-effort
}
revalidatePath(PATH);
}
+84
View File
@@ -0,0 +1,84 @@
"use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { redirect } from "next/navigation";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/services/email";
import { env } from "@/env";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
function sha256(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
// Always respond the same way so we don't reveal which emails exist.
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
if (user) {
const token = randomBytes(32).toString("hex");
await prisma.passwordReset.upsert({
where: { email },
update: { token: sha256(token), createdAt: new Date() },
create: { email, token: sha256(token), createdAt: new Date() },
});
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
await sendMail(
email,
`${env.HOTEL_NAME} — password reset`,
`<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`,
);
}
} catch {
// swallow — generic response below
}
}
redirect("/forgot?sent=1");
}
export async function resetPassword(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const token = String(formData.get("token") ?? "").trim();
const password = String(formData.get("password") ?? "");
let error: string | null = null;
if (password.length < 6) error = "Password must be at least 6 characters";
if (!error) {
try {
const row = await prisma.passwordReset.findUnique({ where: { email } });
const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false;
const a = Buffer.from(sha256(token), "hex");
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
const match = row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired";
} else {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
if (!user) {
error = "Account not found";
} else {
await prisma.user.update({
where: { id: user.id },
data: { password: await hashPassword(password) },
});
await prisma.passwordReset.delete({ where: { email } }).catch(() => {});
}
}
} catch {
error = "Could not reset the password — try again";
}
}
if (error) {
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`);
}
redirect("/login?reset=1");
}
+136
View File
@@ -0,0 +1,136 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
// guilds_forums_comments.message which is TEXT. Keep the first post's message
// bounded defensively even though the column is large.
const SUBJECT_MAX = 255;
const MESSAGE_MAX = 10000;
/**
* Send a friend request to another user.
*
* The REQUESTER (user_from_id) is re-read from the session via auth() and is
* never trusted from the submitted FormData, so a crafted form cannot send a
* request "from" someone else. Only the TARGET user id is taken from the form.
*
* Writes into messenger_friendrequests (userFromId = requester, userToId =
* target). The emulator surfaces the pending request in the in-game messenger.
*/
export async function sendFriendRequest(formData: FormData): Promise<void> {
const session = await auth();
const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) return;
const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) return;
// Can't befriend yourself.
if (toId === fromId) return;
try {
// Guard against duplicate pending requests and already-existing friendships.
const [existingRequest, existingFriendship] = await Promise.all([
prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId },
select: { id: true },
}),
prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: fromId, userTwoId: toId },
{ userOneId: toId, userTwoId: fromId },
],
},
select: { id: true },
}),
]);
if (existingRequest || existingFriendship) return;
await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId },
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
// Optional: revalidate the target profile if a username was supplied, purely
// to refresh any request-state UI rendered there.
const username = String(formData.get("username") ?? "").trim();
if (username) revalidatePath(`/u/${username}`);
}
/**
* Open a new thread in a guild's forum.
*
* The AUTHOR (opener_id) is re-read from the session via auth() and is never
* trusted from the submitted FormData. Only the guild id, subject, and message
* come from the form.
*
* AtomCMS/Arcturus splits a thread into a header row (guilds_forums_threads)
* plus the opening post stored as the first comment (guilds_forums_comments).
* We create both in a transaction so the thread always has its first post, then
* stamp posts_count = 1 to match the emulator's bookkeeping.
*/
export async function postThread(formData: FormData): Promise<void> {
const session = await auth();
const openerId = Number(session?.user?.id);
if (!Number.isInteger(openerId) || openerId <= 0) return;
const guildId = Number(formData.get("guildId"));
if (!Number.isInteger(guildId) || guildId <= 0) return;
const subject = String(formData.get("subject") ?? "").trim().slice(0, SUBJECT_MAX);
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
if (!subject || !message) return;
const now = Math.floor(Date.now() / 1000);
try {
// Confirm the guild exists (and has a forum) before opening a thread.
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true },
});
if (!guild) return;
await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({
data: {
guildId,
openerId,
subject,
postsCount: 1,
createdAt: now,
updatedAt: now,
state: 0,
pinned: 0,
locked: 0,
adminId: 0,
},
select: { id: true },
});
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId: openerId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
});
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
revalidatePath(`/guilds/${guildId}/forum`);
}
+67
View File
@@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
async function sessionUserId(): Promise<number> {
const session = await auth();
if (!session?.user?.id) redirect("/login");
return Number(session.user.id);
}
/** Step 1: generate a secret, store it encrypted but UNconfirmed. */
export async function beginTwoFactor(): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
await prisma.user.update({
where: { id },
data: { twoFactorSecret: encrypted, twoFactorConfirmedAt: null },
});
revalidatePath("/settings/2fa");
}
/** Step 2: verify a code against the pending secret, then confirm. */
export async function confirmTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
const code = String(formData.get("code") ?? "").trim();
const user = await prisma.user.findUnique({
where: { id },
select: { twoFactorSecret: true },
});
let ok = false;
if (user?.twoFactorSecret && code) {
try {
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
ok = verifyTotp(code, secret);
} catch {
ok = false;
}
}
if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } });
redirect("/settings/2fa?enabled=1");
}
export async function disableTwoFactor(): Promise<void> {
const id = await sessionUserId();
await prisma.user.update({
where: { id },
data: {
twoFactorSecret: null,
twoFactorRecoveryCodes: null,
twoFactorConfirmedAt: null,
},
});
redirect("/settings/2fa?disabled=1");
}
+150
View File
@@ -0,0 +1,150 @@
import {
hotelAlert,
updateCatalog,
updateNavigator,
updateWordFilter,
} from "@/actions/commandocentrum";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatTimestamp(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 19).replace("T", " ");
}
// stacktrace is a MySQL BLOB (Prisma `Bytes`), so it arrives as a Buffer/
// Uint8Array. Decode to UTF-8 and trim to a single readable preview line.
function decodeStacktrace(raw: unknown): string {
if (raw == null) return "";
try {
if (typeof raw === "string") return raw;
return Buffer.from(raw as Uint8Array).toString("utf8");
} catch {
return "";
}
}
export default async function CommandoCentrum() {
const errors = await prisma.emulatorErrors
.findMany({
orderBy: { timestamp: "desc" },
take: 50,
})
.catch(() => []);
return (
<main>
<h1>Commandocentrum</h1>
<p className="muted" style={{ marginTop: "-0.25rem" }}>
Emulator control center — push live reloads and broadcast alerts over RCON.
</p>
{/* ── RCON controls ──────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}>
<h2>Emulator controls</h2>
<div className="grid cols-3">
<form action={updateCatalog} className="card">
<h3 style={{ marginTop: 0 }}>Update catalog</h3>
<p className="muted" style={{ margin: "0 0 0.85rem" }}>
Reload catalog pages and items on the live server.
</p>
<button type="submit" className="btn btn-primary">
Update catalog
</button>
</form>
<form action={updateWordFilter} className="card">
<h3 style={{ marginTop: 0 }}>Update word filter</h3>
<p className="muted" style={{ margin: "0 0 0.85rem" }}>
Reload the chat word filter from the database.
</p>
<button type="submit" className="btn btn-primary">
Update word filter
</button>
</form>
<form action={updateNavigator} className="card">
<h3 style={{ marginTop: 0 }}>Update navigator</h3>
<p className="muted" style={{ margin: "0 0 0.85rem" }}>
Reload navigator categories and room listings.
</p>
<button type="submit" className="btn btn-primary">
Update navigator
</button>
</form>
</div>
<form action={hotelAlert} className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Hotel alert</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
Broadcast a message to every connected user.
</p>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input
name="message"
required
maxLength={512}
placeholder="Message to broadcast…"
style={{ flex: 1, minWidth: 220 }}
/>
<button type="submit" className="btn btn-danger">
Send alert
</button>
</div>
</form>
</section>
{/* ── Emulator errors ────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}>
<h2>Recent emulator errors</h2>
{errors.length === 0 ? (
<p className="muted">No emulator errors logged.</p>
) : (
<div className="card" style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Version</th>
<th>Stacktrace</th>
</tr>
</thead>
<tbody>
{errors.map((e) => {
const trace = decodeStacktrace(e.stacktrace);
return (
<tr key={e.id}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{formatTimestamp(e.timestamp)}
</td>
<td>{e.type}</td>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{e.version}
</td>
<td>
<pre
style={{
margin: 0,
maxHeight: 160,
overflow: "auto",
fontSize: "0.75rem",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{trace || "(empty)"}
</pre>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+1
View File
@@ -39,6 +39,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin/housekeeping">Housekeeping</Link>
<Link href="/admin/permissions">Permissions</Link>
<Link href="/admin/emulator">Emulator</Link>
<Link href="/admin/commandocentrum">Commandocentrum</Link>
<Link href="/admin/email-templates">Email</Link>
<Link href="/admin/settings">Settings</Link>
</nav>
+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>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { requestReset } from "@/actions/password-reset";
export default async function ForgotPage({
searchParams,
}: {
searchParams: Promise<{ sent?: string }>;
}) {
const { sent } = await searchParams;
return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}>
<h1 style={{ marginTop: 0, textAlign: "center" }}>Reset password</h1>
{sent ? (
<p className="muted" style={{ textAlign: "center" }}>
If that email is registered, a reset link has been sent. Check your inbox.
</p>
) : (
<form action={requestReset} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
<input name="email" type="email" placeholder="Your email" autoComplete="email" required />
<button type="submit" className="btn btn-primary">
Send reset link
</button>
</form>
)}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link>
</p>
</div>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { postThread } from "@/actions/social";
export const dynamic = "force-dynamic";
export default async function NewThreadPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
// Auth gate: only logged-in users may open a thread.
const session = await auth();
if (!session?.user?.id) redirect("/login");
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
let guild: { id: number; name: string } | null = null;
try {
guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true, name: true },
});
} catch {
guild = null;
}
if (!guild) notFound();
return (
<main>
<p style={{ marginTop: 0 }}>
<Link href={`/guilds/${guild.id}/forum`}>← Back to forum</Link>
</p>
<h1 style={{ marginBottom: "0.25rem" }}>New thread</h1>
<p className="muted" style={{ marginTop: 0 }}>
in {guild.name || "Unnamed guild"} forum
</p>
<form action={postThread} className="card" style={{ marginTop: "1rem" }}>
{/* Guild target drives the write; the author is taken from the session
inside the action, never from this form. */}
<input type="hidden" name="guildId" value={String(guild.id)} />
<label htmlFor="thread-subject" className="muted">
Subject
</label>
<input
id="thread-subject"
name="subject"
required
maxLength={255}
placeholder="Thread subject"
style={{ width: "100%", margin: "0.4rem 0 0.9rem" }}
/>
<label htmlFor="thread-message" className="muted">
Message
</label>
<textarea
id="thread-message"
name="message"
required
rows={8}
placeholder="Write your opening post…"
style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }}
/>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="submit" className="btn btn-primary">
Post thread
</button>
<Link href={`/guilds/${guild.id}/forum`} className="btn btn-outline">
Cancel
</Link>
</div>
</form>
</main>
);
}
+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>
);
}
+70 -30
View File
@@ -3,10 +3,13 @@
import Link from "next/link";
import { signIn } from "next-auth/react";
import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
@@ -14,13 +17,32 @@ export default function LoginPage() {
e.preventDefault();
setError(null);
setPending(true);
const res = await signIn("credentials", { username, password, redirect: false });
setPending(false);
if (!res || res.error) {
setError("Invalid username or password");
return;
try {
if (!needs2fa) {
const pre = await precheckLogin(username, password);
if (pre === "invalid") {
setError("Invalid username or password");
return;
}
if (pre === "twofactor") {
setNeeds2fa(true);
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
return;
}
window.location.href = "/";
} finally {
setPending(false);
}
window.location.href = "/";
}
return (
@@ -28,7 +50,7 @@ export default function LoginPage() {
<div className="card" style={{ padding: "1.75rem" }}>
<h1 style={{ marginTop: 0, textAlign: "center" }}>Sign in</h1>
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>
Welcome back — log in to enter the hotel.
{needs2fa ? "Enter the 6-digit code from your authenticator." : "Welcome back — log in to enter the hotel."}
</p>
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
<input
@@ -36,6 +58,7 @@ export default function LoginPage() {
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
autoComplete="username"
disabled={needs2fa}
/>
<input
type="password"
@@ -43,38 +66,55 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
autoComplete="current-password"
disabled={needs2fa}
/>
{needs2fa ? (
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="2FA code"
inputMode="numeric"
autoComplete="one-time-code"
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
autoFocus
/>
) : null}
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending ? "Signing in…" : "Sign in"}
{pending ? "Please wait…" : needs2fa ? "Verify" : "Sign in"}
</button>
</form>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", margin: "1rem 0" }}>
<span style={{ flex: 1, height: 1, background: "var(--border-color)" }} />
<span className="muted">or</span>
<span style={{ flex: 1, height: 1, background: "var(--border-color)" }} />
</div>
<div style={{ display: "grid", gap: "0.5rem" }}>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("discord", { callbackUrl: "/" })}
>
Continue with Discord
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("google", { callbackUrl: "/" })}
>
Continue with Google
</button>
</div>
{!needs2fa ? (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", margin: "1rem 0" }}>
<span style={{ flex: 1, height: 1, background: "var(--border-color)" }} />
<span className="muted">or</span>
<span style={{ flex: 1, height: 1, background: "var(--border-color)" }} />
</div>
<div style={{ display: "grid", gap: "0.5rem" }}>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("discord", { callbackUrl: "/" })}
>
Continue with Discord
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("google", { callbackUrl: "/" })}
>
Continue with Google
</button>
</div>
</>
) : null}
{error ? (
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p>
) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
No account? <Link href="/register">Create one</Link>
No account? <Link href="/register">Create one</Link> · <Link href="/forgot">Forgot password?</Link>
</p>
</div>
</main>
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import { resetPassword } from "@/actions/password-reset";
export default async function ResetPage({
searchParams,
}: {
searchParams: Promise<{ email?: string; token?: string; error?: string }>;
}) {
const { email = "", token = "", error } = await searchParams;
return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}>
<h1 style={{ marginTop: 0, textAlign: "center" }}>Set a new password</h1>
<form action={resetPassword} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
<input type="hidden" name="email" value={email} />
<input type="hidden" name="token" value={token} />
<input
name="password"
type="password"
placeholder="New password (min 6 chars)"
autoComplete="new-password"
required
/>
<button type="submit" className="btn btn-primary">
Reset password
</button>
</form>
{error ? (
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p>
) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link>
</p>
</div>
</main>
);
}
+113
View File
@@ -0,0 +1,113 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { beginTwoFactor, confirmTwoFactor, disableTwoFactor } from "@/actions/twofactor";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { totpKeyUri } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { env } from "@/env";
export const dynamic = "force-dynamic";
export default async function TwoFactorPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; enabled?: string; disabled?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const sp = await searchParams;
const id = Number(session.user.id);
let user: { twoFactorSecret: string | null; twoFactorConfirmedAt: Date | null } | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: { twoFactorSecret: true, twoFactorConfirmedAt: true },
});
} catch {
user = null;
}
const hasAppKey = Boolean(env.APP_KEY);
const enabled = Boolean(user?.twoFactorConfirmedAt);
const pending = Boolean(user?.twoFactorSecret && !user?.twoFactorConfirmedAt);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
let secret = "";
let uri = "";
if (pending && hasAppKey && user?.twoFactorSecret) {
try {
secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
uri = totpKeyUri(secret, session.user.name ?? "user", hotelName);
} catch {
secret = "";
}
}
return (
<main style={{ maxWidth: 520 }}>
<p className="muted">
<Link href="/settings">← Settings</Link>
</p>
<h1>Two-factor authentication</h1>
{sp.enabled ? <p style={{ color: "var(--color-accent)" }}>2FA is now enabled. 🔒</p> : null}
{sp.disabled ? <p className="muted">2FA has been disabled.</p> : null}
{sp.error === "badcode" ? (
<p style={{ color: "var(--color-danger)" }}>That code wasn't valid — try again.</p>
) : null}
{!hasAppKey ? (
<div className="card">
<p style={{ margin: 0 }}>
2FA is unavailable until <code>APP_KEY</code> is configured (the same Laravel
<code> APP_KEY</code> as your AtomCMS install, so existing secrets stay readable).
</p>
</div>
) : enabled ? (
<div className="card">
<p>
<strong>2FA is enabled</strong> on your account.
</p>
<form action={disableTwoFactor}>
<button type="submit" className="btn btn-danger">
Disable 2FA
</button>
</form>
</div>
) : pending ? (
<div className="card">
<h3 style={{ marginTop: 0 }}>Scan or enter this key</h3>
<p className="muted" style={{ marginTop: 0 }}>
Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm.
</p>
<p>
Manual key: <code style={{ userSelect: "all" }}>{secret}</code>
</p>
<p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}>
{uri}
</p>
<form action={confirmTwoFactor} style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<input name="code" placeholder="6-digit code" inputMode="numeric" required />
<button type="submit" className="btn btn-primary">
Confirm
</button>
</form>
</div>
) : (
<div className="card">
<p style={{ marginTop: 0 }}>
Add a second layer of security with an authenticator app.
</p>
<form action={beginTwoFactor}>
<button type="submit" className="btn btn-primary">
Enable 2FA
</button>
</form>
</div>
)}
</main>
);
}
+11
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { updateMotto } from "@/actions/user-settings";
import { avatarImageUrl } from "@/lib/format";
@@ -81,6 +82,16 @@ export default async function SettingsPage() {
Updates instantly in-game if you are online.
</p>
</div>
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Security</h3>
<p className="muted" style={{ marginTop: 0 }}>
Protect your account with two-factor authentication.
</p>
<Link href="/settings/2fa" className="btn btn-outline">
Two-factor authentication
</Link>
</div>
</main>
);
}
+11
View File
@@ -10,6 +10,17 @@ const schema = z.object({
DATABASE_IDLE_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
DATABASE_CONNECT_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
HOTEL_NAME: z.string().default("Atom"),
APP_URL: z.string().url().default("http://localhost:3000"),
// SMTP (password reset / notifications). Email features no-op if unset.
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_SECURE: z
.string()
.optional()
.transform((v) => v === "true" || v === "1"),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(),
// RCON link to the Arcturus emulator (raw-JSON TCP protocol).
RCON_HOST: z.string().default("127.0.0.1"),
RCON_PORT: z.coerce.number().int().positive().default(3001),
+16
View File
@@ -2,7 +2,9 @@ import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Discord from "next-auth/providers/discord";
import Google from "next-auth/providers/google";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { checkLogin } from "@/lib/auth/password";
import { verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
@@ -15,6 +17,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
code: { label: "2FA code", type: "text" },
},
authorize: async (credentials) => {
const username = String(credentials?.username ?? "").trim();
@@ -37,6 +40,19 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
});
}
// Two-factor: if enabled, a valid TOTP code is required. The secret is
// Laravel-encrypted with APP_KEY (fail closed if it cannot be read).
if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
const code = String(credentials?.code ?? "").trim();
if (!code || !env.APP_KEY) return null;
try {
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
if (!verifyTotp(code, secret)) return null;
} catch {
return null;
}
}
return { id: String(user.id), name: user.username, rank: user.rank };
},
}),
+5
View File
@@ -18,6 +18,11 @@ export function generateTotp(secret: string): string {
return authenticator.generate(secret);
}
/** Generate a fresh base32 secret for enrolling a new authenticator. */
export function generateTotpSecret(): string {
return authenticator.generateSecret();
}
/** otpauth:// URI for provisioning a QR code. */
export function totpKeyUri(secret: string, accountName: string, issuer: string): string {
return authenticator.keyuri(accountName, issuer, secret);
+38
View File
@@ -0,0 +1,38 @@
import nodemailer, { type Transporter } from "nodemailer";
import { env } from "@/env";
let transporter: Transporter | null = null;
function getTransport(): Transporter | null {
if (!env.SMTP_HOST) return null;
if (!transporter) {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT ?? 587,
secure: env.SMTP_SECURE,
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
});
}
return transporter;
}
/** Send an HTML email. No-ops (returns false) when SMTP isn't configured. */
export async function sendMail(to: string, subject: string, html: string): Promise<boolean> {
const t = getTransport();
if (!t) {
console.warn("[email] SMTP not configured — skipping mail to", to);
return false;
}
try {
await t.sendMail({
from: env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`,
to,
subject,
html,
});
return true;
} catch (e) {
console.error("[email] send failed:", (e as Error).message);
return false;
}
}