diff --git a/.env.example b/.env.example
index a439675..930c1a8 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,7 @@
+# Admin panel login (server only; required in production)
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=change-me-to-a-strong-password
+
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
# Example: https://kickkings.playpoolstudios.com
# APP_ORIGIN=
@@ -16,3 +20,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Directory containing matchmaker logs: history.log (processed) and matchmaker.log (raw).
# Example: /var/www/html/kickkings/logs/matchmaker/logs
# MATCHMAKER_LOGS_DIR=
+
+# Third-party IP geolocation provider (ipgeolocation.io)
+# Used for country code lookup from user IP and ping report IP.
+IPGEOLOCATION_API_KEY=your-ipgeolocation-api-key
diff --git a/deploy/kickkings-admin.service b/deploy/kickkings-admin.service
new file mode 100644
index 0000000..7465c43
--- /dev/null
+++ b/deploy/kickkings-admin.service
@@ -0,0 +1,25 @@
+[Unit]
+Description=KickKings Admin (Next.js)
+After=network.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=next
+Group=next
+WorkingDirectory=/home/next/kickkings_admin
+
+Environment=NODE_ENV=production
+Environment=PATH=/home/next/.nvm/versions/node/v24.13.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+
+ExecStart=/home/next/.nvm/versions/node/v24.13.0/bin/npm run start
+
+Restart=on-failure
+RestartSec=5
+
+# Hardening (optional; remove if the service fails to start)
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..cb651cd 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,5 @@
import type { NextConfig } from "next";
-const nextConfig: NextConfig = {
- /* config options here */
-};
+const nextConfig: NextConfig = {};
export default nextConfig;
diff --git a/schemas/ping_reports.md b/schemas/ping_reports.md
new file mode 100644
index 0000000..7167aee
--- /dev/null
+++ b/schemas/ping_reports.md
@@ -0,0 +1,11 @@
+create table public.ping_reports (
+ id bigint generated by default as identity not null,
+ created_at timestamp with time zone not null default now(),
+ user_id bigint not null,
+ match_id bigint not null,
+ ip_address text null,
+ ping integer not null,
+ constraint ping_reports_pkey primary key (id),
+ constraint ping_reports_user_id_fkey foreign key (user_id) references users (id),
+ constraint ping_reports_match_id_fkey foreign key (match_id) references matches (id)
+) TABLESPACE pg_default;
diff --git a/schemas/users.md b/schemas/users.md
index 7bc6705..addce4f 100644
--- a/schemas/users.md
+++ b/schemas/users.md
@@ -2,7 +2,9 @@ create table public.users (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
username text null,
+ email text null,
password text null,
+ ip_address text null,
cc real null default '0'::real,
rc real null default '0'::real,
last_logged_at timestamp with time zone null default now(),
diff --git a/src/app/actions/settings-actions.ts b/src/app/actions/settings-actions.ts
index 7b2014e..0b6e9a3 100644
--- a/src/app/actions/settings-actions.ts
+++ b/src/app/actions/settings-actions.ts
@@ -6,27 +6,29 @@ import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { parseStoredCoins } from "@/lib/coins-rc";
import { createAdminSupabase } from "@/lib/supabase/admin";
-function normalizeValue(raw: FormDataEntryValue | null): string | null {
- if (raw == null) return null;
- const s = String(raw).trim();
- return s === "" ? null : s;
+const EMPTY = "__empty__";
+const INVALID = "__invalid__";
+
+function normalizeValue(raw: FormDataEntryValue | null): string | typeof EMPTY {
+ const s = raw == null ? "" : String(raw).trim();
+ return s === "" ? EMPTY : s;
}
function normalizeSettingValue(
key: string,
raw: FormDataEntryValue | null,
-): string | null {
+): string | typeof EMPTY | typeof INVALID {
const s = raw == null ? "" : String(raw).trim();
if (key === "entry_fee") {
- if (s === "") return null;
+ if (s === "") return EMPTY;
const coins = parseStoredCoins(s);
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
return String(coins);
}
if (key === "bet_fee") {
- if (s === "") return null;
+ if (s === "") return EMPTY;
const n = Math.round(Number(s));
if (!Number.isFinite(n)) return "__invalid__";
return String(Math.min(100, Math.max(0, n)));
@@ -47,7 +49,7 @@ export async function updateSetting(formData: FormData) {
}
const value = normalizeSettingValue(key, formData.get("value"));
- if (value === "__invalid__") {
+ if (value === INVALID || value === EMPTY) {
redirect("/settings?saveError=1");
}
@@ -80,6 +82,9 @@ export async function insertSetting(formData: FormData) {
if (!key) {
redirect("/settings?addError=missing");
}
+ if (value === EMPTY) {
+ redirect("/settings?addError=missingValue");
+ }
const supabase = createAdminSupabase();
if (!supabase) {
@@ -95,3 +100,27 @@ export async function insertSetting(formData: FormData) {
redirect("/settings");
}
+
+export async function deleteSetting(formData: FormData) {
+ const cookieStore = await cookies();
+ if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
+ redirect("/login");
+ }
+
+ const key = String(formData.get("key") ?? "").trim();
+ if (!key) {
+ redirect("/settings?saveError=1");
+ }
+
+ const supabase = createAdminSupabase();
+ if (!supabase) {
+ redirect("/settings?saveError=1");
+ }
+
+ const { error } = await supabase.from("settings").delete().eq("key", key);
+ if (error) {
+ redirect("/settings?saveError=1");
+ }
+
+ redirect("/settings");
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index f6d5f36..d01072d 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -1,4 +1,12 @@
import { NextResponse } from "next/server";
+import { getClientIp } from "@/lib/auth/client-ip";
+import { verifyAdminCredentials } from "@/lib/auth/credentials";
+import {
+ checkLoginRateLimit,
+ clearLoginAttempts,
+ rateLimitedLoginResponse,
+ recordFailedLogin,
+} from "@/lib/auth/login-rate-limit";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import {
applySessionCookie,
@@ -10,8 +18,23 @@ function invalidRedirect(request: Request) {
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
}
+function invalidJson() {
+ return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+}
+
export async function POST(request: Request) {
const contentType = request.headers.get("content-type") ?? "";
+ const clientIp = getClientIp(request);
+
+ const rateLimit = checkLoginRateLimit(clientIp);
+ if (!rateLimit.allowed) {
+ return rateLimitedLoginResponse(
+ request,
+ contentType,
+ rateLimit.retryAfterSeconds,
+ publicRequestUrl,
+ );
+ }
let username: string;
let password: string;
@@ -31,13 +54,16 @@ export async function POST(request: Request) {
password = String(formData.get("password") ?? "");
}
- if (username !== "admin" || password !== "admin") {
+ if (!verifyAdminCredentials(username, password)) {
+ recordFailedLogin(clientIp);
if (contentType.includes("application/json")) {
- return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+ return invalidJson();
}
return invalidRedirect(request);
}
+ clearLoginAttempts(clientIp);
+
if (contentType.includes("application/json")) {
const res = NextResponse.json({ ok: true });
res.cookies.set(
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index cfd7cad..3675921 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -5,6 +5,7 @@ type Props = {
export default async function LoginPage({ searchParams }: Props) {
const { error } = await searchParams;
const invalid = error === "1";
+ const locked = error === "locked";
return (
@@ -12,16 +13,6 @@ export default async function LoginPage({ searchParams }: Props) {
Admin sign in
-
- Default:{" "}
-
- admin
- {" "}
- /{" "}
-
- admin
-
-
+ {locked ? (
+
+ Too many failed attempts. Wait about 15 minutes, then try again.
+
+ ) : null}
{invalid ? (
Invalid username or password.
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 8b73b9a..bd5811e 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -25,6 +25,10 @@ import {
} from "@/lib/match-log-analysis-server";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
+import {
+ loadPingAnalytics,
+ type PingAnalyticsResult,
+} from "@/lib/ping-analytics";
import type {
AdminMatchRow,
DbMatch,
@@ -118,6 +122,22 @@ export default async function Home({
let analysisTo = utcLast30DaysDateRange().to;
let analysisPlayerIds: number[] = [];
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
+ let pingAnalytics: PingAnalyticsResult = {
+ summary: {
+ totalReports: 0,
+ totalMatches: 0,
+ totalPlayers: 0,
+ avgPing: null,
+ p95Ping: null,
+ badThresholdMs: 120,
+ badReports: 0,
+ badRatePercent: null,
+ },
+ byMatches: [],
+ byPlayers: [],
+ byCountries: [],
+ error: null,
+ };
let statsBundle: Awaited> | null =
null;
@@ -141,7 +161,7 @@ export default async function Home({
} else {
const usersRes = await supabase
.from("users")
- .select("id, created_at, username, cc, rc, last_logged_at")
+ .select("id, created_at, username, email, ip_address, cc, rc, last_logged_at")
.order("id", { ascending: false })
.limit(500);
@@ -252,12 +272,17 @@ export default async function Home({
analysisFrom = range.from;
analysisTo = range.to;
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
- matchAnalysis = await analyzeMatchLogsForMatches(
- matches,
- analysisFrom,
- analysisTo,
- analysisPlayerIds,
- );
+ const [matchLogResult, pingResult] = await Promise.all([
+ analyzeMatchLogsForMatches(
+ matches,
+ analysisFrom,
+ analysisTo,
+ analysisPlayerIds,
+ ),
+ loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
+ ]);
+ matchAnalysis = matchLogResult;
+ pingAnalytics = pingResult;
}
}
@@ -309,6 +334,7 @@ export default async function Home({
analysisTo={analysisTo}
analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis}
+ pingAnalytics={pingAnalytics}
/>
{editUser ? (
("wins");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
@@ -330,6 +334,25 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
+ const filteredUsers = useMemo(() => {
+ const q = playersSearch.trim().toLowerCase();
+ if (!q) return users;
+ return users.filter((u) => {
+ const haystack = [
+ String(u.id),
+ u.username ?? "",
+ u.email ?? "",
+ u.cc == null ? "" : String(u.cc),
+ u.rc == null ? "" : String(u.rc),
+ formatTs(u.created_at),
+ formatTs(u.last_logged_at),
+ u.ip_address ?? "",
+ ]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(q);
+ });
+ }, [users, playersSearch]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
@@ -546,6 +569,7 @@ export function AdminDashboard({
activeDir={lbSortDir}
onActivate={onLbSort}
/>
+ Email
No wins recorded yet (no rows with a winner).
@@ -603,6 +627,9 @@ export function AdminDashboard({
)}
+
+ {row.email?.trim() ? row.email.trim() : "—"}
+
{formatRcBalanceWithCoins(row.rcBalance)}
@@ -630,31 +657,51 @@ export function AdminDashboard({
{usersError ? (
{usersError}
) : (
+
+
+
+ Search players
+ setPlayersSearch(e.target.value)}
+ placeholder="Search by any field (id, username, email, CC, RC, timestamps)"
+ className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400"
+ />
+
+
+ Showing {filteredUsers.length} of {users.length}
+
+
ID
Username
+ Email
CC
RC
Created
Last seen
+ IP
Actions
- {users.length === 0 ? (
+ {filteredUsers.length === 0 ? (
- No players yet.
+ {users.length === 0
+ ? "No players yet."
+ : "No players match this search."}
) : (
- users.map((u) => {
+ filteredUsers.map((u) => {
const isHi =
highlightId != null &&
highlightId === String(u.id);
@@ -672,6 +719,9 @@ export function AdminDashboard({
{u.username ?? "—"}
+
+ {u.email?.trim() ? u.email.trim() : "—"}
+
{u.cc ?? "—"}
{u.rc ?? "—"}
@@ -680,6 +730,9 @@ export function AdminDashboard({
{formatTs(u.last_logged_at)}
+
+ {u.ip_address?.trim() ? u.ip_address.trim() : "—"}
+
+
)}
) : tab === "matchmaker" ? (
@@ -751,6 +805,7 @@ export function AdminDashboard({
) : tab === "analysis" ? (
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
type Props = {
analysis: MatchLogAnalysisResult;
+ pingAnalytics: PingAnalyticsResult;
users: DbUser[];
analysisFrom: string;
analysisTo: string;
@@ -90,6 +105,7 @@ type Props = {
export function AdminMatchAnalysis({
analysis,
+ pingAnalytics,
users,
analysisFrom,
analysisTo,
@@ -116,6 +132,34 @@ export function AdminMatchAnalysis({
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
: null;
+ const fmtMs = (value: number | null): string =>
+ value == null ? "—" : `${Math.round(value).toLocaleString("en-US")} ms`;
+ const fmtPct = (value: number | null): string =>
+ value == null
+ ? "—"
+ : `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })}%`;
+
+ const reports = pingAnalytics.summary.totalReports;
+ const avgPing = pingAnalytics.summary.avgPing;
+ const p95Ping = pingAnalytics.summary.p95Ping;
+ const badThreshold = pingAnalytics.summary.badThresholdMs;
+ const badReports = pingAnalytics.summary.badReports;
+ const badRate = pingAnalytics.summary.badRatePercent;
+ const goodCount =
+ reports > 0 && avgPing != null
+ ? Math.max(0, reports - badReports)
+ : Math.max(0, reports - badReports);
+ const topCountryRows = pingAnalytics.byCountries.slice(0, 8);
+ const [pingView, setPingView] = useState<"matches" | "players" | "countries">(
+ "matches",
+ );
+ const pingRows =
+ pingView === "matches"
+ ? pingAnalytics.byMatches
+ : pingView === "players"
+ ? pingAnalytics.byPlayers
+ : pingAnalytics.byCountries;
+
return (
{analysis.configError ? (
@@ -394,6 +438,183 @@ export function AdminMatchAnalysis({
)}
) : null}
+
+
+
+
+ Ping latency analytics
+
+
+ Visual summary of current ping health and worst latency countries in this filter.
+
+
+
+ {pingAnalytics.error ? (
+
+ Failed to load ping reports: {pingAnalytics.error}
+
+ ) : (
+ <>
+
+
+
+
+ = ${badThreshold}ms`}
+ value={`${badReports.toLocaleString("en-US")} (${fmtPct(badRate)})`}
+ />
+
+
+
+
+ Ping quality split
+
+
+
+
+
+
+
+
+
+
+ Ping breakdown
+
+
+ setPingView("matches")}
+ className={[
+ "rounded px-2.5 py-1 text-xs font-medium transition",
+ pingView === "matches"
+ ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
+ : "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
+ ].join(" ")}
+ >
+ By matches
+
+ setPingView("players")}
+ className={[
+ "rounded px-2.5 py-1 text-xs font-medium transition",
+ pingView === "players"
+ ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
+ : "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
+ ].join(" ")}
+ >
+ By players
+
+ setPingView("countries")}
+ className={[
+ "rounded px-2.5 py-1 text-xs font-medium transition",
+ pingView === "countries"
+ ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
+ : "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
+ ].join(" ")}
+ >
+ By country
+
+
+
+
+ {pingRows.length === 0 ? (
+
+ No ping data for this filter.
+
+ ) : (
+
+ {pingRows.map((row) => (
+
+ ))}
+
+ )}
+
+ >
+ )}
+
);
}
+
+function SplitBar({
+ label,
+ value,
+ total,
+ colorClassName,
+}: {
+ label: string;
+ value: number;
+ total: number;
+ colorClassName: string;
+}) {
+ const pct = total > 0 ? Math.max(0, Math.min(100, (value / total) * 100)) : 0;
+ return (
+
+
+ {label}
+
+ {value.toLocaleString("en-US")} ({pct.toLocaleString("en-US", { maximumFractionDigits: 1 })}%)
+
+
+
+
+ );
+}
+
+function PingEntryBar({
+ label,
+ minPing,
+ avgPing,
+ maxPing,
+}: {
+ label: string;
+ minPing: number;
+ avgPing: number;
+ maxPing: number;
+}) {
+ const clamped = Math.max(0, Math.min(400, avgPing));
+ const widthPct = (clamped / 400) * 100;
+ return (
+
+
+ {label}
+
+ min {Math.round(minPing).toLocaleString("en-US")} ms · avg {Math.round(avgPing).toLocaleString("en-US")} ms · max {Math.round(maxPing).toLocaleString("en-US")} ms
+
+
+
+
+ );
+}
diff --git a/src/components/admin-settings-editor.tsx b/src/components/admin-settings-editor.tsx
index 43cdba0..57250e5 100644
--- a/src/components/admin-settings-editor.tsx
+++ b/src/components/admin-settings-editor.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import {
insertSetting,
+ deleteSetting,
updateSetting,
} from "@/app/actions/settings-actions";
import {
@@ -20,39 +21,58 @@ type Props = {
};
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
+ const [value, setValue] = useState(row.value ?? "");
+ const saveDisabled = value.trim() === "";
+
return (
-
-
-
-
-
-
-
-
- {row.key}
+
+
+
+
+
+
+
+
+
+ {row.key}
+
+
+
+ 0–100 (saved as the number shown).
+
+
+
+
setV(Number(e.target.value))}
+ className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
+ />
+
+ {v}
-
-
- 0–100 (saved as the number shown).
-
-
-
- setV(Number(e.target.value))}
- className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
- />
-
- {v}
-
+
+
+ Save
+
+
+
+
+
{
+ if (!window.confirm(`Delete setting "${row.key}"?`)) {
+ e.preventDefault();
+ }
+ }}
>
- Save
+ Delete
-
-
+
+
);
}
@@ -123,79 +157,103 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
return rcToCoins(Number(t));
}, [rcText]);
+ const saveDisabled = rcText.trim() === "";
+
return (
-
{
- setLocalError(null);
- const trimmed = rcText.trim();
- if (trimmed === "") {
- e.preventDefault();
- setLocalError("Enter an RC value (one decimal place; tenths digit 0–3).");
- return;
- }
- const rc = Number(trimmed);
- const coins = rcToCoins(rc);
- if (coins === null) {
- e.preventDefault();
- setLocalError(
- "Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
- );
- }
- }}
- >
-
-
-
-
-
+ {
+ setLocalError(null);
+ const trimmed = rcText.trim();
+ if (trimmed === "") {
+ e.preventDefault();
+ setLocalError(
+ "Enter an RC value (one decimal place; tenths digit 0–3).",
+ );
+ return;
+ }
+ const rc = Number(trimmed);
+ const coins = rcToCoins(rc);
+ if (coins === null) {
+ e.preventDefault();
+ setLocalError(
+ "Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
+ );
+ }
+ }}
+ >
+
+
+
+
+
+
+ {row.key}
+
+
+ (edit as RC; stored as coins)
+
+
+
setRcText(e.target.value)}
+ className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
+ />
+ {localError ? (
+
+ {localError}
+
+ ) : coinsPreview !== null ? (
+
+ Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
+ the database.
+
+ ) : rcText.trim() !== "" ? (
+
+ Not a valid RC encoding yet — fix the value to save.
+
+ ) : null}
+
+
-
- {row.key}
-
-
- (edit as RC; stored as coins)
-
-
- setRcText(e.target.value)}
- className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
- />
- {localError ? (
-
- {localError}
-
- ) : coinsPreview !== null ? (
-
- Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
- database.
-
- ) : rcText.trim() !== "" ? (
-
- Not a valid RC encoding yet — fix the value to save.
-
- ) : null}
+ Save
+
+
+
+
+
{
+ if (!window.confirm(`Delete setting "${row.key}"?`)) {
+ e.preventDefault();
+ }
+ }}
>
- Save
+ Delete
-
-
+
+
);
}
@@ -214,6 +272,9 @@ export function AdminSettingsEditor({
saveError,
addError,
}: Props) {
+ const [newKey, setNewKey] = useState("");
+ const [newValue, setNewValue] = useState("");
+
return (
{saveError ? (
@@ -255,6 +316,10 @@ export function AdminSettingsEditor({
Enter a non-empty key.
+ ) : addError === "missingValue" ? (
+
+ Enter a non-empty value.
+
) : addError === "config" ? (
Supabase admin client is not configured.
@@ -277,7 +342,8 @@ export function AdminSettingsEditor({
id="new-setting-key"
name="newKey"
type="text"
- required
+ value={newKey}
+ onChange={(e) => setNewKey(e.target.value)}
autoComplete="off"
placeholder="e.g. maintenance_message"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
@@ -294,6 +360,8 @@ export function AdminSettingsEditor({
id="new-setting-value"
name="newValue"
type="text"
+ value={newValue}
+ onChange={(e) => setNewValue(e.target.value)}
autoComplete="off"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
@@ -301,6 +369,7 @@ export function AdminSettingsEditor({
Add row
diff --git a/src/components/player-card-modal.tsx b/src/components/player-card-modal.tsx
index 52a0d7d..e4e9af3 100644
--- a/src/components/player-card-modal.tsx
+++ b/src/components/player-card-modal.tsx
@@ -16,6 +16,7 @@ import {
formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt,
} from "@/lib/coins-rc";
+import { countryFlagFromCode } from "@/lib/ip-geolocation";
import { usePlayerCard } from "@/components/player-card-context";
function formatTsUtc(value: string | null): string {
@@ -153,6 +154,12 @@ export function PlayerCardModal() {
{load.status === "ok" ? (
+
+ Email
+
+ {load.data.email?.trim() ? load.data.email.trim() : "—"}
+
+
Account created
@@ -165,6 +172,25 @@ export function PlayerCardModal() {
{formatTsUtc(load.data.lastSeen)}
+
+ Last logged in IP
+
+ {load.data.lastLoggedInIp?.trim()
+ ? load.data.lastLoggedInIp.trim()
+ : "—"}
+
+
+
+ Country code
+
+ {(() => {
+ const code = load.data.lastLoggedInCountryCode?.trim();
+ if (!code) return "—";
+ const flag = countryFlagFromCode(code);
+ return flag ? `${flag} ${code}` : code;
+ })()}
+
+
) : null}
diff --git a/src/lib/auth/client-ip.ts b/src/lib/auth/client-ip.ts
new file mode 100644
index 0000000..a91cd7b
--- /dev/null
+++ b/src/lib/auth/client-ip.ts
@@ -0,0 +1,16 @@
+/**
+ * Best-effort client IP for rate limiting behind a reverse proxy.
+ * Falls back to a sentinel when unknown so attempts are still counted.
+ */
+export function getClientIp(request: Request): string {
+ const forwarded = request.headers.get("x-forwarded-for");
+ if (forwarded) {
+ const first = forwarded.split(",")[0]?.trim();
+ if (first) return first;
+ }
+
+ const realIp = request.headers.get("x-real-ip")?.trim();
+ if (realIp) return realIp;
+
+ return "unknown";
+}
diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts
new file mode 100644
index 0000000..bd8d4ea
--- /dev/null
+++ b/src/lib/auth/credentials.ts
@@ -0,0 +1,30 @@
+import { timingSafeEqual } from "node:crypto";
+
+function safeEqual(a: string, b: string): boolean {
+ const aBuf = Buffer.from(a);
+ const bBuf = Buffer.from(b);
+ if (aBuf.length !== bBuf.length) return false;
+ return timingSafeEqual(aBuf, bBuf);
+}
+
+export function getAdminUsername(): string {
+ return process.env.ADMIN_USERNAME?.trim() || "admin";
+}
+
+export function getAdminPassword(): string | null {
+ const password = process.env.ADMIN_PASSWORD?.trim();
+ return password || null;
+}
+
+export function verifyAdminCredentials(
+ username: string,
+ password: string,
+): boolean {
+ const expectedUsername = getAdminUsername();
+ const expectedPassword = getAdminPassword();
+ if (!expectedPassword) return false;
+ return (
+ safeEqual(username, expectedUsername) &&
+ safeEqual(password, expectedPassword)
+ );
+}
diff --git a/src/lib/auth/login-rate-limit.ts b/src/lib/auth/login-rate-limit.ts
new file mode 100644
index 0000000..08e8ee5
--- /dev/null
+++ b/src/lib/auth/login-rate-limit.ts
@@ -0,0 +1,100 @@
+import { NextResponse } from "next/server";
+
+const MAX_FAILURES = 5;
+const WINDOW_MS = 15 * 60 * 1000;
+const LOCKOUT_MS = 15 * 60 * 1000;
+
+type Entry = {
+ failures: number;
+ windowStartedAt: number;
+ lockedUntil: number | null;
+};
+
+const attemptsByIp = new Map();
+
+function getEntry(ip: string): Entry {
+ const existing = attemptsByIp.get(ip);
+ if (existing) return existing;
+
+ const entry: Entry = {
+ failures: 0,
+ windowStartedAt: Date.now(),
+ lockedUntil: null,
+ };
+ attemptsByIp.set(ip, entry);
+ return entry;
+}
+
+function resetWindowIfExpired(entry: Entry, now: number): void {
+ if (now - entry.windowStartedAt >= WINDOW_MS) {
+ entry.failures = 0;
+ entry.windowStartedAt = now;
+ if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
+ entry.lockedUntil = null;
+ }
+ }
+}
+
+export type LoginRateLimitResult =
+ | { allowed: true }
+ | { allowed: false; retryAfterSeconds: number };
+
+export function checkLoginRateLimit(ip: string): LoginRateLimitResult {
+ const now = Date.now();
+ const entry = getEntry(ip);
+ resetWindowIfExpired(entry, now);
+
+ if (entry.lockedUntil !== null && entry.lockedUntil > now) {
+ return {
+ allowed: false,
+ retryAfterSeconds: Math.ceil((entry.lockedUntil - now) / 1000),
+ };
+ }
+
+ if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
+ entry.lockedUntil = null;
+ entry.failures = 0;
+ entry.windowStartedAt = now;
+ }
+
+ return { allowed: true };
+}
+
+export function recordFailedLogin(ip: string): void {
+ const now = Date.now();
+ const entry = getEntry(ip);
+ resetWindowIfExpired(entry, now);
+
+ entry.failures += 1;
+ if (entry.failures >= MAX_FAILURES) {
+ entry.lockedUntil = now + LOCKOUT_MS;
+ }
+}
+
+export function clearLoginAttempts(ip: string): void {
+ attemptsByIp.delete(ip);
+}
+
+export function rateLimitedLoginResponse(
+ request: Request,
+ contentType: string,
+ retryAfterSeconds: number,
+ publicRequestUrl: (request: Request, path: string) => URL,
+): NextResponse {
+ const headers = { "Retry-After": String(retryAfterSeconds) };
+
+ if (contentType.includes("application/json")) {
+ return NextResponse.json(
+ {
+ error: "Too many failed login attempts. Try again later.",
+ retryAfterSeconds,
+ },
+ { status: 429, headers },
+ );
+ }
+
+ return NextResponse.redirect(
+ publicRequestUrl(request, "/login?error=locked"),
+ { headers },
+ );
+}
diff --git a/src/lib/dashboard-stats.ts b/src/lib/dashboard-stats.ts
index fe292ca..dc54c20 100644
--- a/src/lib/dashboard-stats.ts
+++ b/src/lib/dashboard-stats.ts
@@ -14,6 +14,7 @@ export type DashboardStatsSnapshot = {
export type LeaderboardRow = {
userId: number;
username: string | null;
+ email: string | null;
/** Matches with `winner_id` equal to this user (same as player card). */
matchesWon: number;
rcBalance: number | null;
@@ -105,7 +106,7 @@ async function fetchTopPlayersByMatchWins(
);
const [usersRes, ...playedRes] = await Promise.all([
- supabase.from("users").select("id, username, rc").in("id", ids),
+ supabase.from("users").select("id, username, email, rc").in("id", ids),
...playedPromises,
]);
@@ -114,10 +115,12 @@ async function fetchTopPlayersByMatchWins(
}
const nameById = new Map();
+ const emailById = new Map();
const rcById = new Map();
for (const u of usersRes.data ?? []) {
const uid = u.id as number;
nameById.set(uid, (u.username as string | null) ?? null);
+ emailById.set(uid, (u.email as string | null) ?? null);
const rawRc = u.rc as number | null;
rcById.set(
uid,
@@ -146,6 +149,7 @@ async function fetchTopPlayersByMatchWins(
return {
userId,
username: nameById.get(userId) ?? null,
+ email: emailById.get(userId) ?? null,
matchesWon,
rcBalance: rcById.get(userId) ?? null,
winRatePercent,
diff --git a/src/lib/ip-geolocation.ts b/src/lib/ip-geolocation.ts
new file mode 100644
index 0000000..2545294
--- /dev/null
+++ b/src/lib/ip-geolocation.ts
@@ -0,0 +1,87 @@
+const IPV4_WITH_OPTIONAL_PORT = /^(\d{1,3}(?:\.\d{1,3}){3})(?::\d{1,5})?$/;
+const COUNTRY_LOOKUP_ENDPOINT = "https://api.ipgeolocation.io/v3/ipgeo";
+const geoLookupCache = new Map>();
+
+function normalizeIp(raw: string | null | undefined): string | null {
+ const value = (raw ?? "").trim();
+ if (!value) return null;
+ if (value.toLowerCase() === "unknown") return null;
+
+ const first = value.split(",")[0]?.trim() ?? value;
+ if (!first) return null;
+
+ if (first.startsWith("::ffff:")) {
+ return first.slice("::ffff:".length);
+ }
+
+ const v4WithPort = IPV4_WITH_OPTIONAL_PORT.exec(first);
+ if (v4WithPort) {
+ return v4WithPort[1] ?? null;
+ }
+
+ // Handle [ipv6]:port style values.
+ if (first.startsWith("[") && first.includes("]")) {
+ const end = first.indexOf("]");
+ if (end > 1) return first.slice(1, end);
+ }
+
+ return first;
+}
+
+async function fetchCountryCodeFromApi(ip: string): Promise {
+ const apiKey = process.env.IPGEOLOCATION_API_KEY?.trim();
+ if (!apiKey) return null;
+ const url = new URL(COUNTRY_LOOKUP_ENDPOINT);
+ url.searchParams.set("apiKey", apiKey);
+ url.searchParams.set("ip", ip);
+
+ try {
+ const res = await fetch(url.toString(), {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ // 12h cache for same IP in Next server runtime.
+ next: { revalidate: 60 * 60 * 12 },
+ });
+ if (!res.ok) return null;
+ const json = (await res.json()) as {
+ location?: { country_code2?: string | null };
+ };
+ const cc = json.location?.country_code2?.trim().toUpperCase();
+ return cc ? cc : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Best-effort ISO country code from an IP address (e.g. "IN", "US"). */
+export async function countryCodeFromIp(
+ ip: string | null | undefined,
+): Promise {
+ const normalized = normalizeIp(ip);
+ if (!normalized) return null;
+
+ const cached = geoLookupCache.get(normalized);
+ if (cached) return cached;
+
+ const promise = fetchCountryCodeFromApi(normalized);
+ geoLookupCache.set(normalized, promise);
+ const result = await promise;
+ if (result == null) {
+ // allow retries for transient API/network failures
+ geoLookupCache.delete(normalized);
+ }
+ return result;
+}
+
+/** Convert ISO country code (e.g. "IN") to emoji flag (e.g. 🇮🇳). */
+export function countryFlagFromCode(
+ countryCode: string | null | undefined,
+): string | null {
+ const cc = (countryCode ?? "").trim().toUpperCase();
+ if (!/^[A-Z]{2}$/.test(cc)) return null;
+ const base = 127397; // Regional indicator symbol letter A offset.
+ const chars = [...cc].map((ch) =>
+ String.fromCodePoint(base + ch.charCodeAt(0)),
+ );
+ return chars.join("");
+}
diff --git a/src/lib/ping-analytics.ts b/src/lib/ping-analytics.ts
new file mode 100644
index 0000000..ac65bba
--- /dev/null
+++ b/src/lib/ping-analytics.ts
@@ -0,0 +1,223 @@
+import type { SupabaseClient } from "@supabase/supabase-js";
+import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation";
+import type { DbPingReport } from "@/types/database";
+
+const BAD_PING_THRESHOLD_MS = 120;
+const TOP_ROWS_LIMIT = 20;
+
+type PingReportWithUser = DbPingReport & {
+ user:
+ | { id: number; username: string | null; ip_address: string | null }[]
+ | null;
+};
+
+export type PingGroupRow = {
+ key: string;
+ label: string;
+ reports: number;
+ players: number;
+ avgPing: number;
+ minPing: number;
+ maxPing: number;
+ badReports: number;
+ badRatePercent: number;
+};
+
+export type PingAnalyticsSummary = {
+ totalReports: number;
+ totalMatches: number;
+ totalPlayers: number;
+ avgPing: number | null;
+ p95Ping: number | null;
+ badThresholdMs: number;
+ badReports: number;
+ badRatePercent: number | null;
+};
+
+export type PingAnalyticsResult = {
+ summary: PingAnalyticsSummary;
+ byMatches: PingGroupRow[];
+ byPlayers: PingGroupRow[];
+ byCountries: PingGroupRow[];
+ error: string | null;
+};
+
+function percentile(sorted: number[], ratio: number): number | null {
+ if (sorted.length === 0) return null;
+ const idx = Math.min(
+ sorted.length - 1,
+ Math.max(0, Math.ceil(sorted.length * ratio) - 1),
+ );
+ return sorted[idx] ?? null;
+}
+
+function toGroupRow(
+ key: string,
+ label: string,
+ pings: number[],
+ players: Set,
+): PingGroupRow {
+ const reports = pings.length;
+ const sum = pings.reduce((acc, value) => acc + value, 0);
+ const minPing = reports > 0 ? Math.min(...pings) : 0;
+ const maxPing = reports > 0 ? Math.max(...pings) : 0;
+ const badReports = pings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length;
+ const badRatePercent = reports > 0 ? (badReports / reports) * 100 : 0;
+
+ return {
+ key,
+ label,
+ reports,
+ players: players.size,
+ avgPing: reports > 0 ? sum / reports : 0,
+ minPing,
+ maxPing,
+ badReports,
+ badRatePercent,
+ };
+}
+
+export async function loadPingAnalytics(
+ supabase: SupabaseClient,
+ from: string,
+ to: string,
+ playerIds: number[],
+): Promise {
+ const rangeStartIso = `${from}T00:00:00.000Z`;
+ const rangeEndIso = `${to}T23:59:59.999Z`;
+
+ let query = supabase
+ .from("ping_reports")
+ .select("id, created_at, user_id, match_id, ip_address, ping, user:users(id, username, ip_address)")
+ .gte("created_at", rangeStartIso)
+ .lte("created_at", rangeEndIso)
+ .order("created_at", { ascending: false })
+ .limit(10000);
+
+ if (playerIds.length > 0) {
+ query = query.in("user_id", playerIds);
+ }
+
+ const { data, error } = await query;
+ if (error) {
+ return {
+ summary: {
+ totalReports: 0,
+ totalMatches: 0,
+ totalPlayers: 0,
+ avgPing: null,
+ p95Ping: null,
+ badThresholdMs: BAD_PING_THRESHOLD_MS,
+ badReports: 0,
+ badRatePercent: null,
+ },
+ byMatches: [],
+ byPlayers: [],
+ byCountries: [],
+ error: error.message,
+ };
+ }
+
+ const rows = (data ?? []) as PingReportWithUser[];
+ const allPings: number[] = [];
+ const uniquePlayers = new Set();
+ const uniqueMatches = new Set();
+
+ const byMatch = new Map }>();
+ const byPlayer = new Map }>();
+ const byCountry = new Map }>();
+ const ipByRowIndex = new Map();
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i]!;
+ const user = row.user?.[0] ?? null;
+ ipByRowIndex.set(i, row.ip_address ?? user?.ip_address ?? null);
+ }
+ const countryByRowIndex = new Map();
+ await Promise.all(
+ [...ipByRowIndex.entries()].map(async ([index, ip]) => {
+ countryByRowIndex.set(index, await countryCodeFromIp(ip));
+ }),
+ );
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i]!;
+ const ping = Number(row.ping);
+ const userId = Number(row.user_id);
+ const matchId = Number(row.match_id);
+ if (!Number.isFinite(ping) || !Number.isFinite(userId) || !Number.isFinite(matchId)) {
+ continue;
+ }
+
+ allPings.push(ping);
+ uniquePlayers.add(userId);
+ uniqueMatches.add(matchId);
+
+ const matchKey = String(matchId);
+ const matchState = byMatch.get(matchKey) ?? {
+ label: `M${matchId}`,
+ pings: [],
+ players: new Set(),
+ };
+ matchState.pings.push(ping);
+ matchState.players.add(userId);
+ byMatch.set(matchKey, matchState);
+
+ const user = row.user?.[0] ?? null;
+ const username = user?.username?.trim();
+ const playerLabel = username ? `${username} (${userId})` : `User ${userId}`;
+ const playerKey = String(userId);
+ const playerState = byPlayer.get(playerKey) ?? {
+ label: playerLabel,
+ pings: [],
+ players: new Set(),
+ };
+ playerState.pings.push(ping);
+ playerState.players.add(userId);
+ byPlayer.set(playerKey, playerState);
+
+ const country = countryByRowIndex.get(i) ?? "UNK";
+ const countryFlag = countryFlagFromCode(country);
+ const countryWithFlag = countryFlag ? `${countryFlag} ${country}` : country;
+ const countryState = byCountry.get(country) ?? {
+ label: countryWithFlag,
+ pings: [],
+ players: new Set(),
+ };
+ countryState.pings.push(ping);
+ countryState.players.add(userId);
+ byCountry.set(country, countryState);
+ }
+
+ const sortedPings = [...allPings].sort((a, b) => a - b);
+ const badReports = allPings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length;
+
+ const toRankedRows = (
+ src: Map }>,
+ ): PingGroupRow[] =>
+ [...src.entries()]
+ .map(([key, state]) => toGroupRow(key, state.label, state.pings, state.players))
+ .sort((a, b) => b.avgPing - a.avgPing || b.reports - a.reports)
+ .slice(0, TOP_ROWS_LIMIT);
+
+ return {
+ summary: {
+ totalReports: allPings.length,
+ totalMatches: uniqueMatches.size,
+ totalPlayers: uniquePlayers.size,
+ avgPing:
+ allPings.length > 0
+ ? allPings.reduce((acc, value) => acc + value, 0) / allPings.length
+ : null,
+ p95Ping: percentile(sortedPings, 0.95),
+ badThresholdMs: BAD_PING_THRESHOLD_MS,
+ badReports,
+ badRatePercent:
+ allPings.length > 0 ? (badReports / allPings.length) * 100 : null,
+ },
+ byMatches: toRankedRows(byMatch),
+ byPlayers: toRankedRows(byPlayer),
+ byCountries: toRankedRows(byCountry),
+ error: null,
+ };
+}
diff --git a/src/lib/player-card-server.ts b/src/lib/player-card-server.ts
index d57c629..43fb7c4 100644
--- a/src/lib/player-card-server.ts
+++ b/src/lib/player-card-server.ts
@@ -1,6 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DbMatch, DbTransaction } from "@/types/database";
import { txAmountToBigInt } from "@/lib/ledger-integrity";
+import { countryCodeFromIp } from "@/lib/ip-geolocation";
const TX_PAGE = 5000;
/** Recent matches listed on the player card (newest first). */
@@ -22,8 +23,11 @@ export type PlayerCardMatchHistoryRow = {
export type PlayerCardData = {
id: number;
username: string | null;
+ email: string | null;
createdAt: string | null;
lastSeen: string | null;
+ lastLoggedInIp: string | null;
+ lastLoggedInCountryCode: string | null;
rcBalance: number | null;
ccBalance: number | null;
matchesPlayed: number;
@@ -83,7 +87,7 @@ export async function fetchPlayerCardData(
const { data: userRow, error: userErr } = await supabase
.from("users")
- .select("id, username, created_at, last_logged_at, rc, cc")
+ .select("id, username, email, created_at, last_logged_at, ip_address, rc, cc")
.eq("id", userId)
.maybeSingle();
@@ -270,8 +274,13 @@ export async function fetchPlayerCardData(
data: {
id: userRow.id as number,
username: (userRow.username as string | null) ?? null,
+ email: (userRow.email as string | null) ?? null,
createdAt: (userRow.created_at as string) ?? null,
lastSeen: (userRow.last_logged_at as string | null) ?? null,
+ lastLoggedInIp: (userRow.ip_address as string | null) ?? null,
+ lastLoggedInCountryCode: await countryCodeFromIp(
+ (userRow.ip_address as string | null) ?? null,
+ ),
rcBalance: (userRow.rc as number | null) ?? null,
ccBalance: (userRow.cc as number | null) ?? null,
matchesPlayed,
diff --git a/src/types/database.ts b/src/types/database.ts
index 0d26cb4..96e7630 100644
--- a/src/types/database.ts
+++ b/src/types/database.ts
@@ -3,7 +3,9 @@ export type DbUser = {
id: number;
created_at: string;
username: string | null;
+ email: string | null;
password: string | null;
+ ip_address: string | null;
cc: number | null;
rc: number | null;
last_logged_at: string | null;
@@ -52,3 +54,13 @@ export type DbTransaction = {
remarks: string | null;
match_id: number | null;
};
+
+/** Mirrors `public.ping_reports` (see schemas/ping_reports.md). */
+export type DbPingReport = {
+ id: number;
+ created_at: string;
+ user_id: number;
+ match_id: number;
+ ip_address: string | null;
+ ping: number;
+};