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:
1 parent
486ce51559
commit
e668fa85ec
24 files changed
+1223
-34
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+27
-4
@@ -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]: {}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user