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:
1 parent
73f0f54624
commit
486ce51559
12 files changed
+911
-3
No files matched your search
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
|||||||
<Link href="/admin/wordfilter">Word Filter</Link>
|
<Link href="/admin/wordfilter">Word Filter</Link>
|
||||||
<Link href="/admin/ip">IP Management</Link>
|
<Link href="/admin/ip">IP Management</Link>
|
||||||
<Link href="/admin/logs">Logs</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/teams">Teams</Link>
|
||||||
<Link href="/admin/housekeeping">Housekeeping</Link>
|
<Link href="/admin/housekeeping">Housekeeping</Link>
|
||||||
<Link href="/admin/permissions">Permissions</Link>
|
<Link href="/admin/permissions">Permissions</Link>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,28 @@ export default function LoginPage() {
|
|||||||
{pending ? "Signing in…" : "Sign in"}
|
{pending ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 ? (
|
{error ? (
|
||||||
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p>
|
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ export async function SiteHeader() {
|
|||||||
{diamonds}
|
{diamonds}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Link className="nav-item" href="/friends">
|
||||||
|
Friends
|
||||||
|
</Link>
|
||||||
<Link className="nav-item" href="/redeem">
|
<Link className="nav-item" href="/redeem">
|
||||||
Redeem
|
Redeem
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ const schema = z.object({
|
|||||||
RCON_MAX_RETRIES: z.coerce.number().int().positive().default(3),
|
RCON_MAX_RETRIES: z.coerce.number().int().positive().default(3),
|
||||||
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
|
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
|
||||||
AUTH_SECRET: z.string().min(1).optional(),
|
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.
|
// Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->argon2id.
|
||||||
CONVERT_PASSWORDS: z
|
CONVERT_PASSWORDS: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
+43
-3
@@ -1,5 +1,7 @@
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
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 { checkLogin } from "@/lib/auth/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { env } from "@/env";
|
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 };
|
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: {
|
callbacks: {
|
||||||
jwt({ token, user }) {
|
async signIn({ user, account }) {
|
||||||
if (user) token.rank = (user as { rank?: number }).rank;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
// NextAuth stores the user id in token.sub automatically; surface id + rank.
|
|
||||||
session({ session, token }) {
|
session({ session, token }) {
|
||||||
if (token.sub && session.user) session.user.id = token.sub;
|
if (token.sub && session.user) session.user.id = token.sub;
|
||||||
if (typeof token.rank === "number" && session.user) session.user.rank = token.rank;
|
if (typeof token.rank === "number" && session.user) session.user.rank = token.rank;
|
||||||
|
|||||||
Reference in new issue
Block a user