Add social login (Discord/Google) + batch-6 pages

Auth: NextAuth Discord + Google providers (enabled when env id+secret set);
OAuth signIn allowed only if a hotel account matches the email; jwt binds the
session to that account (id/rank/username). Login page gets social buttons.

Batch 6 (parallel agents): /friends (messenger_friendships), /guilds/[id]/forum
(threads), /admin/navigation (navigator config), /admin/maintenance (toggle
maintenance settings), /admin/alerts (alert_logs + send hotel alert via RCON).
Header (Friends) + admin nav (Alerts/Maintenance/Navigator) extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0 (57 page routes).
This commit is contained in:
Simo committed 2026-06-28 14:13:41 +02:00
1 parent 73f0f54624
commit 486ce51559
12 files changed
+911 -3

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
/**
* Broadcast a hotel-wide alert to every online user via RCON.
*
* Faithful to AtomCMS: the emulator's `hotelalert` command takes a single
* `message` payload. Staff-gated; the message is trimmed/bounded before send.
*/
export async function sendHotelAlert(formData: FormData): Promise<void> {
await requireStaff();
const message = String(formData.get("message") ?? "").trim().slice(0, 1000);
if (!message) return;
try {
await rcon.send("hotelalert", { message });
} catch {
// Best-effort delivery (dead socket / emulator offline) — never 500 the
// admin page. The emulator writes its own alert_logs row on receipt.
}
revalidatePath("/admin/alerts");
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
// AtomCMS's MaintenanceToggle Livewire component):
// maintenance_enabled '1' | '0'
// maintenance_message free text (HTML allowed, shown on the splash)
// min_maintenance_login_rank int as string (min rank that may still log in)
// The Laravel login flow reads these via setting() to gate non-staff logins
// while maintenance is on, so the website_settings keys are the source of truth.
const KEY_ENABLED = "maintenance_enabled";
const KEY_MESSAGE = "maintenance_message";
const KEY_MIN_RANK = "min_maintenance_login_rank";
const COMMENTS: Record<string, string> = {
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
[KEY_MESSAGE]:
"The maintenance message displayed to users while maintenance is activated",
[KEY_MIN_RANK]:
"The minimum rank required to login to the hotel during maintenance",
};
async function upsertSetting(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: COMMENTS[key] ?? null },
});
}
export async function saveMaintenance(formData: FormData): Promise<void> {
await requireStaff();
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
// emulator/Laravel side expects.
const enabled = formData.get("enabled") != null ? "1" : "0";
const message = String(formData.get("message") ?? "");
// Coerce the rank to a non-negative integer; fall back to AtomCMS's default
// of 5 when the field is blank or garbage.
const rawRank = String(formData.get("min_rank") ?? "").trim();
const parsedRank = Number.parseInt(rawRank, 10);
const minRank = Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
await upsertSetting(KEY_ENABLED, enabled);
await upsertSetting(KEY_MESSAGE, message);
await upsertSetting(KEY_MIN_RANK, String(minRank));
siteSettings.reload();
revalidatePath("/admin/maintenance");
}
+131
View File
@@ -0,0 +1,131 @@
import { sendHotelAlert } from "@/actions/admin-alerts";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// Read-only feed of alert_logs (prisma.alertLogs delegate): the emulator and
// CMS write rows here when hotel/user alerts fire. Auto-gated by the admin
// layout (requireStaff); only the mutation re-asserts staff in the action.
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
// Map a free-text severity to a theme-token colour. Unknown severities fall
// back to muted grey rather than inventing a new palette.
function severityColor(severity: string): string {
switch (severity.toLowerCase()) {
case "critical":
case "error":
case "danger":
return "var(--color-danger)";
case "warning":
case "warn":
return "var(--color-primary)";
case "success":
return "var(--color-secondary)";
case "info":
case "notice":
return "var(--color-accent)";
default:
return "var(--color-text-muted)";
}
}
function Badge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: "inline-block",
padding: "0.1rem 0.55rem",
borderRadius: "999px",
background: color,
color: "#fff",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.02em",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</span>
);
}
export default async function AdminAlerts() {
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>> = [];
try {
alerts = await prisma.alertLogs.findMany({
orderBy: { id: "desc" },
take: 100,
});
} catch {
alerts = [];
}
return (
<main>
<h1>Alerts</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only · 100 most recent alert_logs (newest first).
</p>
<form action={sendHotelAlert} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Send hotel alert</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
Broadcasts a message to every online user via RCON (<code>hotelalert</code>).
</p>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-start" }}>
<textarea
name="message"
required
maxLength={1000}
rows={2}
placeholder="Message to broadcast to the hotel…"
style={{ flex: 1, minWidth: 240, resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary">
Send alert
</button>
</div>
</form>
<section className="card">
<h3 style={{ marginTop: 0 }}>Recent alerts ({alerts.length})</h3>
{alerts.length === 0 ? (
<p className="muted">No alert logs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>When</th>
<th>Severity</th>
<th>Type</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{alerts.map((a) => (
<tr key={String(a.id)}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{fromDate(a.createdAt)}
</td>
<td>
<Badge label={a.severity || "—"} color={severityColor(a.severity)} />
</td>
<td>
<Badge label={a.type || "—"} color="var(--color-text-muted)" />
</td>
<td>{a.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+3
View File
@@ -32,6 +32,9 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin/wordfilter">Word Filter</Link>
<Link href="/admin/ip">IP Management</Link>
<Link href="/admin/logs">Logs</Link>
<Link href="/admin/alerts">Alerts</Link>
<Link href="/admin/maintenance">Maintenance</Link>
<Link href="/admin/navigation">Navigator</Link>
<Link href="/admin/teams">Teams</Link>
<Link href="/admin/housekeeping">Housekeeping</Link>
<Link href="/admin/permissions">Permissions</Link>
+144
View File
@@ -0,0 +1,144 @@
import { saveMaintenance } from "@/actions/admin-maintenance";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const KEYS = [
"maintenance_enabled",
"maintenance_message",
"min_maintenance_login_rank",
] as const;
export default async function AdminMaintenance() {
// Read the three maintenance rows from website_settings. Isolated so a DB
// hiccup degrades to defaults rather than 500-ing the page.
let rows: { key: string; value: string }[] = [];
try {
rows = await prisma.websiteSetting.findMany({
where: { key: { in: [...KEYS] } },
select: { key: true, value: true },
});
} catch {
rows = [];
}
const byKey = new Map(rows.map((r) => [r.key, r.value]));
const enabledRaw = byKey.get("maintenance_enabled") ?? "0";
const enabled = enabledRaw === "1" || enabledRaw.toLowerCase() === "true";
const message = byKey.get("maintenance_message") ?? "";
const minRankRaw = byKey.get("min_maintenance_login_rank") ?? "5";
const minRank = Number.parseInt(minRankRaw, 10);
const minRankValue = Number.isFinite(minRank) ? minRank : 5;
return (
<main>
<h1>Maintenance Mode</h1>
<p className="muted">
While maintenance is enabled, only staff (and users at or above the
minimum login rank) can log into the hotel. These values are stored in
<code> website_settings</code> and read live by the login flow.
</p>
{/* ── Current state ─────────────────────────────────────── */}
<div
className="card"
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "1.5rem",
}}
>
<span
aria-hidden="true"
style={{
width: 12,
height: 12,
borderRadius: 999,
flexShrink: 0,
background: enabled
? "var(--color-danger)"
: "var(--color-secondary)",
}}
/>
<div>
<strong>
Maintenance is currently {enabled ? "ENABLED" : "disabled"}.
</strong>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
{enabled
? `Only users with rank ${minRankValue} or higher can log in right now.`
: "The hotel is open to everyone."}
</p>
</div>
</div>
{/* ── Edit form ─────────────────────────────────────────── */}
<form action={saveMaintenance} className="card">
<h3 style={{ marginTop: 0 }}>Settings</h3>
<label
htmlFor="mt-enabled"
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
}}
>
<input
id="mt-enabled"
type="checkbox"
name="enabled"
value="1"
defaultChecked={enabled}
style={{ width: "auto" }}
/>
<span>Enable maintenance mode</span>
</label>
<div style={{ marginBottom: "1rem", maxWidth: 220 }}>
<label
htmlFor="mt-min-rank"
className="muted"
style={{ display: "block", marginBottom: "0.3rem" }}
>
Minimum login rank during maintenance
</label>
<input
id="mt-min-rank"
type="number"
name="min_rank"
min={0}
step={1}
defaultValue={minRankValue}
style={{ width: "100%" }}
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label
htmlFor="mt-message"
className="muted"
style={{ display: "block", marginBottom: "0.3rem" }}
>
Maintenance message (shown on the splash; basic HTML allowed)
</label>
<textarea
id="mt-message"
name="message"
rows={8}
defaultValue={message}
placeholder="We're working hard on the hotel — back soon!"
style={{ width: "100%", resize: "vertical" }}
/>
</div>
<button type="submit" className="btn btn-primary">
Save changes
</button>
</form>
</main>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the
// navigator. The Prisma delegate exists because the table has a primary key.
type Flatcat = {
id: number;
caption: string;
captionSave: string;
minRank: number;
maxUserCount: number;
canTrade: string;
public: string;
listType: number;
orderNum: number;
};
// navigator_publics — emulator-owned public-room assignments. This table has no
// primary/unique key, so Prisma marks the model `@@ignore` and generates NO
// delegate (prisma.navigatorPublics does not exist). It must be read via raw SQL.
type PublicRow = {
public_cat_id: number;
room_id: number;
visible: string;
};
export default async function AdminNavigation() {
let flatcats: Flatcat[] = [];
try {
flatcats = await prisma.navigatorFlatcats.findMany({
select: {
id: true,
caption: true,
captionSave: true,
minRank: true,
maxUserCount: true,
canTrade: true,
public: true,
listType: true,
orderNum: true,
},
orderBy: [{ orderNum: "asc" }, { id: "asc" }],
});
} catch {
flatcats = [];
}
// Raw read because navigator_publics is an `@@ignore`d (keyless) model.
let publics: PublicRow[] = [];
try {
publics = await prisma.$queryRaw<PublicRow[]>`
SELECT public_cat_id, room_id, visible
FROM navigator_publics
ORDER BY public_cat_id ASC, room_id ASC
`;
} catch {
publics = [];
}
// Resolve the public-category names and room names referenced by the rows above
// so the read-only view is legible. Each lookup degrades to an empty map on error.
const catIds = Array.from(new Set(publics.map((p) => p.public_cat_id)));
const roomIds = Array.from(new Set(publics.map((p) => p.room_id)));
let catNameById = new Map<number, string>();
if (catIds.length) {
try {
const cats = await prisma.navigatorPubliccats.findMany({
where: { id: { in: catIds } },
select: { id: true, name: true },
});
catNameById = new Map(cats.map((c) => [c.id, c.name]));
} catch {
catNameById = new Map();
}
}
let roomNameById = new Map<number, string>();
if (roomIds.length) {
try {
const rooms = await prisma.rooms.findMany({
where: { id: { in: roomIds } },
select: { id: true, name: true },
});
roomNameById = new Map(rooms.map((r) => [r.id, r.name]));
} catch {
roomNameById = new Map();
}
}
return (
<main>
<h1>Navigator</h1>
<p className="muted" style={{ marginTop: 0 }}>
Read-only · the navigator config is owned by the emulator. Edit it in your
emulator tooling, not here.
</p>
{/* ── navigator_flatcats ───────────────────────────────── */}
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: "1.5rem" }}>
<div style={{ padding: "1.1rem 1.25rem 0" }}>
<h3 style={{ margin: 0 }}>Flat categories</h3>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
<code>navigator_flatcats</code> · {flatcats.length} rows
</p>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Caption</th>
<th>Caption (save)</th>
<th>Min rank</th>
<th>Max users</th>
<th>Can trade</th>
<th>Public</th>
<th>List type</th>
<th>Order</th>
</tr>
</thead>
<tbody>
{flatcats.map((c) => (
<tr key={c.id}>
<td>{c.id}</td>
<td>{c.caption || "—"}</td>
<td>{c.captionSave || "—"}</td>
<td>{c.minRank}</td>
<td>{c.maxUserCount}</td>
<td>{c.canTrade === "1" ? "Yes" : "No"}</td>
<td>{c.public === "1" ? "Yes" : "No"}</td>
<td>{c.listType}</td>
<td>{c.orderNum}</td>
</tr>
))}
</tbody>
</table>
{flatcats.length === 0 ? (
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
No flat categories found.
</p>
) : null}
</div>
{/* ── navigator_publics ────────────────────────────────── */}
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<div style={{ padding: "1.1rem 1.25rem 0" }}>
<h3 style={{ margin: 0 }}>Public rooms</h3>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
<code>navigator_publics</code> · {publics.length} rows
</p>
</div>
<table>
<thead>
<tr>
<th>Public cat</th>
<th>Room</th>
<th>Visible</th>
</tr>
</thead>
<tbody>
{publics.map((p, i) => (
<tr key={`${p.public_cat_id}-${p.room_id}-${i}`}>
<td>
{catNameById.get(p.public_cat_id) ?? "—"}{" "}
<span className="muted">#{p.public_cat_id}</span>
</td>
<td>
{roomNameById.get(p.room_id) || "(unnamed)"}{" "}
<span className="muted">#{p.room_id}</span>
</td>
<td>{p.visible === "1" ? "Yes" : "No"}</td>
</tr>
))}
</tbody>
</table>
{publics.length === 0 ? (
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
No public-room assignments found.
</p>
) : null}
</div>
</main>
);
}
+127
View File
@@ -0,0 +1,127 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function FriendsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// Friendships are bidirectional and stored once: the session user can appear
// as either user_one_id or user_two_id, with the *other* column being the
// friend. Read both directions, then dedupe the friend ids.
const friendships = await prisma.messengerFriendships
.findMany({
where: {
OR: [{ userOneId: userId }, { userTwoId: userId }],
},
select: { userOneId: true, userTwoId: true },
})
.catch(() => []);
const friendIds = Array.from(
new Set(
friendships
.map((f) => (f.userOneId === userId ? f.userTwoId : f.userOneId))
.filter((id) => id && id !== userId),
),
);
const [friends, imagerBase] = await Promise.all([
friendIds.length
? prisma.user
.findMany({
where: { id: { in: friendIds } },
select: { id: true, username: true, look: true, motto: true, online: true },
orderBy: { username: "asc" },
})
.catch(() => [])
: Promise.resolve([]),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
return (
<main>
<h1>Friends</h1>
<p className="muted" style={{ marginTop: "-0.25rem" }}>
{friends.length === 0
? "You have no friends added yet."
: `You have ${friends.length} friend${friends.length === 1 ? "" : "s"}.`}
</p>
{friends.length === 0 ? (
<div className="card">
<p className="muted" style={{ margin: 0 }}>
Add friends in the hotel and they will show up here.
</p>
</div>
) : (
<div className="grid cols-3">
{friends.map((friend) => {
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
size: "s",
headOnly: true,
});
const isOnline = friend.online === "1";
return (
<div
key={friend.id}
className="card hover"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${friend.username} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<p style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span
aria-hidden
title={isOnline ? "Online" : "Offline"}
style={{
width: 9,
height: 9,
borderRadius: "999px",
flexShrink: 0,
background: isOnline
? "var(--color-accent)"
: "var(--color-text-muted)",
}}
/>
<Link href={`/u/${friend.username}`}>
<strong>{friend.username}</strong>
</Link>
</p>
<p
className="muted"
style={{
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{friend.motto || "No motto"}
</p>
</div>
</div>
);
})}
</div>
)}
</main>
);
}
+162
View File
@@ -0,0 +1,162 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type ThreadRow = {
id: number;
openerId: number | null;
subject: string | null;
postsCount: number | null;
createdAt: number | null;
updatedAt: number | null;
pinned: number | null;
locked: number | null;
};
function formatTimestamp(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export default async function GuildForumPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
let guild: { id: number; name: string; badge: string } | null = null;
try {
guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true, name: true, badge: true },
});
} catch {
guild = null;
}
if (!guild) notFound();
// Threads for this guild. Pinned first, then most-recently active.
let threads: ThreadRow[] = [];
try {
threads = await prisma.guildsForumsThreads.findMany({
where: { guildId: guild.id, state: 0 },
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }, { id: "desc" }],
take: 100,
select: {
id: true,
openerId: true,
subject: true,
postsCount: true,
createdAt: true,
updatedAt: true,
pinned: true,
locked: true,
},
});
} catch {
threads = [];
}
// Resolve opener usernames in one query (no relation exists in the schema, so
// join the users table manually by id). Falls back to the raw author id.
const openerIds = Array.from(
new Set(
threads
.map((t) => t.openerId ?? 0)
.filter((v) => v > 0),
),
);
let users: { id: number; username: string }[] = [];
try {
users = openerIds.length
? await prisma.user.findMany({
where: { id: { in: openerIds } },
select: { id: true, username: true },
})
: [];
} catch {
users = [];
}
const usernameById = new Map(users.map((u) => [u.id, u.username]));
return (
<main>
<p style={{ marginTop: 0 }}>
<Link href={`/guilds/${guild.id}`}>← Back to guild</Link>
</p>
<h1 style={{ marginBottom: "0.25rem" }}>
{guild.name || "Unnamed guild"} — Forum
</h1>
<p className="muted" style={{ marginTop: 0 }}>
{threads.length} {threads.length === 1 ? "thread" : "threads"}
</p>
{threads.length === 0 ? (
<p className="muted">No threads in this forum yet.</p>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<th style={{ padding: "0.6rem 0.75rem" }}>Subject</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Author</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Posts</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Date</th>
</tr>
</thead>
<tbody>
{threads.map((t) => {
const opener = t.openerId ?? 0;
const username = opener > 0 ? usernameById.get(opener) : undefined;
return (
<tr key={t.id}>
<td style={{ padding: "0.6rem 0.75rem" }}>
<span style={{ fontWeight: 700 }}>
{t.subject?.trim() || "(no subject)"}
</span>
{t.pinned ? (
<span className="muted" style={{ marginLeft: "0.5rem" }}>
· Pinned
</span>
) : null}
{t.locked ? (
<span className="muted" style={{ marginLeft: "0.5rem" }}>
· Locked
</span>
) : null}
</td>
<td style={{ padding: "0.6rem 0.75rem" }}>
{username ? (
<Link href={`/u/${username}`}>{username}</Link>
) : (
<span className="muted">
{opener > 0 ? `User #${opener}` : "Unknown"}
</span>
)}
</td>
<td style={{ padding: "0.6rem 0.75rem" }}>
{t.postsCount ?? 0}
</td>
<td className="muted" style={{ padding: "0.6rem 0.75rem" }}>
{formatTimestamp(t.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</main>
);
}
+22
View File
@@ -48,6 +48,28 @@ export default function LoginPage() {
{pending ? "Signing in…" : "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>
{error ? (
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p>
) : null}
+3
View File
@@ -97,6 +97,9 @@ export async function SiteHeader() {
{diamonds}
</span>
</div>
<Link className="nav-item" href="/friends">
Friends
</Link>
<Link className="nav-item" href="/redeem">
Redeem
</Link>
+7
View File
@@ -17,6 +17,13 @@ const schema = z.object({
RCON_MAX_RETRIES: z.coerce.number().int().positive().default(3),
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
AUTH_SECRET: z.string().min(1).optional(),
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
APP_KEY: z.string().optional(),
// Optional OAuth providers (enabled only when both id+secret are set).
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->argon2id.
CONVERT_PASSWORDS: z
.string()
+43 -3
View File
@@ -1,5 +1,7 @@
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 { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
@@ -38,13 +40,51 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return { id: String(user.id), name: user.username, rank: user.rank };
},
}),
// OAuth providers — enabled only when both id + secret are configured.
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
? [Discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET })]
: []),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? [Google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET })]
: []),
],
callbacks: {
jwt({ token, user }) {
if (user) token.rank = (user as { rank?: number }).rank;
async signIn({ user, account }) {
if (account?.provider === "credentials") return true;
// OAuth: only allow if a hotel account with this email already exists.
const email = user.email;
if (!email) return "/login?error=NoEmail";
try {
const dbUser = await prisma.user.findFirst({
where: { mail: email },
select: { id: true },
});
return dbUser ? true : "/login?error=NoAccount";
} catch {
return "/login?error=Unavailable";
}
},
async jwt({ token, user, account }) {
if (user && account?.provider === "credentials") {
token.rank = (user as { rank?: number }).rank;
} else if (user?.email) {
// OAuth: bind the session to the matching hotel account.
try {
const dbUser = await prisma.user.findFirst({
where: { mail: user.email },
select: { id: true, rank: true, username: true },
});
if (dbUser) {
token.sub = String(dbUser.id);
token.rank = dbUser.rank;
token.name = dbUser.username;
}
} catch {
// leave token as-is on lookup failure
}
}
return token;
},
// NextAuth stores the user id in token.sub automatically; surface id + rank.
session({ session, token }) {
if (token.sub && session.user) session.user.id = token.sub;
if (typeof token.rank === "number" && session.user) session.user.rank = token.rank;