diff --git a/package.json b/package.json index b5d78d43..ab511140 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@prisma/client": "^7.8.0", "bcryptjs": "^3.0.2", "hash-wasm": "^4.12.0", + "nodemailer": "^6.9.0", "next": "^16.2.9", "next-auth": "5.0.0-beta.31", "otplib": "^12.0.1", @@ -28,6 +29,7 @@ }, "devDependencies": { "@types/node": "^22.10.0", + "@types/nodemailer": "^6.4.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "dotenv": "^16.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c559882e..dbf607aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,10 @@ importers: version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-auth: specifier: 5.0.0-beta.31 - version: 5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@6.10.1)(react@19.2.7) + nodemailer: + specifier: ^6.9.0 + version: 6.10.1 otplib: specifier: ^12.0.1 version: 12.0.1 @@ -42,6 +45,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.20.0 + '@types/nodemailer': + specifier: ^6.4.0 + version: 6.4.24 '@types/react': specifier: ^19.2.0 version: 19.2.17 @@ -1240,6 +1246,9 @@ packages: '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/nodemailer@6.4.24': + resolution: {integrity: sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1694,6 +1703,10 @@ packages: sass: optional: true + nodemailer@6.10.1: + resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + engines: {node: '>=6.0.0'} + oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} @@ -2011,13 +2024,15 @@ packages: snapshots: - '@auth/core@0.41.2': + '@auth/core@0.41.2(nodemailer@6.10.1)': dependencies: '@panva/hkdf': 1.2.1 jose: 6.2.3 oauth4webapi: 3.8.6 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) + optionalDependencies: + nodemailer: 6.10.1 '@drizzle-team/brocli@0.10.2': {} @@ -2730,6 +2745,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@6.4.24': + dependencies: + '@types/node': 22.20.0 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -3136,11 +3155,13 @@ snapshots: nanoid@3.3.15: {} - next-auth@5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + next-auth@5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@6.10.1)(react@19.2.7): dependencies: - '@auth/core': 0.41.2 + '@auth/core': 0.41.2(nodemailer@6.10.1) next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 + optionalDependencies: + nodemailer: 6.10.1 next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: @@ -3166,6 +3187,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + nodemailer@6.10.1: {} + oauth4webapi@3.8.6: {} ohash@2.0.11: {} diff --git a/prisma/migrations/0002_users_2fa.sql b/prisma/migrations/0002_users_2fa.sql new file mode 100644 index 00000000..70f9fcf2 --- /dev/null +++ b/prisma/migrations/0002_users_2fa.sql @@ -0,0 +1,6 @@ +-- Fortify-style 2FA columns on the emulator users table. Idempotent (MariaDB). +-- AtomCMS installs already have these; this only adds them when missing. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS two_factor_secret TEXT NULL, + ADD COLUMN IF NOT EXISTS two_factor_recovery_codes TEXT NULL, + ADD COLUMN IF NOT EXISTS two_factor_confirmed_at TIMESTAMP NULL; diff --git a/prisma/migrations/0003_password_resets.sql b/prisma/migrations/0003_password_resets.sql new file mode 100644 index 00000000..6eec0f59 --- /dev/null +++ b/prisma/migrations/0003_password_resets.sql @@ -0,0 +1,7 @@ +-- CMS password reset tokens (Laravel-compatible). Idempotent. +CREATE TABLE IF NOT EXISTS password_resets ( + email VARCHAR(255) NOT NULL, + token VARCHAR(255) NOT NULL, + created_at TIMESTAMP NULL, + PRIMARY KEY (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 812238a8..3b4a0b2c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,10 @@ model User { secretKey String? @map("secret_key") @db.VarChar(40) pincode String? @db.VarChar(11) extraRank Int? @map("extra_rank") + // CMS-added (Laravel Fortify) 2FA columns on the users table. + twoFactorSecret String? @map("two_factor_secret") @db.Text + twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text + twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0) @@map("users") } @@ -101,6 +105,16 @@ model WebsiteSetting { @@map("website_settings") } +/// CMS-owned password reset tokens (Laravel-compatible table). Created via +/// prisma/migrations if absent. +model PasswordReset { + email String @id @db.VarChar(255) + token String @db.VarChar(255) + createdAt DateTime? @map("created_at") @db.Timestamp(0) + + @@map("password_resets") +} + // === GENERATED MODELS (assembled from default.sql + Laravel migrations) === // 186 tables. Regenerated by assemble-schema.mjs — edit the // generator instead of hand-editing below. diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts new file mode 100644 index 00000000..5cfaf374 --- /dev/null +++ b/src/actions/auth-precheck.ts @@ -0,0 +1,38 @@ +"use server"; + +import { checkLogin } from "@/lib/auth/password"; +import { prisma } from "@/lib/prisma"; +import { env } from "@/env"; + +export type PrecheckResult = "ok" | "invalid" | "twofactor"; + +/** + * Validates username+password WITHOUT creating a session, and reports whether a + * TOTP code is still required. Lets the login form do the two-step 2FA flow. + */ +export async function precheckLogin( + username: string, + password: string, +): Promise { + const u = String(username ?? "").trim(); + const p = String(password ?? ""); + if (!u || !p) return "invalid"; + + let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null; + try { + user = await prisma.user.findUnique({ + where: { username: u }, + select: { password: true, twoFactorConfirmedAt: true }, + }); + } catch { + return "invalid"; + } + if (!user) return "invalid"; + + const res = await checkLogin(p, user.password, { + convertPasswords: env.CONVERT_PASSWORDS, + }); + if (!res.valid) return "invalid"; + + return user.twoFactorConfirmedAt ? "twofactor" : "ok"; +} diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts new file mode 100644 index 00000000..76a25023 --- /dev/null +++ b/src/actions/commandocentrum.ts @@ -0,0 +1,53 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { rcon } from "@/lib/services/rcon"; + +const PATH = "/admin/commandocentrum"; + +/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */ +export async function updateCatalog(): Promise { + await requireStaff(); + try { + await rcon.updateCatalog(); + } catch { + // RCON is best-effort; a dead socket must not 500 the admin page. + } + revalidatePath(PATH); +} + +/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */ +export async function updateWordFilter(): Promise { + await requireStaff(); + try { + await rcon.updateWordFilter(); + } catch { + // best-effort + } + revalidatePath(PATH); +} + +/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */ +export async function updateNavigator(): Promise { + await requireStaff(); + try { + await rcon.send("updatenavigator", null); + } catch { + // best-effort + } + revalidatePath(PATH); +} + +/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */ +export async function hotelAlert(formData: FormData): Promise { + await requireStaff(); + const message = String(formData.get("message") ?? "").trim().slice(0, 512); + if (!message) return; + try { + await rcon.send("hotelalert", { message }); + } catch { + // best-effort + } + revalidatePath(PATH); +} diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts new file mode 100644 index 00000000..c6985db7 --- /dev/null +++ b/src/actions/password-reset.ts @@ -0,0 +1,84 @@ +"use server"; + +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { redirect } from "next/navigation"; +import { hashPassword } from "@/lib/auth/password"; +import { prisma } from "@/lib/prisma"; +import { sendMail } from "@/lib/services/email"; +import { env } from "@/env"; + +const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour + +function sha256(s: string): string { + return createHash("sha256").update(s).digest("hex"); +} + +export async function requestReset(formData: FormData): Promise { + const email = String(formData.get("email") ?? "").trim().toLowerCase(); + + // Always respond the same way so we don't reveal which emails exist. + if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + try { + const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } }); + if (user) { + const token = randomBytes(32).toString("hex"); + await prisma.passwordReset.upsert({ + where: { email }, + update: { token: sha256(token), createdAt: new Date() }, + create: { email, token: sha256(token), createdAt: new Date() }, + }); + const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`; + await sendMail( + email, + `${env.HOTEL_NAME} — password reset`, + `

Click to reset your password (valid 1 hour):

${link}

`, + ); + } + } catch { + // swallow — generic response below + } + } + + redirect("/forgot?sent=1"); +} + +export async function resetPassword(formData: FormData): Promise { + const email = String(formData.get("email") ?? "").trim().toLowerCase(); + const token = String(formData.get("token") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + let error: string | null = null; + if (password.length < 6) error = "Password must be at least 6 characters"; + + if (!error) { + try { + const row = await prisma.passwordReset.findUnique({ where: { email } }); + const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false; + const a = Buffer.from(sha256(token), "hex"); + const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length); + const match = row != null && a.length === b.length && timingSafeEqual(a, b); + + if (!row || !fresh || !match) { + error = "This reset link is invalid or has expired"; + } else { + const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } }); + if (!user) { + error = "Account not found"; + } else { + await prisma.user.update({ + where: { id: user.id }, + data: { password: await hashPassword(password) }, + }); + await prisma.passwordReset.delete({ where: { email } }).catch(() => {}); + } + } + } catch { + error = "Could not reset the password — try again"; + } + } + + if (error) { + redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`); + } + redirect("/login?reset=1"); +} diff --git a/src/actions/social.ts b/src/actions/social.ts new file mode 100644 index 00000000..465af304 --- /dev/null +++ b/src/actions/social.ts @@ -0,0 +1,136 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// Guild forum subjects are VARCHAR(255); the comment/message body lives in +// guilds_forums_comments.message which is TEXT. Keep the first post's message +// bounded defensively even though the column is large. +const SUBJECT_MAX = 255; +const MESSAGE_MAX = 10000; + +/** + * Send a friend request to another user. + * + * The REQUESTER (user_from_id) is re-read from the session via auth() and is + * never trusted from the submitted FormData, so a crafted form cannot send a + * request "from" someone else. Only the TARGET user id is taken from the form. + * + * Writes into messenger_friendrequests (userFromId = requester, userToId = + * target). The emulator surfaces the pending request in the in-game messenger. + */ +export async function sendFriendRequest(formData: FormData): Promise { + const session = await auth(); + const fromId = Number(session?.user?.id); + if (!Number.isInteger(fromId) || fromId <= 0) return; + + const toId = Number(formData.get("userId")); + if (!Number.isInteger(toId) || toId <= 0) return; + + // Can't befriend yourself. + if (toId === fromId) return; + + try { + // Guard against duplicate pending requests and already-existing friendships. + const [existingRequest, existingFriendship] = await Promise.all([ + prisma.messengerFriendrequests.findFirst({ + where: { userFromId: fromId, userToId: toId }, + select: { id: true }, + }), + prisma.messengerFriendships.findFirst({ + where: { + OR: [ + { userOneId: fromId, userTwoId: toId }, + { userOneId: toId, userTwoId: fromId }, + ], + }, + select: { id: true }, + }), + ]); + + if (existingRequest || existingFriendship) return; + + await prisma.messengerFriendrequests.create({ + data: { userFromId: fromId, userToId: toId }, + }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + // Optional: revalidate the target profile if a username was supplied, purely + // to refresh any request-state UI rendered there. + const username = String(formData.get("username") ?? "").trim(); + if (username) revalidatePath(`/u/${username}`); +} + +/** + * Open a new thread in a guild's forum. + * + * The AUTHOR (opener_id) is re-read from the session via auth() and is never + * trusted from the submitted FormData. Only the guild id, subject, and message + * come from the form. + * + * AtomCMS/Arcturus splits a thread into a header row (guilds_forums_threads) + * plus the opening post stored as the first comment (guilds_forums_comments). + * We create both in a transaction so the thread always has its first post, then + * stamp posts_count = 1 to match the emulator's bookkeeping. + */ +export async function postThread(formData: FormData): Promise { + const session = await auth(); + const openerId = Number(session?.user?.id); + if (!Number.isInteger(openerId) || openerId <= 0) return; + + const guildId = Number(formData.get("guildId")); + if (!Number.isInteger(guildId) || guildId <= 0) return; + + const subject = String(formData.get("subject") ?? "").trim().slice(0, SUBJECT_MAX); + const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX); + if (!subject || !message) return; + + const now = Math.floor(Date.now() / 1000); + + try { + // Confirm the guild exists (and has a forum) before opening a thread. + const guild = await prisma.guilds.findUnique({ + where: { id: guildId }, + select: { id: true }, + }); + if (!guild) return; + + await prisma.$transaction(async (tx) => { + const thread = await tx.guildsForumsThreads.create({ + data: { + guildId, + openerId, + subject, + postsCount: 1, + createdAt: now, + updatedAt: now, + state: 0, + pinned: 0, + locked: 0, + adminId: 0, + }, + select: { id: true }, + }); + + await tx.guildsForumsComments.create({ + data: { + threadId: thread.id, + userId: openerId, + message, + createdAt: now, + state: 0, + adminId: 0, + }, + }); + }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + revalidatePath(`/guilds/${guildId}/forum`); +} diff --git a/src/actions/twofactor.ts b/src/actions/twofactor.ts new file mode 100644 index 00000000..8e81e759 --- /dev/null +++ b/src/actions/twofactor.ts @@ -0,0 +1,67 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; +import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { env } from "@/env"; + +async function sessionUserId(): Promise { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + return Number(session.user.id); +} + +/** Step 1: generate a secret, store it encrypted but UNconfirmed. */ +export async function beginTwoFactor(): Promise { + const id = await sessionUserId(); + if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); + const secret = generateTotpSecret(); + const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret); + await prisma.user.update({ + where: { id }, + data: { twoFactorSecret: encrypted, twoFactorConfirmedAt: null }, + }); + revalidatePath("/settings/2fa"); +} + +/** Step 2: verify a code against the pending secret, then confirm. */ +export async function confirmTwoFactor(formData: FormData): Promise { + const id = await sessionUserId(); + if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); + const code = String(formData.get("code") ?? "").trim(); + + const user = await prisma.user.findUnique({ + where: { id }, + select: { twoFactorSecret: true }, + }); + + let ok = false; + if (user?.twoFactorSecret && code) { + try { + const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret); + ok = verifyTotp(code, secret); + } catch { + ok = false; + } + } + if (!ok) redirect("/settings/2fa?error=badcode"); + + await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } }); + redirect("/settings/2fa?enabled=1"); +} + +export async function disableTwoFactor(): Promise { + const id = await sessionUserId(); + await prisma.user.update({ + where: { id }, + data: { + twoFactorSecret: null, + twoFactorRecoveryCodes: null, + twoFactorConfirmedAt: null, + }, + }); + redirect("/settings/2fa?disabled=1"); +} diff --git a/src/app/admin/commandocentrum/page.tsx b/src/app/admin/commandocentrum/page.tsx new file mode 100644 index 00000000..56abc2c3 --- /dev/null +++ b/src/app/admin/commandocentrum/page.tsx @@ -0,0 +1,150 @@ +import { + hotelAlert, + updateCatalog, + updateNavigator, + updateWordFilter, +} from "@/actions/commandocentrum"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +function formatTimestamp(ts: number | null | undefined): string { + if (!ts) return ""; + return new Date(ts * 1000).toISOString().slice(0, 19).replace("T", " "); +} + +// stacktrace is a MySQL BLOB (Prisma `Bytes`), so it arrives as a Buffer/ +// Uint8Array. Decode to UTF-8 and trim to a single readable preview line. +function decodeStacktrace(raw: unknown): string { + if (raw == null) return ""; + try { + if (typeof raw === "string") return raw; + return Buffer.from(raw as Uint8Array).toString("utf8"); + } catch { + return ""; + } +} + +export default async function CommandoCentrum() { + const errors = await prisma.emulatorErrors + .findMany({ + orderBy: { timestamp: "desc" }, + take: 50, + }) + .catch(() => []); + + return ( +
+

Commandocentrum

+

+ Emulator control center — push live reloads and broadcast alerts over RCON. +

+ + {/* ── RCON controls ──────────────────────────────────────── */} +
+

Emulator controls

+
+
+

Update catalog

+

+ Reload catalog pages and items on the live server. +

+ +
+ +
+

Update word filter

+

+ Reload the chat word filter from the database. +

+ +
+ +
+

Update navigator

+

+ Reload navigator categories and room listings. +

+ +
+
+ +
+

Hotel alert

+

+ Broadcast a message to every connected user. +

+
+ + +
+
+
+ + {/* ── Emulator errors ────────────────────────────────────── */} +
+

Recent emulator errors

+ {errors.length === 0 ? ( +

No emulator errors logged.

+ ) : ( +
+ + + + + + + + + + + {errors.map((e) => { + const trace = decodeStacktrace(e.stacktrace); + return ( + + + + + + + ); + })} + +
WhenTypeVersionStacktrace
+ {formatTimestamp(e.timestamp)} + {e.type} + {e.version} + +
+                          {trace || "(empty)"}
+                        
+
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 07b67dea..0a105159 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -39,6 +39,7 @@ export default async function AdminLayout({ children }: { children: ReactNode }) Housekeeping Permissions Emulator + Commandocentrum Email Settings diff --git a/src/app/badges/page.tsx b/src/app/badges/page.tsx new file mode 100644 index 00000000..ae3959d9 --- /dev/null +++ b/src/app/badges/page.tsx @@ -0,0 +1,56 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here. +const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; + +export default async function BadgesPage() { + const badges = await prisma.websiteBadges + .findMany({ + orderBy: { badgeName: "asc" }, + }) + .catch(() => []); + + return ( +
+
+

Badges

+

+ The badges you can earn and show off around the hotel. +

+
+ + {badges.length === 0 ? ( +

No badges available yet.

+ ) : ( +
+ {badges.map((badge) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {badge.badgeName} +
+

{badge.badgeName}

+

+ {badge.badgeKey} +

+

{badge.badgeDescription}

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/forgot/page.tsx b/src/app/forgot/page.tsx new file mode 100644 index 00000000..5fe7003e --- /dev/null +++ b/src/app/forgot/page.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import { requestReset } from "@/actions/password-reset"; + +export default async function ForgotPage({ + searchParams, +}: { + searchParams: Promise<{ sent?: string }>; +}) { + const { sent } = await searchParams; + + return ( +
+
+

Reset password

+ {sent ? ( +

+ If that email is registered, a reset link has been sent. Check your inbox. +

+ ) : ( +
+ + +
+ )} +

+ Back to login +

+
+
+ ); +} diff --git a/src/app/guilds/[id]/forum/new/page.tsx b/src/app/guilds/[id]/forum/new/page.tsx new file mode 100644 index 00000000..4f70d011 --- /dev/null +++ b/src/app/guilds/[id]/forum/new/page.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { postThread } from "@/actions/social"; + +export const dynamic = "force-dynamic"; + +export default async function NewThreadPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const guildId = Number(id); + if (!Number.isInteger(guildId) || guildId <= 0) notFound(); + + // Auth gate: only logged-in users may open a thread. + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + // Guild header (read-only). A DB hiccup degrades to notFound rather than 500. + let guild: { id: number; name: string } | null = null; + try { + guild = await prisma.guilds.findUnique({ + where: { id: guildId }, + select: { id: true, name: true }, + }); + } catch { + guild = null; + } + + if (!guild) notFound(); + + return ( +
+

+ ← Back to forum +

+ +

New thread

+

+ in {guild.name || "Unnamed guild"} forum +

+ +
+ {/* Guild target drives the write; the author is taken from the session + inside the action, never from this form. */} + + + + + + +