Add 2FA, email + password reset, and batch-7 pages

Auth (hand-built on the auth core):
- 2FA: User model gains two_factor_secret/recovery_codes/confirmed_at (+ idempotent
  MariaDB migration). authorize() requires a valid TOTP code when 2FA is confirmed
  (secret decrypted via Laravel APP_KEY, fail-closed). Two-step login (precheckLogin
  reveals the code field). /settings/2fa enable/confirm/disable flow.
- Password reset: nodemailer email service; PasswordReset model + migration;
  /forgot (request, generic response) + /reset (token sha256 + 1h TTL, sets argon2id
  hash). Login links to forgot.

Batch 7 (parallel agents): /admin/commandocentrum (RCON controls + emulator_errors),
social write actions (friend request + guild forum new thread), /help/[category],
/badges (public). env: APP_KEY, APP_URL, SMTP_*. Nav extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0 (64 page routes).
This commit is contained in:
Simo committed 2026-06-28 14:25:19 +02:00
1 parent 486ce51559
commit e668fa85ec
24 files changed
+1223 -34

No files matched your search

+2
View File
@@ -19,6 +19,7 @@
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.2",
"hash-wasm": "^4.12.0",
"nodemailer": "^6.9.0",
"next": "^16.2.9",
"next-auth": "5.0.0-beta.31",
"otplib": "^12.0.1",
@@ -28,6 +29,7 @@
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"dotenv": "^16.4.0",
+27 -4
View File
@@ -25,7 +25,10 @@ importers:
version: 16.2.9([email protected]([email protected]))([email protected])
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31([email protected]([email protected]([email protected]))([email protected]))([email protected])
version: 5.0.0-beta.31([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])
nodemailer:
specifier: ^6.9.0
version: 6.10.1
otplib:
specifier: ^12.0.1
version: 12.0.1
@@ -42,6 +45,9 @@ importers:
'@types/node':
specifier: ^22.10.0
version: 22.20.0
'@types/nodemailer':
specifier: ^6.4.0
version: 6.4.24
'@types/react':
specifier: ^19.2.0
version: 19.2.17
@@ -1240,6 +1246,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
'@types/[email protected]':
resolution: {integrity: sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==}
'@types/[email protected]':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1694,6 +1703,10 @@ packages:
sass:
optional: true
[email protected]:
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
engines: {node: '>=6.0.0'}
[email protected]:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
@@ -2011,13 +2024,15 @@ packages:
snapshots:
'@auth/[email protected]':
'@auth/[email protected]([email protected])':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
oauth4webapi: 3.8.6
preact: 10.24.3
preact-render-to-string: 6.5.11([email protected])
optionalDependencies:
nodemailer: 6.10.1
'@drizzle-team/[email protected]': {}
@@ -2730,6 +2745,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/[email protected]':
dependencies:
'@types/node': 22.20.0
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 19.2.17
@@ -3136,11 +3155,13 @@ snapshots:
[email protected]: {}
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected]):
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]):
dependencies:
'@auth/core': 0.41.2
'@auth/core': 0.41.2([email protected])
next: 16.2.9([email protected]([email protected]))([email protected])
react: 19.2.7
optionalDependencies:
nodemailer: 6.10.1
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
@@ -3166,6 +3187,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
[email protected]: {}
[email protected]: {}
[email protected]: {}
+6
View File
@@ -0,0 +1,6 @@
-- Fortify-style 2FA columns on the emulator users table. Idempotent (MariaDB).
-- AtomCMS installs already have these; this only adds them when missing.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS two_factor_secret TEXT NULL,
ADD COLUMN IF NOT EXISTS two_factor_recovery_codes TEXT NULL,
ADD COLUMN IF NOT EXISTS two_factor_confirmed_at TIMESTAMP NULL;
@@ -0,0 +1,7 @@
-- CMS password reset tokens (Laravel-compatible). Idempotent.
CREATE TABLE IF NOT EXISTS password_resets (
email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
created_at TIMESTAMP NULL,
PRIMARY KEY (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+14
View File
@@ -60,6 +60,10 @@ model User {
secretKey String? @map("secret_key") @db.VarChar(40)
pincode String? @db.VarChar(11)
extraRank Int? @map("extra_rank")
// CMS-added (Laravel Fortify) 2FA columns on the users table.
twoFactorSecret String? @map("two_factor_secret") @db.Text
twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0)
@@map("users")
}
@@ -101,6 +105,16 @@ model WebsiteSetting {
@@map("website_settings")
}
/// CMS-owned password reset tokens (Laravel-compatible table). Created via
/// prisma/migrations if absent.
model PasswordReset {
email String @id @db.VarChar(255)
token String @db.VarChar(255)
createdAt DateTime? @map("created_at") @db.Timestamp(0)
@@map("password_resets")
}
// === GENERATED MODELS (assembled from default.sql + Laravel migrations) ===
// 186 tables. Regenerated by assemble-schema.mjs — edit the
// generator instead of hand-editing below.
+38
View File
@@ -0,0 +1,38 @@
"use server";
import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
export type PrecheckResult = "ok" | "invalid" | "twofactor";
/**
* Validates username+password WITHOUT creating a session, and reports whether a
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
*/
export async function precheckLogin(
username: string,
password: string,
): Promise<PrecheckResult> {
const u = String(username ?? "").trim();
const p = String(password ?? "");
if (!u || !p) return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null;
try {
user = await prisma.user.findUnique({
where: { username: u },
select: { password: true, twoFactorConfirmedAt: true },
});
} catch {
return "invalid";
}
if (!user) return "invalid";
const res = await checkLogin(p, user.password, {
convertPasswords: env.CONVERT_PASSWORDS,
});
if (!res.valid) return "invalid";
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
}
+53
View File
@@ -0,0 +1,53 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export async function updateCatalog(): Promise<void> {
await requireStaff();
try {
await rcon.updateCatalog();
} catch {
// RCON is best-effort; a dead socket must not 500 the admin page.
}
revalidatePath(PATH);
}
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export async function updateWordFilter(): Promise<void> {
await requireStaff();
try {
await rcon.updateWordFilter();
} catch {
// best-effort
}
revalidatePath(PATH);
}
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export async function updateNavigator(): Promise<void> {
await requireStaff();
try {
await rcon.send("updatenavigator", null);
} catch {
// best-effort
}
revalidatePath(PATH);
}
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export async function hotelAlert(formData: FormData): Promise<void> {
await requireStaff();
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
if (!message) return;
try {
await rcon.send("hotelalert", { message });
} catch {
// best-effort
}
revalidatePath(PATH);
}
+84
View File
@@ -0,0 +1,84 @@
"use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { redirect } from "next/navigation";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/services/email";
import { env } from "@/env";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
function sha256(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
// Always respond the same way so we don't reveal which emails exist.
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
if (user) {
const token = randomBytes(32).toString("hex");
await prisma.passwordReset.upsert({
where: { email },
update: { token: sha256(token), createdAt: new Date() },
create: { email, token: sha256(token), createdAt: new Date() },
});
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
await sendMail(
email,
`${env.HOTEL_NAME} — password reset`,
`<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`,
);
}
} catch {
// swallow — generic response below
}
}
redirect("/forgot?sent=1");
}
export async function resetPassword(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const token = String(formData.get("token") ?? "").trim();
const password = String(formData.get("password") ?? "");
let error: string | null = null;
if (password.length < 6) error = "Password must be at least 6 characters";
if (!error) {
try {
const row = await prisma.passwordReset.findUnique({ where: { email } });
const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false;
const a = Buffer.from(sha256(token), "hex");
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
const match = row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired";
} else {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
if (!user) {
error = "Account not found";
} else {
await prisma.user.update({
where: { id: user.id },
data: { password: await hashPassword(password) },
});
await prisma.passwordReset.delete({ where: { email } }).catch(() => {});
}
}
} catch {
error = "Could not reset the password — try again";
}
}
if (error) {
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`);
}
redirect("/login?reset=1");
}
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
async function sessionUserId(): Promise<number> {
const session = await auth();
if (!session?.user?.id) redirect("/login");
return Number(session.user.id);
}
/** Step 1: generate a secret, store it encrypted but UNconfirmed. */
export async function beginTwoFactor(): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
await prisma.user.update({
where: { id },
data: { twoFactorSecret: encrypted, twoFactorConfirmedAt: null },
});
revalidatePath("/settings/2fa");
}
/** Step 2: verify a code against the pending secret, then confirm. */
export async function confirmTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
const code = String(formData.get("code") ?? "").trim();
const user = await prisma.user.findUnique({
where: { id },
select: { twoFactorSecret: true },
});
let ok = false;
if (user?.twoFactorSecret && code) {
try {
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
ok = verifyTotp(code, secret);
} catch {
ok = false;
}
}
if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } });
redirect("/settings/2fa?enabled=1");
}
export async function disableTwoFactor(): Promise<void> {
const id = await sessionUserId();
await prisma.user.update({
where: { id },
data: {
twoFactorSecret: null,
twoFactorRecoveryCodes: null,
twoFactorConfirmedAt: null,
},
});
redirect("/settings/2fa?disabled=1");
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -39,6 +39,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin/housekeeping">Housekeeping</Link>
<Link href="/admin/permissions">Permissions</Link>
<Link href="/admin/emulator">Emulator</Link>
<Link href="/admin/commandocentrum">Commandocentrum</Link>
<Link href="/admin/email-templates">Email</Link>
<Link href="/admin/settings">Settings</Link>
</nav>
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function BadgesPage() {
const badges = await prisma.websiteBadges
.findMany({
orderBy: { badgeName: "asc" },
})
.catch(() => []);
return (
<main>
<div className="hero">
<h1 style={{ margin: "0 0 0.35rem" }}>Badges</h1>
<p className="muted" style={{ margin: 0 }}>
The badges you can earn and show off around the hotel.
</p>
</div>
{badges.length === 0 ? (
<p className="muted">No badges available yet.</p>
) : (
<div className="grid cols-3">
{badges.map((badge) => (
<div
key={String(badge.id)}
className="card hover"
style={{ display: "flex", gap: "0.9rem", alignItems: "flex-start" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`}
alt={badge.badgeName}
title={badge.badgeKey}
width={40}
height={40}
style={{ flexShrink: 0 }}
/>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.25rem" }}>{badge.badgeName}</h3>
<p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}>
{badge.badgeKey}
</p>
<p style={{ margin: 0, fontSize: "0.9rem" }}>{badge.badgeDescription}</p>
</div>
</div>
))}
</div>
)}
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { requestReset } from "@/actions/password-reset";
export default async function ForgotPage({
searchParams,
}: {
searchParams: Promise<{ sent?: string }>;
}) {
const { sent } = await searchParams;
return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}>
<h1 style={{ marginTop: 0, textAlign: "center" }}>Reset password</h1>
{sent ? (
<p className="muted" style={{ textAlign: "center" }}>
If that email is registered, a reset link has been sent. Check your inbox.
</p>
) : (
<form action={requestReset} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
<input name="email" type="email" placeholder="Your email" autoComplete="email" required />
<button type="submit" className="btn btn-primary">
Send reset link
</button>
</form>
)}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link>
</p>
</div>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { postThread } from "@/actions/social";
export const dynamic = "force-dynamic";
export default async function NewThreadPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
// Auth gate: only logged-in users may open a thread.
const session = await auth();
if (!session?.user?.id) redirect("/login");
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
let guild: { id: number; name: string } | null = null;
try {
guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true, name: true },
});
} catch {
guild = null;
}
if (!guild) notFound();
return (
<main>
<p style={{ marginTop: 0 }}>
<Link href={`/guilds/${guild.id}/forum`}>← Back to forum</Link>
</p>
<h1 style={{ marginBottom: "0.25rem" }}>New thread</h1>
<p className="muted" style={{ marginTop: 0 }}>
in {guild.name || "Unnamed guild"} forum
</p>
<form action={postThread} className="card" style={{ marginTop: "1rem" }}>
{/* Guild target drives the write; the author is taken from the session
inside the action, never from this form. */}
<input type="hidden" name="guildId" value={String(guild.id)} />
<label htmlFor="thread-subject" className="muted">
Subject
</label>
<input
id="thread-subject"
name="subject"
required
maxLength={255}
placeholder="Thread subject"
style={{ width: "100%", margin: "0.4rem 0 0.9rem" }}
/>
<label htmlFor="thread-message" className="muted">
Message
</label>
<textarea
id="thread-message"
name="message"
required
rows={8}
placeholder="Write your opening post…"
style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }}
/>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="submit" className="btn btn-primary">
Post thread
</button>
<Link href={`/guilds/${guild.id}/forum`} className="btn btn-outline">
Cancel
</Link>
</div>
</form>
</main>
);
}
File diff suppressed because it is too large. Load diff
+70 -30
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import { resetPassword } from "@/actions/password-reset";
export default async function ResetPage({
searchParams,
}: {
searchParams: Promise<{ email?: string; token?: string; error?: string }>;
}) {
const { email = "", token = "", error } = await searchParams;
return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}>
<h1 style={{ marginTop: 0, textAlign: "center" }}>Set a new password</h1>
<form action={resetPassword} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
<input type="hidden" name="email" value={email} />
<input type="hidden" name="token" value={token} />
<input
name="password"
type="password"
placeholder="New password (min 6 chars)"
autoComplete="new-password"
required
/>
<button type="submit" className="btn btn-primary">
Reset password
</button>
</form>
{error ? (
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p>
) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link>
</p>
</div>
</main>
);
}
File diff suppressed because it is too large. Load diff
+11
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { updateMotto } from "@/actions/user-settings";
import { avatarImageUrl } from "@/lib/format";
@@ -81,6 +82,16 @@ export default async function SettingsPage() {
Updates instantly in-game if you are online.
</p>
</div>
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Security</h3>
<p className="muted" style={{ marginTop: 0 }}>
Protect your account with two-factor authentication.
</p>
<Link href="/settings/2fa" className="btn btn-outline">
Two-factor authentication
</Link>
</div>
</main>
);
}
+11
View File
@@ -10,6 +10,17 @@ const schema = z.object({
DATABASE_IDLE_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
DATABASE_CONNECT_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
HOTEL_NAME: z.string().default("Atom"),
APP_URL: z.string().url().default("http://localhost:3000"),
// SMTP (password reset / notifications). Email features no-op if unset.
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_SECURE: z
.string()
.optional()
.transform((v) => v === "true" || v === "1"),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(),
// RCON link to the Arcturus emulator (raw-JSON TCP protocol).
RCON_HOST: z.string().default("127.0.0.1"),
RCON_PORT: z.coerce.number().int().positive().default(3001),
+16
View File
@@ -2,7 +2,9 @@ import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Discord from "next-auth/providers/discord";
import Google from "next-auth/providers/google";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { checkLogin } from "@/lib/auth/password";
import { verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
@@ -15,6 +17,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
code: { label: "2FA code", type: "text" },
},
authorize: async (credentials) => {
const username = String(credentials?.username ?? "").trim();
@@ -37,6 +40,19 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
});
}
// Two-factor: if enabled, a valid TOTP code is required. The secret is
// Laravel-encrypted with APP_KEY (fail closed if it cannot be read).
if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
const code = String(credentials?.code ?? "").trim();
if (!code || !env.APP_KEY) return null;
try {
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
if (!verifyTotp(code, secret)) return null;
} catch {
return null;
}
}
return { id: String(user.id), name: user.username, rank: user.rank };
},
}),
+5
View File
@@ -18,6 +18,11 @@ export function generateTotp(secret: string): string {
return authenticator.generate(secret);
}
/** Generate a fresh base32 secret for enrolling a new authenticator. */
export function generateTotpSecret(): string {
return authenticator.generateSecret();
}
/** otpauth:// URI for provisioning a QR code. */
export function totpKeyUri(secret: string, accountName: string, issuer: string): string {
return authenticator.keyuri(accountName, issuer, secret);
+38
View File
@@ -0,0 +1,38 @@
import nodemailer, { type Transporter } from "nodemailer";
import { env } from "@/env";
let transporter: Transporter | null = null;
function getTransport(): Transporter | null {
if (!env.SMTP_HOST) return null;
if (!transporter) {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT ?? 587,
secure: env.SMTP_SECURE,
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
});
}
return transporter;
}
/** Send an HTML email. No-ops (returns false) when SMTP isn't configured. */
export async function sendMail(to: string, subject: string, html: string): Promise<boolean> {
const t = getTransport();
if (!t) {
console.warn("[email] SMTP not configured — skipping mail to", to);
return false;
}
try {
await t.sendMail({
from: env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`,
to,
subject,
html,
});
return true;
} catch (e) {
console.error("[email] send failed:", (e as Error).message);
return false;
}
}