Fill the remaining gaps: badge upload, radio tools, VPN, writeable boxes
Built the admin tools previously listed as missing: - Badge upload (/admin/badges): uploads a <code>.gif into the emulator's badge dir via BADGE_UPLOAD_DIR (node:fs); validated code/type/size, logged. Made configurable rather than skipped. - Radio tools: /admin/radio/api-keys (CRUD, server-generated keys), /admin/radio/autodj (Auto-DJ playlist CRUD), /admin/radio/embed (embed snippet generator), /admin/radio/points (points settings), /admin/radio/monitoring (live stream/now-playing/listeners status). radio_api_keys + radio_auto_dj_playlist already had real columns. - /admin/vpn: VPN/proxy detection config (block toggle + provider + key), complementing /admin/ip's raw blacklist. - Writeable boxes: new website_writeable_boxes table (model + migration 0006) + /admin/writeable-boxes CRUD; active boxes render on the public home page. env: BADGE_UPLOAD_DIR. Verified live (prod, amx_test): all 8 pages render with real data; a test writeable box appeared on the public home and was reverted. tsc 0, vitest 49/49, next build 0 (7 new admin routes).
This commit is contained in:
1 parent
e004dfedaf
commit
0cd4d06ff6
21 files changed
+2443
-61
No files matched your search
@@ -0,0 +1,163 @@
|
||||
import Link from "next/link";
|
||||
import { saveVpn } from "@/actions/admin-vpn";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "VPN" };
|
||||
|
||||
const PROVIDERS: { value: string; label: string }[] = [
|
||||
{ value: "none", label: "None (disabled)" },
|
||||
{ value: "proxycheck", label: "proxycheck.io" },
|
||||
{ value: "ipqualityscore", label: "IPQualityScore" },
|
||||
];
|
||||
|
||||
export default async function AdminVpn({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ saved?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
|
||||
// Read current values (graceful fallbacks via siteSettings / DEFAULTS).
|
||||
const enabled = await siteSettings.getBool("vpn_block_enabled", false);
|
||||
const provider = (await siteSettings.get("vpn_provider", "none")) ?? "none";
|
||||
const apiKey = (await siteSettings.get("vpn_api_key", "")) ?? "";
|
||||
const blockMessage =
|
||||
(await siteSettings.get(
|
||||
"vpn_block_message",
|
||||
"Registrations from VPNs or proxies are not allowed.",
|
||||
)) ?? "";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="admin-section">
|
||||
<h2>VPN / Proxy Detection</h2>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Block registrations from detected VPN/proxy IPs at sign-up. Settings
|
||||
are stored in <code>website_settings</code> (booleans use{" "}
|
||||
<code>0</code> / <code>1</code>). This is distinct from the raw IP
|
||||
allow/deny list — manage that under <Link href="/admin/ip">IP Management</Link>.
|
||||
</p>
|
||||
|
||||
{sp.saved ? (
|
||||
<p className="admin-badge ok" style={{ display: "inline-block" }}>
|
||||
VPN settings saved
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard
|
||||
label="Protection"
|
||||
value={enabled ? "On" : "Off"}
|
||||
state={enabled ? "ok" : "danger"}
|
||||
hint={enabled ? `Provider: ${provider}` : "Registrations not screened"}
|
||||
icon="🛡️"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Settings</h2>
|
||||
<form action={saveVpn} className="card">
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="vpn_block_enabled"
|
||||
value="1"
|
||||
defaultChecked={enabled}
|
||||
/>
|
||||
<span style={{ fontWeight: 700 }}>
|
||||
Block registrations from detected VPN/proxy IPs
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="vpn_provider"
|
||||
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
|
||||
>
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
id="vpn_provider"
|
||||
name="vpn_provider"
|
||||
defaultValue={provider}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
|
||||
Detection service used to score incoming IPs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="vpn_api_key"
|
||||
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
|
||||
>
|
||||
API key
|
||||
</label>
|
||||
<input
|
||||
id="vpn_api_key"
|
||||
name="vpn_api_key"
|
||||
defaultValue={apiKey}
|
||||
autoComplete="off"
|
||||
placeholder="Provider API key"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
|
||||
Required by the selected provider (leave blank for none).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label
|
||||
htmlFor="vpn_block_message"
|
||||
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
|
||||
>
|
||||
Block message
|
||||
</label>
|
||||
<input
|
||||
id="vpn_block_message"
|
||||
name="vpn_block_message"
|
||||
defaultValue={blockMessage}
|
||||
placeholder="Shown to users blocked for using a VPN/proxy"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
|
||||
Message displayed when a registration is rejected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Raw IP allow / deny list</h2>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
VPN detection screens unknown IPs automatically. To explicitly allow
|
||||
or block specific IP addresses or ASNs (overriding detection), use{" "}
|
||||
<Link href="/admin/ip">IP Management</Link>.
|
||||
</p>
|
||||
<Link href="/admin/ip" className="btn btn-secondary">
|
||||
Open IP Management
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user