Add security middleware, audit log, alerts, PayPal, cron, radio + apps

Security (launch blockers):
- src/middleware.ts (edge): forwards x-pathname + real client IP.
- access-guard.ts (Node, from root layout): routes non-staff to /maintenance
  when maintenance mode is on, banned users to /banned. New /banned + /maintenance
  pages (the consumers the admin toggle was missing). Admin layout enforces
  force_staff_2fa before /admin.
- staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions.

Infra (parallel agents): alert service (alert_logs + Discord embed + email),
PayPal top-up (create/capture API routes + /shop/topup), cron worker
(scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check,
bans-cleanup), social connections page, admin radio settings/banners/ranks.
Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways,
apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav
link added. .env.example documents the new optional vars.

(radio song-requests dropped: its table is a stub in AtomCMS — columns added by
un-modeled alter-migrations.)

Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
This commit is contained in:
Simo committed 2026-06-28 15:10:19 +02:00
1 parent e19debb795
commit 22d53d0e9c
40 files changed
+3781 -6

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
export const dynamic = "force-dynamic";
// Social providers we can link. `available` reflects whether the OAuth app
// credentials are configured (mirrors src/lib/auth.ts, which only registers a
// provider when both its id + secret env vars are set). When unavailable the
// connect button is disabled so we don't bounce the user to a 404 sign-in URL.
const PROVIDERS = [
{
key: "discord",
label: "Discord",
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
},
{
key: "google",
label: "Google",
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
},
] as const;
export default async function ConnectionsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const id = Number(session.user.id);
// social_accounts is a CMS-only table that may not exist in every install
// (it has no bundled migration). Fall back to "nothing linked" on any error so
// the page still renders the generic connect buttons.
let linked: Set<string> = new Set();
let lookupFailed = false;
try {
const rows = await prisma.socialAccounts.findMany({
where: { userId: BigInt(id) },
select: { provider: true },
});
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
} catch {
lookupFailed = true;
}
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
const callbackUrl = "/settings/connections";
return (
<main style={{ maxWidth: 560 }}>
<p className="muted">
<Link href="/settings">← Settings</Link>
</p>
<h1>Connected accounts</h1>
<p className="muted" style={{ marginTop: 0 }}>
Link a social account so you can sign in with one click. We match it to
your hotel account by e-mail, so use the address on file:{" "}
<strong>{session.user.name}</strong>.
</p>
{lookupFailed ? (
<div className="card" style={{ marginBottom: "1.5rem" }}>
<p className="muted" style={{ margin: 0 }}>
We couldn't read your linked accounts right now. You can still connect
below — your status will show once it's available.
</p>
</div>
) : null}
<div className="grid" style={{ gap: "1rem" }}>
{PROVIDERS.map((p) => {
const isLinked = linked.has(p.key);
return (
<div
key={p.key}
className="card"
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
>
<div style={{ flex: 1 }}>
<h3 style={{ margin: "0 0 0.25rem" }}>{p.label}</h3>
<p className="muted" style={{ margin: 0 }}>
{!p.available
? "Not available on this hotel."
: isLinked
? "Connected — you can sign in with this account."
: "Not connected."}
</p>
</div>
<div>
{isLinked ? (
<span
className="btn btn-outline"
style={{ cursor: "default", pointerEvents: "none" }}
aria-disabled="true"
>
Connected
</span>
) : p.available ? (
// Plain link into NextAuth's built-in sign-in handler. Re-using
// the same OAuth flow as /login binds the provider to the
// matching hotel account (see auth.ts signIn callback).
<a
className="btn btn-primary"
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(
callbackUrl,
)}`}
>
Connect {p.label}
</a>
) : (
<span
className="btn"
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
aria-disabled="true"
>
Unavailable
</span>
)}
</div>
</div>
);
})}
</div>
<p className="muted" style={{ marginTop: "1.5rem", marginBottom: 0 }}>
Connecting takes you to the provider to authorise, then back here. If your
social account's e-mail doesn't match a hotel account, you'll be returned
to the login screen with a note.
</p>
</main>
);
}
+10
View File
@@ -92,6 +92,16 @@ export default async function SettingsPage() {
Two-factor authentication
</Link>
</div>
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Connected accounts</h3>
<p className="muted" style={{ marginTop: 0 }}>
Link Discord or Google to sign in with one click.
</p>
<Link href="/settings/connections" className="btn btn-outline">
Manage connections
</Link>
</div>
</main>
);
}