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

+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>
);
}