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");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+70
-30
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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