diff --git a/package.json b/package.json
index b5d78d43..ab511140 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c559882e..dbf607aa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,7 +25,10 @@ importers:
version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next-auth:
specifier: 5.0.0-beta.31
- version: 5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ version: 5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@6.10.1)(react@19.2.7)
+ 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/node@24.13.2':
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
+ '@types/nodemailer@6.4.24':
+ resolution: {integrity: sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1694,6 +1703,10 @@ packages:
sass:
optional: true
+ nodemailer@6.10.1:
+ resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
+ engines: {node: '>=6.0.0'}
+
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
@@ -2011,13 +2024,15 @@ packages:
snapshots:
- '@auth/core@0.41.2':
+ '@auth/core@0.41.2(nodemailer@6.10.1)':
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(preact@10.24.3)
+ optionalDependencies:
+ nodemailer: 6.10.1
'@drizzle-team/brocli@0.10.2': {}
@@ -2730,6 +2745,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
+ '@types/nodemailer@6.4.24':
+ dependencies:
+ '@types/node': 22.20.0
+
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -3136,11 +3155,13 @@ snapshots:
nanoid@3.3.15: {}
- next-auth@5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7):
+ next-auth@5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@6.10.1)(react@19.2.7):
dependencies:
- '@auth/core': 0.41.2
+ '@auth/core': 0.41.2(nodemailer@6.10.1)
next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
+ optionalDependencies:
+ nodemailer: 6.10.1
next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
@@ -3166,6 +3187,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
+ nodemailer@6.10.1: {}
+
oauth4webapi@3.8.6: {}
ohash@2.0.11: {}
diff --git a/prisma/migrations/0002_users_2fa.sql b/prisma/migrations/0002_users_2fa.sql
new file mode 100644
index 00000000..70f9fcf2
--- /dev/null
+++ b/prisma/migrations/0002_users_2fa.sql
@@ -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;
diff --git a/prisma/migrations/0003_password_resets.sql b/prisma/migrations/0003_password_resets.sql
new file mode 100644
index 00000000..6eec0f59
--- /dev/null
+++ b/prisma/migrations/0003_password_resets.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 812238a8..3b4a0b2c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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.
diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts
new file mode 100644
index 00000000..5cfaf374
--- /dev/null
+++ b/src/actions/auth-precheck.ts
@@ -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 {
+ 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";
+}
diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts
new file mode 100644
index 00000000..76a25023
--- /dev/null
+++ b/src/actions/commandocentrum.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+}
diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts
new file mode 100644
index 00000000..c6985db7
--- /dev/null
+++ b/src/actions/password-reset.ts
@@ -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 {
+ 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`,
+ `Click to reset your password (valid 1 hour):
${link}
`,
+ );
+ }
+ } catch {
+ // swallow — generic response below
+ }
+ }
+
+ redirect("/forgot?sent=1");
+}
+
+export async function resetPassword(formData: FormData): Promise {
+ 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");
+}
diff --git a/src/actions/social.ts b/src/actions/social.ts
new file mode 100644
index 00000000..465af304
--- /dev/null
+++ b/src/actions/social.ts
@@ -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 {
+ 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 {
+ 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`);
+}
diff --git a/src/actions/twofactor.ts b/src/actions/twofactor.ts
new file mode 100644
index 00000000..8e81e759
--- /dev/null
+++ b/src/actions/twofactor.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ const id = await sessionUserId();
+ await prisma.user.update({
+ where: { id },
+ data: {
+ twoFactorSecret: null,
+ twoFactorRecoveryCodes: null,
+ twoFactorConfirmedAt: null,
+ },
+ });
+ redirect("/settings/2fa?disabled=1");
+}
diff --git a/src/app/admin/commandocentrum/page.tsx b/src/app/admin/commandocentrum/page.tsx
new file mode 100644
index 00000000..56abc2c3
--- /dev/null
+++ b/src/app/admin/commandocentrum/page.tsx
@@ -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 (
+
+ Commandocentrum
+
+ Emulator control center — push live reloads and broadcast alerts over RCON.
+
+
+ {/* ── RCON controls ──────────────────────────────────────── */}
+
+ Emulator controls
+
+
+
+
+
+ {/* ── Emulator errors ────────────────────────────────────── */}
+
+ Recent emulator errors
+ {errors.length === 0 ? (
+ No emulator errors logged.
+ ) : (
+
+
+
+
+ When
+ Type
+ Version
+ Stacktrace
+
+
+
+ {errors.map((e) => {
+ const trace = decodeStacktrace(e.stacktrace);
+ return (
+
+
+ {formatTimestamp(e.timestamp)}
+
+ {e.type}
+
+ {e.version}
+
+
+
+ {trace || "(empty)"}
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 07b67dea..0a105159 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -39,6 +39,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
Housekeeping
Permissions
Emulator
+ Commandocentrum
Email
Settings
diff --git a/src/app/badges/page.tsx b/src/app/badges/page.tsx
new file mode 100644
index 00000000..ae3959d9
--- /dev/null
+++ b/src/app/badges/page.tsx
@@ -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 (
+
+
+
Badges
+
+ The badges you can earn and show off around the hotel.
+
+
+
+ {badges.length === 0 ? (
+ No badges available yet.
+ ) : (
+
+ {badges.map((badge) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
{badge.badgeName}
+
+ {badge.badgeKey}
+
+
{badge.badgeDescription}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/forgot/page.tsx b/src/app/forgot/page.tsx
new file mode 100644
index 00000000..5fe7003e
--- /dev/null
+++ b/src/app/forgot/page.tsx
@@ -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 (
+
+
+
Reset password
+ {sent ? (
+
+ If that email is registered, a reset link has been sent. Check your inbox.
+
+ ) : (
+
+ )}
+
+ Back to login
+
+
+
+ );
+}
diff --git a/src/app/guilds/[id]/forum/new/page.tsx b/src/app/guilds/[id]/forum/new/page.tsx
new file mode 100644
index 00000000..4f70d011
--- /dev/null
+++ b/src/app/guilds/[id]/forum/new/page.tsx
@@ -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 (
+
+
+ ← Back to forum
+
+
+ New thread
+
+ in {guild.name || "Unnamed guild"} forum
+
+
+
+
+ );
+}
diff --git a/src/app/help/[category]/page.tsx b/src/app/help/[category]/page.tsx
new file mode 100644
index 00000000..a15d01df
--- /dev/null
+++ b/src/app/help/[category]/page.tsx
@@ -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/').
+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 (
+
+
+ ← Help Center
+
+
+
+
{title}
+
+
+
+ {imageSrc ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+ {/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */}
+
+
+ {hasButton ? (
+
+ ) : null}
+
+
+ {siblings.length > 0 ? (
+
+ More help topics
+
+ {siblings.map((s) => (
+
+ {withHotel(s.name, hotelName)}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index bbc7cf5d..55a24723 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -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(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() {
Sign in
- 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."}
setUsername(e.target.value)}
placeholder="Username"
autoComplete="username"
+ disabled={needs2fa}
/>
setPassword(e.target.value)}
placeholder="Password"
autoComplete="current-password"
+ disabled={needs2fa}
/>
+ {needs2fa ? (
+ 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}
- {pending ? "Signing in…" : "Sign in"}
+ {pending ? "Please wait…" : needs2fa ? "Verify" : "Sign in"}
-
-
- or
-
-
-
- signIn("discord", { callbackUrl: "/" })}
- >
- Continue with Discord
-
- signIn("google", { callbackUrl: "/" })}
- >
- Continue with Google
-
-
+ {!needs2fa ? (
+ <>
+
+
+ or
+
+
+
+ signIn("discord", { callbackUrl: "/" })}
+ >
+ Continue with Discord
+
+ signIn("google", { callbackUrl: "/" })}
+ >
+ Continue with Google
+
+
+ >
+ ) : null}
+
{error ? (
{error}
) : null}
- No account? Create one
+ No account? Create one · Forgot password?
diff --git a/src/app/reset/page.tsx b/src/app/reset/page.tsx
new file mode 100644
index 00000000..f7e2a5b9
--- /dev/null
+++ b/src/app/reset/page.tsx
@@ -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 (
+
+
+
Set a new password
+
+
+
+
+
+ Reset password
+
+
+ {error ? (
+
{error}
+ ) : null}
+
+ Back to login
+
+
+
+ );
+}
diff --git a/src/app/settings/2fa/page.tsx b/src/app/settings/2fa/page.tsx
new file mode 100644
index 00000000..132b8e98
--- /dev/null
+++ b/src/app/settings/2fa/page.tsx
@@ -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 (
+
+
+ ← Settings
+
+ Two-factor authentication
+
+ {sp.enabled ? 2FA is now enabled. 🔒
: null}
+ {sp.disabled ? 2FA has been disabled.
: null}
+ {sp.error === "badcode" ? (
+ That code wasn't valid — try again.
+ ) : null}
+
+ {!hasAppKey ? (
+
+
+ 2FA is unavailable until APP_KEY is configured (the same Laravel
+ APP_KEY as your AtomCMS install, so existing secrets stay readable).
+
+
+ ) : enabled ? (
+
+
+ 2FA is enabled on your account.
+
+
+
+ Disable 2FA
+
+
+
+ ) : pending ? (
+
+
Scan or enter this key
+
+ Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm.
+
+
+ Manual key: {secret}
+
+
+ {uri}
+
+
+
+
+ Confirm
+
+
+
+ ) : (
+
+
+ Add a second layer of security with an authenticator app.
+
+
+
+ Enable 2FA
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 3e3923e8..3f30893a 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -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.
+
+
+
Security
+
+ Protect your account with two-factor authentication.
+
+
+ Two-factor authentication
+
+
);
}
diff --git a/src/env.ts b/src/env.ts
index c52401ac..d5205c84 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -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),
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 70d0e452..7955c8dd 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -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 };
},
}),
diff --git a/src/lib/auth/totp.ts b/src/lib/auth/totp.ts
index 47c993d6..054e4bd9 100644
--- a/src/lib/auth/totp.ts
+++ b/src/lib/auth/totp.ts
@@ -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);
diff --git a/src/lib/services/email.ts b/src/lib/services/email.ts
new file mode 100644
index 00000000..037489bd
--- /dev/null
+++ b/src/lib/services/email.ts
@@ -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 {
+ 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;
+ }
+}