account system and syslogs

This commit is contained in:
2026-07-26 18:31:43 +00:00
parent 540f6e0af1
commit 6f7af751e1
37 changed files with 2475 additions and 258 deletions
+11 -4
View File
@@ -1,10 +1,17 @@
# Admin panel login (server only; required in production) # Admin panel bootstrap (server only). Used once to create the admin account
# in data/admin-accounts.json. After bootstrap, change the password in Settings.
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-a-strong-password ADMIN_PASSWORD=change-me-to-a-strong-password
# Read-only supervisor login (server only; can view all data but cannot modify) # Optional: HMAC secret for signed session cookies. Defaults to a value derived
SUPERVISOR_USERNAME=supervisor # from ADMIN_PASSWORD when unset.
SUPERVISOR_PASSWORD=change-me-to-a-strong-password # ADMIN_SESSION_SECRET=
# Optional: absolute path for the accounts JSON file (default: <cwd>/data/admin-accounts.json)
# ADMIN_ACCOUNTS_PATH=
# Optional: absolute path for the panel audit log (default: <cwd>/data/admin-audit.jsonl)
# ADMIN_AUDIT_LOG_PATH=
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects). # Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
# Example: https://kickkings.playpoolstudios.com # Example: https://kickkings.playpoolstudios.com
+5
View File
@@ -39,3 +39,8 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# admin panel accounts (passwords hashed; still keep private)
/data/admin-accounts.json
/data/admin-audit.jsonl
/data/*.tmp
+1
View File
@@ -0,0 +1 @@
+161
View File
@@ -0,0 +1,161 @@
"use server";
import { redirect } from "next/navigation";
import {
createAccount,
deleteAccount,
findAccountById,
updateAccount,
} from "@/lib/auth/accounts-store";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
normalizePagePermissions,
PAGE_KEYS,
type PageKey,
type PagePermissions,
} from "@/lib/auth/permissions";
import { requireAdmin } from "@/lib/auth/require-session";
function parsePermissionsFromForm(formData: FormData): PagePermissions {
const raw: Partial<Record<PageKey, { read: boolean; write: boolean }>> = {};
for (const key of PAGE_KEYS) {
raw[key] = {
read: formData.get(`perm_${key}_read`) === "1",
write: formData.get(`perm_${key}_write`) === "1",
};
}
return normalizePagePermissions(raw);
}
export async function createAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
const permissions = parsePermissionsFromForm(formData);
if (!username) {
redirect("/settings?accountError=missingUsername");
}
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await createAccount({ username, password, permissions });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.create",
summary: `Created account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=created");
}
export async function updateAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const isAdminRow = formData.get("isAdmin") === "1";
const password = String(formData.get("password") ?? "");
const usernameRaw = String(formData.get("username") ?? "");
if (isAdminRow) {
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await updateAccount({ id, password });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Changed password for admin “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
passwordChanged: true,
},
});
redirect("/settings?accountOk=updated");
}
const permissions = parsePermissionsFromForm(formData);
const result = await updateAccount({
id,
username: usernameRaw,
password: password || undefined,
permissions,
});
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Updated account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
previousUsername: existing.username,
passwordChanged: Boolean(password),
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=updated");
}
export async function deleteAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const result = await deleteAccount(id);
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.delete",
summary: `Deleted account “${existing.username}`,
details: {
targetId: existing.id,
targetUsername: existing.username,
},
});
redirect("/settings?accountOk=deleted");
}
+11 -2
View File
@@ -1,7 +1,8 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireWriteAccess } from "@/lib/auth/require-session"; import { appendAuditLog } from "@/lib/auth/audit-log";
import { requirePageWrite } from "@/lib/auth/require-session";
import { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc"; import { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc";
import { import {
buildDashboardHref, buildDashboardHref,
@@ -24,7 +25,7 @@ function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null {
} }
export async function addSystemSupply(formData: FormData) { export async function addSystemSupply(formData: FormData) {
await requireWriteAccess(); const actor = await requirePageWrite("ledger");
const tabRaw = String(formData.get("tab") ?? ""); const tabRaw = String(formData.get("tab") ?? "");
const tab: AdminDashboardTab = const tab: AdminDashboardTab =
@@ -154,5 +155,13 @@ export async function addSystemSupply(formData: FormData) {
); );
} }
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "ledger.system_supply",
summary: `Added system supply of ${coins.toString()} coins`,
details: { coins: coins.toString(), systemAccountId: SYSTEM_ACCOUNT_ID },
});
redirect(buildDashboardHref(base)); redirect(buildDashboardHref(base));
} }
+29 -4
View File
@@ -1,7 +1,8 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireWriteAccess } from "@/lib/auth/require-session"; import { appendAuditLog } from "@/lib/auth/audit-log";
import { requireAdmin } from "@/lib/auth/require-session";
import { parseStoredCoins } from "@/lib/coins-rc"; import { parseStoredCoins } from "@/lib/coins-rc";
import { createAdminSupabase } from "@/lib/supabase/admin"; import { createAdminSupabase } from "@/lib/supabase/admin";
@@ -37,7 +38,7 @@ function normalizeSettingValue(
} }
export async function updateSetting(formData: FormData) { export async function updateSetting(formData: FormData) {
await requireWriteAccess(); const actor = await requireAdmin();
const key = String(formData.get("key") ?? "").trim(); const key = String(formData.get("key") ?? "").trim();
if (!key) { if (!key) {
@@ -63,11 +64,19 @@ export async function updateSetting(formData: FormData) {
redirect("/settings?saveError=1"); redirect("/settings?saveError=1");
} }
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.update",
summary: `Updated setting “${key}`,
details: { key, value },
});
redirect("/settings"); redirect("/settings");
} }
export async function insertSetting(formData: FormData) { export async function insertSetting(formData: FormData) {
await requireWriteAccess(); const actor = await requireAdmin();
const key = String(formData.get("newKey") ?? "").trim(); const key = String(formData.get("newKey") ?? "").trim();
const value = normalizeValue(formData.get("newValue")); const value = normalizeValue(formData.get("newValue"));
@@ -91,11 +100,19 @@ export async function insertSetting(formData: FormData) {
redirect(`/settings?addError=${code}`); redirect(`/settings?addError=${code}`);
} }
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.insert",
summary: `Created setting “${key}`,
details: { key, value },
});
redirect("/settings"); redirect("/settings");
} }
export async function deleteSetting(formData: FormData) { export async function deleteSetting(formData: FormData) {
await requireWriteAccess(); const actor = await requireAdmin();
const key = String(formData.get("key") ?? "").trim(); const key = String(formData.get("key") ?? "").trim();
if (!key) { if (!key) {
@@ -112,5 +129,13 @@ export async function deleteSetting(formData: FormData) {
redirect("/settings?saveError=1"); redirect("/settings?saveError=1");
} }
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.delete",
summary: `Deleted setting “${key}`,
details: { key },
});
redirect("/settings"); redirect("/settings");
} }
+11 -2
View File
@@ -1,7 +1,8 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireWriteAccess } from "@/lib/auth/require-session"; import { appendAuditLog } from "@/lib/auth/audit-log";
import { requirePageWrite } from "@/lib/auth/require-session";
import { import {
buildDashboardHref, buildDashboardHref,
type AdminDashboardTab, type AdminDashboardTab,
@@ -18,7 +19,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null {
} }
export async function updateUserCcRc(formData: FormData) { export async function updateUserCcRc(formData: FormData) {
await requireWriteAccess(); const actor = await requirePageWrite("players");
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const tabRaw = String(formData.get("tab") ?? ""); const tabRaw = String(formData.get("tab") ?? "");
@@ -123,5 +124,13 @@ export async function updateUserCcRc(formData: FormData) {
); );
} }
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "players.update_cc_rc",
summary: `Updated CC/RC for player #${userId}`,
details: { userId, cc, rc },
});
redirect(buildDashboardHref(base)); redirect(buildDashboardHref(base));
} }
+42 -7
View File
@@ -1,9 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
getRequestUserAgent,
parseDeviceFromUserAgent,
} from "@/lib/auth/client-device";
import { getClientIp } from "@/lib/auth/client-ip"; import { getClientIp } from "@/lib/auth/client-ip";
import { verifyCredentials } from "@/lib/auth/credentials"; import { verifyCredentials } from "@/lib/auth/credentials";
import { import { createSessionToken } from "@/lib/auth/roles";
roleToSessionCookieValue,
} from "@/lib/auth/roles";
import { import {
checkLoginRateLimit, checkLoginRateLimit,
clearLoginAttempts, clearLoginAttempts,
@@ -28,6 +31,9 @@ function invalidJson() {
export async function POST(request: Request) { export async function POST(request: Request) {
const contentType = request.headers.get("content-type") ?? ""; const contentType = request.headers.get("content-type") ?? "";
const clientIp = getClientIp(request); const clientIp = getClientIp(request);
const { device, userAgent } = parseDeviceFromUserAgent(
getRequestUserAgent(request),
);
const rateLimit = checkLoginRateLimit(clientIp); const rateLimit = checkLoginRateLimit(clientIp);
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
@@ -57,8 +63,22 @@ export async function POST(request: Request) {
password = String(formData.get("password") ?? ""); password = String(formData.get("password") ?? "");
} }
const role = verifyCredentials(username, password); let account;
if (!role) { try {
account = await verifyCredentials(username, password);
} catch {
recordFailedLogin(clientIp);
if (contentType.includes("application/json")) {
return NextResponse.json(
{ error: "Admin account is not configured" },
{ status: 503 },
);
}
return NextResponse.redirect(
publicRequestUrl(request, "/login?error=1"),
);
}
if (!account) {
recordFailedLogin(clientIp); recordFailedLogin(clientIp);
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
return invalidJson(); return invalidJson();
@@ -68,17 +88,32 @@ export async function POST(request: Request) {
clearLoginAttempts(clientIp); clearLoginAttempts(clientIp);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "auth.login",
summary: `Signed in from ${clientIp} · ${device}`,
ip: clientIp,
device,
userAgent,
details: {
ip: clientIp,
device,
userAgent,
},
});
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.cookies.set( res.cookies.set(
ADMIN_SESSION_COOKIE, ADMIN_SESSION_COOKIE,
roleToSessionCookieValue(role), createSessionToken(account.username),
getSessionCookieSetOptions(request), getSessionCookieSetOptions(request),
); );
return res; return res;
} }
const res = NextResponse.redirect(publicRequestUrl(request, "/")); const res = NextResponse.redirect(publicRequestUrl(request, "/"));
applySessionCookie(res, request, role); applySessionCookie(res, request, account.username);
return res; return res;
} }
+25
View File
@@ -1,8 +1,33 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
getRequestUserAgent,
parseDeviceFromUserAgent,
} from "@/lib/auth/client-device";
import { getClientIp } from "@/lib/auth/client-ip";
import { getSessionAccount } from "@/lib/auth/require-session";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie"; import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
export async function POST(request: Request) { export async function POST(request: Request) {
const account = await getSessionAccount();
if (account) {
const clientIp = getClientIp(request);
const { device, userAgent } = parseDeviceFromUserAgent(
getRequestUserAgent(request),
);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "auth.logout",
summary: `Signed out from ${clientIp} · ${device}`,
ip: clientIp,
device,
userAgent,
details: { ip: clientIp, device, userAgent },
});
}
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.cookies.set( res.cookies.set(
ADMIN_SESSION_COOKIE, ADMIN_SESSION_COOKIE,
+7 -3
View File
@@ -1,4 +1,5 @@
import { getSessionRole } from "@/lib/auth/require-session"; import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { import {
parseMatchIdParam, parseMatchIdParam,
readMatchLogFile, readMatchLogFile,
@@ -8,8 +9,11 @@ export async function GET(
_request: Request, _request: Request,
context: { params: Promise<{ matchId: string }> }, context: { params: Promise<{ matchId: string }> },
) { ) {
const role = await getSessionRole(); const account = await getSessionAccount();
if (!role) { if (
!account ||
(!canReadPage(account, "matches") && !canReadPage(account, "analysis"))
) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
} }
+4 -3
View File
@@ -1,12 +1,13 @@
import { getSessionRole } from "@/lib/auth/require-session"; import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source"; import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
const NO_STORE = { "Cache-Control": "no-store, max-age=0" }; const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
export async function GET(request: Request) { export async function GET(request: Request) {
const role = await getSessionRole(); const account = await getSessionAccount();
if (!role) { if (!account || !canReadPage(account, "matchmaker")) {
return Response.json( return Response.json(
{ error: "Unauthorized" }, { error: "Unauthorized" },
{ status: 401, headers: NO_STORE }, { status: 401, headers: NO_STORE },
+5 -3
View File
@@ -3,7 +3,8 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AdminHeader } from "@/components/admin-header"; import { AdminHeader } from "@/components/admin-header";
import { requireSession } from "@/lib/auth/require-session"; import { logPageAccess } from "@/lib/auth/log-page-access";
import { requirePageRead } from "@/lib/auth/require-session";
import { import {
formatRcDecimalFromCoinsBigInt, formatRcDecimalFromCoinsBigInt,
formatRcLabelFromCoinsBigInt, formatRcLabelFromCoinsBigInt,
@@ -57,7 +58,8 @@ export default async function LedgerBookPage({
}: { }: {
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>; searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
}) { }) {
await requireSession(); const account = await requirePageRead("ledger");
await logPageAccess(account, "ledger-book");
const sp = await searchParams; const sp = await searchParams;
let fromStr = firstSearchParam(sp.from); let fromStr = firstSearchParam(sp.from);
@@ -147,7 +149,7 @@ export default async function LedgerBookPage({
return ( return (
<div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950"> <div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950">
<AdminHeader /> <AdminHeader username={account.username} isAdmin={account.isAdmin} />
<main className="flex-1 space-y-6 px-6 py-8"> <main className="flex-1 space-y-6 px-6 py-8">
<div className="mx-auto max-w-[1200px] space-y-2"> <div className="mx-auto max-w-[1200px] space-y-2">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"> <h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
+12 -1
View File
@@ -1,12 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { canReadPage } from "@/lib/auth/permissions";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requireSession } from "@/lib/auth/require-session"; import { requireSession } from "@/lib/auth/require-session";
import { MatchLogColoredBody } from "@/components/match-log-colored-body"; import { MatchLogColoredBody } from "@/components/match-log-colored-body";
import { import {
parseMatchIdParam, parseMatchIdParam,
readMatchLogFile, readMatchLogFile,
} from "@/lib/match-logs-server"; } from "@/lib/match-logs-server";
import { redirect } from "next/navigation";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -24,7 +27,13 @@ export default async function MatchLogPage({
}: { }: {
params: Promise<{ matchId: string }>; params: Promise<{ matchId: string }>;
}) { }) {
await requireSession(); const account = await requireSession();
if (
!canReadPage(account, "matches") &&
!canReadPage(account, "analysis")
) {
redirect("/");
}
const { matchId: raw } = await params; const { matchId: raw } = await params;
const matchId = parseMatchIdParam(raw); const matchId = parseMatchIdParam(raw);
@@ -32,6 +41,8 @@ export default async function MatchLogPage({
notFound(); notFound();
} }
await logPageAccess(account, "match-log", { matchId });
const result = await readMatchLogFile(matchId); const result = await readMatchLogFile(matchId);
return ( return (
+4 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal"; import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import { requireSession } from "@/lib/auth/require-session"; import { logPageAccess } from "@/lib/auth/log-page-access";
import { requirePageRead } from "@/lib/auth/require-session";
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source"; import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
@@ -15,7 +16,8 @@ export default async function MatchmakerLogsPage({
}: { }: {
searchParams: Promise<{ mklog?: string | string[] }>; searchParams: Promise<{ mklog?: string | string[] }>;
}) { }) {
await requireSession(); const account = await requirePageRead("matchmaker");
await logPageAccess(account, "matchmaker-logs");
const sp = await searchParams; const sp = await searchParams;
const raw = const raw =
+108 -12
View File
@@ -1,13 +1,23 @@
import { AdminDashboard } from "@/components/admin-dashboard"; import { AdminDashboard } from "@/components/admin-dashboard";
import { AdminHeader } from "@/components/admin-header"; import { AdminHeader } from "@/components/admin-header";
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay"; import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
import { isReadOnlyRole } from "@/lib/auth/roles"; import {
canReadPage,
canWritePage,
tabToPageKey,
} from "@/lib/auth/permissions";
import { requireSession } from "@/lib/auth/require-session"; import { requireSession } from "@/lib/auth/require-session";
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats"; import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
import { import {
normalizeLedgerDateRange, normalizeLedgerDateRange,
parseIsoDateOnly,
utcLast30DaysDateRange, utcLast30DaysDateRange,
} from "@/lib/ledger-utc-date-range"; } from "@/lib/ledger-utc-date-range";
import {
localDateRangeToUtcIsoBounds,
normalizeMatchesDateRange,
parseTzOffsetMinutes,
} from "@/lib/local-date-range";
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server"; import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
import { import {
serializeLedgerGlobalSummary, serializeLedgerGlobalSummary,
@@ -51,6 +61,9 @@ import {
type LedgerSortKey, type LedgerSortKey,
type LedgerSortOrder, type LedgerSortOrder,
} from "@/lib/ledger-table-view"; } from "@/lib/ledger-table-view";
import { redirect } from "next/navigation";
import { readAuditLog, type AuditLogEntry } from "@/lib/auth/audit-log";
import { logPageAccess } from "@/lib/auth/log-page-access";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -82,14 +95,26 @@ export default async function Home({
afrom?: string | string[]; afrom?: string | string[];
ato?: string | string[]; ato?: string | string[];
aplayers?: string | string[]; aplayers?: string | string[];
mfrom?: string | string[];
mto?: string | string[];
mtz?: string | string[];
}>; }>;
}) { }) {
const role = await requireSession(); const account = await requireSession();
const readOnly = isReadOnlyRole(role); const canWritePlayers = canWritePage(account, "players");
const canWriteLedger = canWritePage(account, "ledger");
const pageAccess = {
players: canReadPage(account, "players"),
matches: canReadPage(account, "matches"),
analysis: canReadPage(account, "analysis"),
matchmaker: canReadPage(account, "matchmaker"),
ledger: canReadPage(account, "ledger"),
logs: canReadPage(account, "logs"),
};
const sp = await searchParams; const sp = await searchParams;
const tabParam = firstSearchParam(sp.tab); const tabParam = firstSearchParam(sp.tab);
const tab: AdminDashboardTab = const requestedTab: AdminDashboardTab =
tabParam === "matches" tabParam === "matches"
? "matches" ? "matches"
: tabParam === "players" : tabParam === "players"
@@ -100,7 +125,20 @@ export default async function Home({
? "ledger" ? "ledger"
: tabParam === "analysis" : tabParam === "analysis"
? "analysis" ? "analysis"
: "dashboard"; : tabParam === "logs"
? "logs"
: "dashboard";
const pageKey = tabToPageKey(requestedTab);
if (pageKey && !canReadPage(account, pageKey)) {
redirect("/");
}
const tab = requestedTab;
const accessPage =
tab === "dashboard"
? "overview"
: tab;
await logPageAccess(account, accessPage);
const highlightId = firstSearchParam(sp.highlight); const highlightId = firstSearchParam(sp.highlight);
const participantRaw = firstSearchParam(sp.participant); const participantRaw = firstSearchParam(sp.participant);
const editRaw = firstSearchParam(sp.edit); const editRaw = firstSearchParam(sp.edit);
@@ -129,6 +167,10 @@ export default async function Home({
let ledgerTo = utcLast30DaysDateRange().to; let ledgerTo = utcLast30DaysDateRange().to;
let analysisFrom = utcLast30DaysDateRange().from; let analysisFrom = utcLast30DaysDateRange().from;
let analysisTo = utcLast30DaysDateRange().to; let analysisTo = utcLast30DaysDateRange().to;
let matchesFrom = utcLast30DaysDateRange().from;
let matchesTo = utcLast30DaysDateRange().to;
let matchesTzOffsetMinutes: number | null = null;
let matchesRangeExplicit = false;
let analysisPlayerIds: number[] = []; let analysisPlayerIds: number[] = [];
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult(); let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
let pingAnalytics: PingAnalyticsResult = { let pingAnalytics: PingAnalyticsResult = {
@@ -138,7 +180,7 @@ export default async function Home({
totalPlayers: 0, totalPlayers: 0,
avgPing: null, avgPing: null,
p95Ping: null, p95Ping: null,
badThresholdMs: 120, badThresholdMs: 200,
badReports: 0, badReports: 0,
badRatePercent: null, badRatePercent: null,
}, },
@@ -158,6 +200,8 @@ export default async function Home({
let matchmakerSource: MatchmakerLogSource = "processed"; let matchmakerSource: MatchmakerLogSource = "processed";
let matchmakerContent = ""; let matchmakerContent = "";
let matchmakerError: string | null = null; let matchmakerError: string | null = null;
let auditEntries: AuditLogEntry[] = [];
let auditError: string | null = null;
if (tab === "matchmaker") { if (tab === "matchmaker") {
const mkRaw = firstSearchParam(sp.mklog); const mkRaw = firstSearchParam(sp.mklog);
matchmakerSource = parseMatchmakerLogParam(mkRaw); matchmakerSource = parseMatchmakerLogParam(mkRaw);
@@ -169,6 +213,15 @@ export default async function Home({
} }
} }
if (tab === "logs") {
try {
auditEntries = await readAuditLog(1000);
} catch (err) {
auditError =
err instanceof Error ? err.message : "Failed to read system logs.";
}
}
if (!supabase) { if (!supabase) {
configError = configError =
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local."; "Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
@@ -185,11 +238,45 @@ export default async function Home({
users = (usersRes.data ?? []) as DbUser[]; users = (usersRes.data ?? []) as DbUser[];
} }
const matchesRes = await supabase const matchesTzRaw = firstSearchParam(sp.mtz);
matchesTzOffsetMinutes = parseTzOffsetMinutes(matchesTzRaw);
const mfromRaw = firstSearchParam(sp.mfrom);
const mtoRaw = firstSearchParam(sp.mto);
matchesRangeExplicit = Boolean(
mfromRaw &&
mtoRaw &&
parseIsoDateOnly(mfromRaw.trim()) &&
parseIsoDateOnly(mtoRaw.trim()),
);
const matchesRange = normalizeMatchesDateRange(
mfromRaw,
mtoRaw,
matchesTzOffsetMinutes,
);
matchesFrom = matchesRange.from;
matchesTo = matchesRange.to;
let matchesQuery = supabase
.from("matches") .from("matches")
.select("*") .select("*")
.order("id", { ascending: false }) .order("id", { ascending: false });
.limit(500);
if (tab === "matches") {
const tz = matchesTzOffsetMinutes ?? 0;
const { rangeStartIso, rangeEndIso } = localDateRangeToUtcIsoBounds(
matchesFrom,
matchesTo,
tz,
);
matchesQuery = matchesQuery
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.limit(10000);
} else {
matchesQuery = matchesQuery.limit(500);
}
const matchesRes = await matchesQuery;
if (matchesRes.error) { if (matchesRes.error) {
matchesError = matchesRes.error.message; matchesError = matchesRes.error.message;
@@ -309,13 +396,13 @@ export default async function Home({
} }
let editUser: DbUser | null = null; let editUser: DbUser | null = null;
if (!readOnly && Number.isInteger(editIdNum) && editIdNum >= 1) { if (canWritePlayers && Number.isInteger(editIdNum) && editIdNum >= 1) {
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null; editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
} }
return ( return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950"> <div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader readOnly={readOnly} /> <AdminHeader username={account.username} isAdmin={account.isAdmin} />
{configError ? ( {configError ? (
<div className="px-6 pt-8"> <div className="px-6 pt-8">
<div <div
@@ -352,13 +439,22 @@ export default async function Home({
ledgerFrom={ledgerFrom} ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo} ledgerTo={ledgerTo}
supplyError={supplyError} supplyError={supplyError}
matchesFrom={matchesFrom}
matchesTo={matchesTo}
matchesTzOffsetMinutes={matchesTzOffsetMinutes}
matchesRangeExplicit={matchesRangeExplicit}
analysisFrom={analysisFrom} analysisFrom={analysisFrom}
analysisTo={analysisTo} analysisTo={analysisTo}
analysisPlayerIds={analysisPlayerIds} analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis} matchAnalysis={matchAnalysis}
pingAnalytics={pingAnalytics} pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics} geolocationAnalytics={geolocationAnalytics}
readOnly={readOnly} auditEntries={auditEntries}
auditError={auditError}
pageAccess={pageAccess}
canWritePlayers={canWritePlayers}
canWriteLedger={canWriteLedger}
isAdmin={account.isAdmin}
/> />
{editUser ? ( {editUser ? (
<EditUserCcRcOverlay <EditUserCcRcOverlay
+32 -9
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { requireSession } from "@/lib/auth/require-session"; import { requireAdmin } from "@/lib/auth/require-session";
import { isReadOnlyRole } from "@/lib/auth/roles"; import { listSessionAccounts } from "@/lib/auth/accounts-store";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { AdminHeader } from "@/components/admin-header"; import { AdminHeader } from "@/components/admin-header";
import { AdminSettingsEditor } from "@/components/admin-settings-editor"; import { AdminSettingsEditor } from "@/components/admin-settings-editor";
import { AdminAccountsEditor } from "@/components/admin-accounts-editor";
import { createAdminSupabase } from "@/lib/supabase/admin"; import { createAdminSupabase } from "@/lib/supabase/admin";
import type { DbSetting } from "@/types/database"; import type { DbSetting } from "@/types/database";
@@ -28,14 +30,20 @@ export default async function SettingsPage({
searchParams: Promise<{ searchParams: Promise<{
saveError?: string | string[]; saveError?: string | string[];
addError?: string | string[]; addError?: string | string[];
accountError?: string | string[];
accountOk?: string | string[];
}>; }>;
}) { }) {
const role = await requireSession(); const account = await requireAdmin();
const readOnly = isReadOnlyRole(role); await logPageAccess(account, "settings");
const sp = await searchParams; const sp = await searchParams;
const saveError = firstSearchParam(sp.saveError) === "1"; const saveError = firstSearchParam(sp.saveError) === "1";
const addError = firstSearchParam(sp.addError); const addError = firstSearchParam(sp.addError);
const accountError = firstSearchParam(sp.accountError);
const accountOk = firstSearchParam(sp.accountOk);
const accounts = await listSessionAccounts();
const supabase = createAdminSupabase(); const supabase = createAdminSupabase();
let rows: DbSetting[] = []; let rows: DbSetting[] = [];
@@ -60,12 +68,27 @@ export default async function SettingsPage({
return ( return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950"> <div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader readOnly={readOnly} /> <AdminHeader username={account.username} isAdmin={account.isAdmin} />
<main className="flex-1 px-6 py-8"> <main className="flex-1 space-y-12 px-6 py-8">
<div className="mx-auto mb-8 max-w-[900px]"> <div className="mx-auto max-w-[900px]">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"> <h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Global settings Settings
</h1> </h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Global game settings and panel account access. Admin only.
</p>
</div>
<AdminAccountsEditor
accounts={accounts}
accountError={accountError}
accountOk={accountOk}
/>
<div className="mx-auto max-w-[900px] border-t border-zinc-200 pt-10 dark:border-zinc-800">
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Global settings
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400"> <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Key/value rows in the{" "} Key/value rows in the{" "}
<span className="font-mono text-zinc-800 dark:text-zinc-200"> <span className="font-mono text-zinc-800 dark:text-zinc-200">
@@ -93,7 +116,7 @@ export default async function SettingsPage({
rows={rows} rows={rows}
saveError={saveError} saveError={saveError}
addError={addError} addError={addError}
readOnly={readOnly} readOnly={false}
/> />
)} )}
</main> </main>
+434
View File
@@ -0,0 +1,434 @@
"use client";
import { useState } from "react";
import {
createAdminAccount,
deleteAdminAccount,
updateAdminAccount,
} from "@/app/actions/account-actions";
import {
PAGE_KEYS,
type PageKey,
type PagePermissions,
type SessionAccount,
} from "@/lib/auth/permissions";
const PAGE_LABELS: Record<PageKey, string> = {
players: "Players",
matches: "Matches",
analysis: "Analysis",
matchmaker: "Matchmaker",
ledger: "Ledger",
logs: "System logs",
};
type Props = {
accounts: SessionAccount[];
accountError: string | null;
accountOk: string | null;
};
function errorMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "missingUsername":
return "Username is required.";
case "missingPassword":
return "Password is required.";
case "duplicate":
return "That username is already taken.";
case "notFound":
return "Account not found.";
case "cannotDeleteAdmin":
return "The admin account cannot be deleted.";
default:
return "Could not update accounts. Try again.";
}
}
function okMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "created":
return "Account created.";
case "updated":
return "Account updated.";
case "deleted":
return "Account deleted.";
default:
return "Done.";
}
}
function PermissionCheckboxes({
idPrefix,
permissions,
onChange,
}: {
idPrefix: string;
permissions: PagePermissions;
onChange: (next: PagePermissions) => void;
}) {
function setPerm(page: PageKey, field: "read" | "write", value: boolean) {
const next = { ...permissions, [page]: { ...permissions[page] } };
if (page === "logs") {
next[page] = { read: field === "read" ? value : next[page].read, write: false };
onChange(next);
return;
}
if (field === "write") {
next[page] = { write: value, read: value ? true : next[page].read };
} else {
next[page] = {
read: value,
write: value ? next[page].write : false,
};
}
onChange(next);
}
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[320px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
<th className="py-2 pr-3 font-medium">Page</th>
<th className="py-2 px-2 font-medium">Read</th>
<th className="py-2 px-2 font-medium">Write</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-zinc-100 dark:border-zinc-800">
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
Overview
</td>
<td className="py-2 px-2 text-zinc-500" colSpan={2}>
Always enabled
</td>
</tr>
{PAGE_KEYS.map((page) => (
<tr
key={page}
className="border-b border-zinc-100 dark:border-zinc-800"
>
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
{PAGE_LABELS[page]}
</td>
<td className="py-2 px-2">
<input
type="checkbox"
id={`${idPrefix}_${page}_read`}
name={`perm_${page}_read`}
value="1"
checked={permissions[page].read}
onChange={(e) => setPerm(page, "read", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
</td>
<td className="py-2 px-2">
{page === "logs" ? (
<span className="text-xs text-zinc-500 dark:text-zinc-400">
</span>
) : (
<input
type="checkbox"
id={`${idPrefix}_${page}_write`}
name={`perm_${page}_write`}
value="1"
checked={permissions[page].write}
onChange={(e) => setPerm(page, "write", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function permissionSummary(account: SessionAccount): string {
if (account.isAdmin) return "Full access";
const parts: string[] = ["Overview"];
for (const page of PAGE_KEYS) {
const p = account.permissions[page];
if (p.write) parts.push(`${PAGE_LABELS[page]} (rw)`);
else if (p.read) parts.push(`${PAGE_LABELS[page]} (r)`);
}
return parts.join(", ");
}
function AccountEditForm({
account,
onClose,
}: {
account: SessionAccount;
onClose: () => void;
}) {
const [username, setUsername] = useState(account.username);
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState(account.permissions);
return (
<form
action={updateAdminAccount}
className="mt-3 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-950/60"
>
<input type="hidden" name="id" value={account.id} />
<input
type="hidden"
name="isAdmin"
value={account.isAdmin ? "1" : "0"}
/>
{account.isAdmin ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Admin account username is fixed; only the password can be changed.
</p>
) : (
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-username-${account.id}`}
>
Username
</label>
<input
id={`edit-username-${account.id}`}
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
)}
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-password-${account.id}`}
>
{account.isAdmin ? "New password" : "New password (optional)"}
</label>
<input
id={`edit-password-${account.id}`}
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={account.isAdmin}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
{!account.isAdmin ? (
<PermissionCheckboxes
idPrefix={`edit_${account.id}`}
permissions={permissions}
onChange={setPermissions}
/>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
</div>
</form>
);
}
function AddAccountForm() {
const [permissions, setPermissions] = useState<PagePermissions>({
players: { read: false, write: false },
matches: { read: false, write: false },
analysis: { read: false, write: false },
matchmaker: { read: false, write: false },
ledger: { read: false, write: false },
logs: { read: false, write: false },
});
return (
<form
action={createAdminAccount}
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Add account
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-username"
>
Username
</label>
<input
id="new-account-username"
name="username"
required
autoComplete="off"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-password"
>
Password
</label>
<input
id="new-account-password"
name="password"
type="password"
required
autoComplete="new-password"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
</div>
<PermissionCheckboxes
idPrefix="new"
permissions={permissions}
onChange={setPermissions}
/>
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Create account
</button>
</form>
);
}
export function AdminAccountsEditor({
accounts,
accountError,
accountOk,
}: Props) {
const [editingId, setEditingId] = useState<string | null>(null);
const err = errorMessage(accountError);
const ok = okMessage(accountOk);
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Accounts
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Manage panel logins and per-page read/write access. Only the admin
account can manage other accounts.
</p>
</div>
{err ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{err}
</div>
) : null}
{ok ? (
<div
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
role="status"
>
{ok}
</div>
) : null}
<section className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing accounts
</h3>
<ul className="space-y-3">
{accounts.map((account) => (
<li
key={account.id}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-50">
{account.username}
{account.isAdmin ? (
<span className="ml-2 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
Admin
</span>
) : null}
</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{permissionSummary(account)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() =>
setEditingId((id) =>
id === account.id ? null : account.id,
)
}
className="rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{editingId === account.id ? "Close" : "Edit"}
</button>
{!account.isAdmin ? (
<form action={deleteAdminAccount}>
<input type="hidden" name="id" value={account.id} />
<button
type="submit"
className="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (
!window.confirm(
`Delete account “${account.username}”?`,
)
) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
) : null}
</div>
</div>
{editingId === account.id ? (
<AccountEditForm
account={account}
onClose={() => setEditingId(null)}
/>
) : null}
</li>
))}
</ul>
</section>
<AddAccountForm />
</div>
);
}
+272 -85
View File
@@ -4,7 +4,10 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id"; import { ClickableUserId } from "@/components/clickable-user-id";
import { AdminLedger } from "@/components/admin-ledger"; import { AdminLedger } from "@/components/admin-ledger";
import { AdminSystemLogs } from "@/components/admin-system-logs";
import { LocalTimestamp } from "@/components/local-timestamp";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card"; import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { AuditLogEntry } from "@/lib/auth/audit-log";
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats"; import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc"; import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { AdminMatchAnalysis } from "@/components/admin-match-analysis"; import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
@@ -16,6 +19,9 @@ import {
buildDashboardHref, buildDashboardHref,
type AdminDashboardTab, type AdminDashboardTab,
} from "@/lib/dashboard-search-url"; } from "@/lib/dashboard-search-url";
import {
localLast30DaysDateRange,
} from "@/lib/local-date-range";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type { import type {
AdminMatchRow, AdminMatchRow,
@@ -221,13 +227,31 @@ type Props = {
matchmakerError: string | null; matchmakerError: string | null;
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */ /** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
supplyError: boolean; supplyError: boolean;
/** Matches tab: local date-only bounds + browser tz offset minutes. */
matchesFrom: string;
matchesTo: string;
matchesTzOffsetMinutes: number | null;
/** True when `mfrom`/`mto` were present and valid in the URL. */
matchesRangeExplicit: boolean;
analysisFrom: string; analysisFrom: string;
analysisTo: string; analysisTo: string;
analysisPlayerIds: number[]; analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult; matchAnalysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult; pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult; geolocationAnalytics: GeolocationAnalyticsResult;
readOnly: boolean; auditEntries: AuditLogEntry[];
auditError: string | null;
pageAccess: {
players: boolean;
matches: boolean;
analysis: boolean;
matchmaker: boolean;
ledger: boolean;
logs: boolean;
};
canWritePlayers: boolean;
canWriteLedger: boolean;
isAdmin: boolean;
}; };
function StatCard({ function StatCard({
@@ -278,19 +302,55 @@ export function AdminDashboard({
matchmakerContent, matchmakerContent,
matchmakerError, matchmakerError,
supplyError, supplyError,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
matchesRangeExplicit,
analysisFrom, analysisFrom,
analysisTo, analysisTo,
analysisPlayerIds, analysisPlayerIds,
matchAnalysis, matchAnalysis,
pingAnalytics, pingAnalytics,
geolocationAnalytics, geolocationAnalytics,
readOnly, auditEntries,
auditError,
pageAccess,
canWritePlayers,
canWriteLedger,
isAdmin,
}: Props) { }: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true); const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState(""); const [playersSearch, setPlayersSearch] = useState("");
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins"); const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc"); const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
/** Once we know the browser offset, reload with mtz so day bounds are local. */
useEffect(() => {
if (tab !== "matches") return;
if (matchesTzOffsetMinutes != null) return;
const tz = new Date().getTimezoneOffset();
const range = matchesRangeExplicit
? { from: matchesFrom, to: matchesTo }
: localLast30DaysDateRange(tz);
const href = buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesFrom: range.from,
matchesTo: range.to,
matchesTzOffsetMinutes: tz,
});
window.location.replace(href);
}, [
tab,
matchesTzOffsetMinutes,
matchesRangeExplicit,
highlightId,
participantRaw,
matchesFrom,
matchesTo,
]);
const lbRaw = statsBundle?.leaderboard; const lbRaw = statsBundle?.leaderboard;
const sortedLeaderboard = useMemo(() => { const sortedLeaderboard = useMemo(() => {
const rows = lbRaw ?? []; const rows = lbRaw ?? [];
@@ -407,84 +467,112 @@ export function AdminDashboard({
> >
Overview Overview
</Link> </Link>
<Link {pageAccess.players ? (
href={buildDashboardHref({ <Link
tab: "players", href={buildDashboardHref({
highlightId, tab: "players",
participantRaw, highlightId,
})} participantRaw,
className={tabClass(tab === "players")} })}
scroll={false} className={tabClass(tab === "players")}
> scroll={false}
Players ( >
{totalUsersLabel.toLocaleString("en-US")} Players (
{!statsBundle?.stats {totalUsersLabel.toLocaleString("en-US")}
? ` · table ${users.length.toLocaleString("en-US")}` {!statsBundle?.stats
: null} ? ` · table ${users.length.toLocaleString("en-US")}`
) : null}
</Link> )
<Link </Link>
href={buildDashboardHref({ ) : null}
tab: "matches", {pageAccess.matches ? (
highlightId, <Link
participantRaw, href={buildDashboardHref({
})} tab: "matches",
className={tabClass(tab === "matches")} highlightId,
scroll={false} participantRaw,
> matchesFrom,
Matches ( matchesTo,
{totalMatchesLabel.toLocaleString("en-US")} matchesTzOffsetMinutes,
{!statsBundle?.stats })}
? ` · table ${matches.length.toLocaleString("en-US")}` className={tabClass(tab === "matches")}
: null} scroll={false}
) >
</Link> Matches (
<Link {totalMatchesLabel.toLocaleString("en-US")}
href={buildDashboardHref({ {!statsBundle?.stats
tab: "analysis", ? ` · table ${matches.length.toLocaleString("en-US")}`
highlightId, : null}
participantRaw, )
analysisFrom, </Link>
analysisTo, ) : null}
analysisPlayers: {pageAccess.analysis ? (
analysisPlayerIds.length > 0 <Link
? analysisPlayerIds.join(",") href={buildDashboardHref({
: null, tab: "analysis",
})} highlightId,
className={tabClass(tab === "analysis")} participantRaw,
scroll={false} analysisFrom,
> analysisTo,
Analysis analysisPlayers:
</Link> analysisPlayerIds.length > 0
<Link ? analysisPlayerIds.join(",")
href={buildDashboardHref({ : null,
tab: "matchmaker", })}
highlightId, className={tabClass(tab === "analysis")}
participantRaw, scroll={false}
})} >
className={tabClass(tab === "matchmaker")} Analysis
scroll={false} </Link>
> ) : null}
Matchmaker {pageAccess.matchmaker ? (
</Link> <Link
<Link href={buildDashboardHref({
href={buildDashboardHref({ tab: "matchmaker",
tab: "ledger", highlightId,
highlightId, participantRaw,
participantRaw, })}
})} className={tabClass(tab === "matchmaker")}
className={tabClass(tab === "ledger")} scroll={false}
scroll={false} >
> Matchmaker
Ledger </Link>
</Link> ) : null}
<Link {pageAccess.ledger ? (
href="/settings" <Link
className={tabClass(false)} href={buildDashboardHref({
scroll={false} tab: "ledger",
> highlightId,
Settings participantRaw,
</Link> })}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</Link>
) : null}
{pageAccess.logs ? (
<Link
href={buildDashboardHref({
tab: "logs",
highlightId,
participantRaw,
})}
className={tabClass(tab === "logs")}
scroll={false}
>
System logs
</Link>
) : null}
{isAdmin ? (
<Link
href="/settings"
className={tabClass(false)}
scroll={false}
>
Settings
</Link>
) : null}
</div> </div>
<div className="mx-auto w-full max-w-[1400px]"> <div className="mx-auto w-full max-w-[1400px]">
@@ -740,7 +828,7 @@ export function AdminDashboard({
</td> </td>
<td className="px-4 py-2 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{!readOnly ? ( {canWritePlayers ? (
<Link <Link
href={editHref(u)} href={editHref(u)}
scroll={false} scroll={false}
@@ -839,10 +927,102 @@ export function AdminDashboard({
ledgerFrom={ledgerFrom} ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo} ledgerTo={ledgerTo}
supplyError={supplyError} supplyError={supplyError}
readOnly={readOnly} readOnly={!canWriteLedger}
/> />
) : tab === "logs" ? (
<AdminSystemLogs entries={auditEntries} error={auditError} />
) : ( ) : (
<section className="space-y-3"> <section className="space-y-3">
<form
method="get"
action="/"
className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => {
const form = e.currentTarget;
const mtzInput = form.elements.namedItem(
"mtz",
) as HTMLInputElement | null;
if (mtzInput) {
mtzInput.value = String(new Date().getTimezoneOffset());
}
}}
>
<input type="hidden" name="tab" value="matches" />
{highlightId ? (
<input type="hidden" name="highlight" value={highlightId} />
) : null}
{participantRaw ? (
<input type="hidden" name="participant" value={participantRaw} />
) : null}
<input
type="hidden"
name="mtz"
defaultValue={
matchesTzOffsetMinutes != null
? String(matchesTzOffsetMinutes)
: ""
}
/>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="matches-mfrom"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
From (local)
</label>
<input
id="matches-mfrom"
name="mfrom"
type="date"
defaultValue={matchesFrom}
className="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"
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="matches-mto"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
To (local)
</label>
<input
id="matches-mto"
name="mto"
type="date"
defaultValue={matchesTo}
className="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"
/>
</div>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesTzOffsetMinutes,
})}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset range
</Link>
</form>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {matches.length.toLocaleString("en-US")}{" "}
{matches.length === 1 ? "match" : "matches"} created{" "}
<span className="font-mono">
{matchesFrom}{matchesTo}
</span>{" "}
in your local timezone
{matches.length >= 10000 ? " · capped at 10,000 rows" : null}
</p>
{participantId != null ? ( {participantId != null ? (
<div <div
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100" className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
@@ -864,7 +1044,14 @@ export function AdminDashboard({
{filteredMatches.length} of {matches.length}) {filteredMatches.length} of {matches.length})
</span> </span>
<Link <Link
href="/?tab=matches" href={buildDashboardHref({
tab: "matches",
highlightId: null,
participantRaw: null,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
})}
className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800" className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800"
scroll={false} scroll={false}
> >
@@ -900,7 +1087,7 @@ export function AdminDashboard({
{visibleMatches.length === 0 ? ( {visibleMatches.length === 0 ? (
<div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
{filteredMatches.length === 0 {filteredMatches.length === 0
? "No matches yet." ? "No matches in this date range."
: hideNoWinner : hideNoWinner
? "No matches left after hiding no-winner matches." ? "No matches left after hiding no-winner matches."
: "No matches for this filter."} : "No matches for this filter."}
@@ -918,7 +1105,7 @@ export function AdminDashboard({
key={m.id} key={m.id}
matchId={m.id} matchId={m.id}
statusLabel={statusLabel(m.status)} statusLabel={statusLabel(m.status)}
createdAtLabel={formatTs(m.created_at)} createdAtLabel={<LocalTimestamp value={m.created_at} />}
entryFeeCoins={m.entryFeePerPlayerCoins} entryFeeCoins={m.entryFeePerPlayerCoins}
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins} entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
prizeCcLabel={formatPrizeCcChip(m.prize_cc)} prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
+10 -5
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
type Props = { type Props = {
readOnly?: boolean; username?: string;
isAdmin?: boolean;
}; };
export function AdminHeader({ readOnly = false }: Props) { export function AdminHeader({ username, isAdmin = false }: Props) {
async function logout() { async function logout() {
await fetch("/api/auth/logout", { method: "POST" }); await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/login"; window.location.href = "/login";
@@ -16,9 +17,13 @@ export function AdminHeader({ readOnly = false }: Props) {
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"> <h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Kick Kings Admin Dashboard Kick Kings Admin Dashboard
</h1> </h1>
{readOnly ? ( {username ? (
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300"> <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
Read-only access Signed in as{" "}
<span className="font-medium text-zinc-700 dark:text-zinc-300">
{username}
</span>
{isAdmin ? " · admin" : null}
</p> </p>
) : null} ) : null}
</div> </div>
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useMemo, useState } from "react";
import { LocalTimestamp } from "@/components/local-timestamp";
import type { AuditLogEntry } from "@/lib/auth/audit-log";
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
type Props = {
entries: AuditLogEntry[];
error: string | null;
};
const ACTION_LABELS: Record<string, string> = {
access: "Page access",
"auth.login": "Sign in",
"auth.logout": "Sign out",
"players.update_cc_rc": "Update player CC/RC",
"ledger.system_supply": "System supply",
"settings.update": "Update setting",
"settings.insert": "Create setting",
"settings.delete": "Delete setting",
"accounts.create": "Create account",
"accounts.update": "Update account",
"accounts.delete": "Delete account",
};
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action;
}
function isAuthAction(action: string): boolean {
return action === "auth.login" || action === "auth.logout";
}
function isAccessAction(action: string): boolean {
return action === "access";
}
/** Prefer top-level fields; fall back to details for older/partial entries. */
function entryIp(entry: AuditLogEntry): string | null {
if (entry.ip) return entry.ip;
const d = entry.details?.ip;
return typeof d === "string" ? d : null;
}
function entryDevice(entry: AuditLogEntry): string | null {
if (entry.device) return entry.device;
const d = entry.details?.device;
return typeof d === "string" ? d : null;
}
function entryUserAgent(entry: AuditLogEntry): string | null {
if (entry.userAgent) return entry.userAgent;
const d = entry.details?.userAgent;
return typeof d === "string" ? d : null;
}
function entryPage(entry: AuditLogEntry): string | null {
const d = entry.details?.page;
return typeof d === "string" ? d : null;
}
function matchesQuery(entry: AuditLogEntry, q: string): boolean {
if (!q) return true;
const hay = [
entry.username,
entry.action,
actionLabel(entry.action),
entry.summary,
entryIp(entry) ?? "",
entryDevice(entry) ?? "",
entryUserAgent(entry) ?? "",
entryPage(entry) ?? "",
entryPage(entry) ? pageAccessLabel(entryPage(entry)!) : "",
entry.details ? JSON.stringify(entry.details) : "",
]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
export function AdminSystemLogs({ entries, error }: Props) {
const [query, setQuery] = useState("");
const [actionFilter, setActionFilter] = useState("");
const [accountFilter, setAccountFilter] = useState("");
const actionOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) set.add(e.action);
for (const known of Object.keys(ACTION_LABELS)) set.add(known);
return [...set].sort((a, b) =>
actionLabel(a).localeCompare(actionLabel(b)),
);
}, [entries]);
const accountOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) {
if (e.username) set.add(e.username);
}
return [...set].sort((a, b) => a.localeCompare(b));
}, [entries]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return entries.filter((entry) => {
if (actionFilter && entry.action !== actionFilter) return false;
if (accountFilter && entry.username !== accountFilter) return false;
return matchesQuery(entry, q);
});
}, [entries, query, actionFilter, accountFilter]);
const filtersActive = Boolean(query.trim() || actionFilter || accountFilter);
return (
<section className="mx-auto w-full max-w-[1400px] space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
System logs
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Panel actions, page access, and who performed them including login
IP and device. Newest first (up to 1,000 entries).
</p>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-[200px] flex-1">
<label
htmlFor="logs-search"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Search
</label>
<input
id="logs-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search summary, IP, device…"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
<div className="w-full sm:w-52">
<label
htmlFor="logs-action"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Action
</label>
<select
id="logs-action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
>
<option value="">All actions</option>
{actionOptions.map((action) => (
<option key={action} value={action}>
{actionLabel(action)}
</option>
))}
</select>
</div>
<div className="w-full sm:w-44">
<label
htmlFor="logs-account"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Account
</label>
<select
id="logs-account"
value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
>
<option value="">All accounts</option>
{accountOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
{filtersActive ? (
<button
type="button"
onClick={() => {
setQuery("");
setActionFilter("");
setAccountFilter("");
}}
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Clear
</button>
) : null}
</div>
{error ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{error}
</div>
) : null}
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filtered.length.toLocaleString("en-US")} of{" "}
{entries.length.toLocaleString("en-US")}
{filtersActive ? " (filtered)" : ""}
</p>
{entries.length === 0 && !error ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No actions logged yet.
</p>
) : filtered.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No log entries match these filters.
</p>
) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="w-full min-w-[900px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400">
<th className="px-4 py-3 font-medium">When</th>
<th className="px-4 py-3 font-medium">Who</th>
<th className="px-4 py-3 font-medium">Action</th>
<th className="px-4 py-3 font-medium">Details</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Device</th>
</tr>
</thead>
<tbody>
{filtered.map((entry) => {
const ip = entryIp(entry);
const device = entryDevice(entry);
const userAgent = entryUserAgent(entry);
const page = entryPage(entry);
const showClientMeta =
isAuthAction(entry.action) ||
isAccessAction(entry.action) ||
Boolean(ip || device);
return (
<tr
key={entry.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-zinc-700 dark:text-zinc-300">
<LocalTimestamp value={entry.at} />
</td>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-50">
{entry.username}
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
<span className="font-medium">
{actionLabel(entry.action)}
</span>
<span className="mt-0.5 block font-mono text-xs text-zinc-500 dark:text-zinc-400">
{entry.action}
{page ? ` · ${page}` : ""}
</span>
</td>
<td className="max-w-md px-4 py-3 text-zinc-700 dark:text-zinc-300">
<p>{entry.summary}</p>
{entry.details &&
Object.keys(entry.details).length > 0 &&
!isAuthAction(entry.action) &&
!isAccessAction(entry.action) ? (
<pre className="mt-1 max-h-24 overflow-auto rounded bg-zinc-50 px-2 py-1 font-mono text-[11px] text-zinc-600 dark:bg-zinc-950 dark:text-zinc-400">
{JSON.stringify(entry.details, null, 0)}
</pre>
) : null}
{(isAuthAction(entry.action) ||
isAccessAction(entry.action)) &&
userAgent ? (
<p
className="mt-1 break-all font-mono text-[11px] text-zinc-500 dark:text-zinc-400"
title={userAgent}
>
{userAgent}
</p>
) : null}
</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{showClientMeta ? (ip ?? "—") : "—"}
</td>
<td className="max-w-[220px] px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{showClientMeta ? (device ?? "—") : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { formatLocalTimestamp } from "@/lib/local-date-range";
type Props = {
value: string | null;
className?: string;
};
/**
* Renders timestamps in the browser's local timezone.
* Avoids SSR/client hydration mismatch by filling in after mount.
*/
export function LocalTimestamp({ value, className }: Props) {
const [label, setLabel] = useState("—");
useEffect(() => {
setLabel(formatLocalTimestamp(value));
}, [value]);
if (!value) {
return <span className={className}></span>;
}
return (
<time dateTime={value} className={className} suppressHydrationWarning>
{label}
</time>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react";
import { ClickableUserId } from "@/components/clickable-user-id"; import { ClickableUserId } from "@/components/clickable-user-id";
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc"; import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
@@ -49,7 +50,7 @@ type PlayerSide = {
type Props = { type Props = {
matchId: number; matchId: number;
statusLabel: string; statusLabel: string;
createdAtLabel: string; createdAtLabel: ReactNode;
/** Ledger `entry_fee` / player as coin string; null if unknown. */ /** Ledger `entry_fee` / player as coin string; null if unknown. */
entryFeeCoins: string | null; entryFeeCoins: string | null;
/** Ledger `entry_hold` / player as coin string; null if unknown. */ /** Ledger `entry_hold` / player as coin string; null if unknown. */
+319
View File
@@ -0,0 +1,319 @@
import {
randomBytes,
randomUUID,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import {
fullPagePermissions,
normalizePagePermissions,
type PagePermissions,
type SessionAccount,
} from "@/lib/auth/permissions";
const SCRYPT_OPTS = {
N: 16384,
r: 8,
p: 1,
maxmem: 64 * 1024 * 1024,
} as const;
const HASH_KEYLEN = 64;
export type AdminAccountRecord = {
id: string;
username: string;
passwordHash: string;
isAdmin: boolean;
permissions: PagePermissions;
};
type AccountsFile = {
version: 1;
accounts: AdminAccountRecord[];
};
function accountsFilePath(): string {
const override = process.env.ADMIN_ACCOUNTS_PATH?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), "data", "admin-accounts.json");
}
export function getBootstrapAdminUsername(): string {
return process.env.ADMIN_USERNAME?.trim() || "admin";
}
export function getBootstrapAdminPassword(): string | null {
const password = process.env.ADMIN_PASSWORD?.trim();
return password || null;
}
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, HASH_KEYLEN, SCRYPT_OPTS);
return `scrypt:${salt.toString("base64")}:${hash.toString("base64")}`;
}
export function verifyPassword(
password: string,
stored: string,
): boolean {
const parts = stored.split(":");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(parts[1]!, "base64");
expected = Buffer.from(parts[2]!, "base64");
} catch {
return false;
}
if (salt.length === 0 || expected.length === 0) return false;
const actual = scryptSync(password, salt, expected.length, SCRYPT_OPTS);
if (actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
function toSessionAccount(record: AdminAccountRecord): SessionAccount {
return {
id: record.id,
username: record.username,
isAdmin: record.isAdmin,
permissions: record.isAdmin
? fullPagePermissions()
: normalizePagePermissions(record.permissions),
};
}
function normalizeRecord(raw: unknown): AdminAccountRecord | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
if (typeof o.id !== "string" || !o.id) return null;
if (typeof o.username !== "string" || !o.username.trim()) return null;
if (typeof o.passwordHash !== "string" || !o.passwordHash) return null;
const isAdmin = Boolean(o.isAdmin);
const permissions = isAdmin
? fullPagePermissions()
: normalizePagePermissions(
o.permissions as Partial<
Record<string, { read?: boolean; write?: boolean }>
>,
);
return {
id: o.id,
username: o.username.trim(),
passwordHash: o.passwordHash,
isAdmin,
permissions,
};
}
async function readRawFile(): Promise<AccountsFile | null> {
const filePath = accountsFilePath();
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return null;
throw err;
}
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const accountsRaw = (parsed as { accounts?: unknown }).accounts;
if (!Array.isArray(accountsRaw)) return { version: 1, accounts: [] };
const accounts: AdminAccountRecord[] = [];
for (const item of accountsRaw) {
const rec = normalizeRecord(item);
if (rec) accounts.push(rec);
}
return { version: 1, accounts };
} catch {
return { version: 1, accounts: [] };
}
}
async function writeAccountsFile(accounts: AdminAccountRecord[]): Promise<void> {
const filePath = accountsFilePath();
await mkdir(path.dirname(filePath), { recursive: true });
const payload: AccountsFile = { version: 1, accounts };
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
await rename(tmp, filePath);
}
let writeChain: Promise<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
const next = writeChain.then(fn, fn);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
async function ensureAdminBootstrapped(
accounts: AdminAccountRecord[],
): Promise<AdminAccountRecord[]> {
if (accounts.some((a) => a.isAdmin)) return accounts;
const password = getBootstrapAdminPassword();
if (!password) {
throw new Error(
"ADMIN_PASSWORD is required to bootstrap the admin account.",
);
}
const username = getBootstrapAdminUsername();
const admin: AdminAccountRecord = {
id: randomUUID(),
username,
passwordHash: hashPassword(password),
isAdmin: true,
permissions: fullPagePermissions(),
};
// Drop any non-admin that collides with the bootstrap username.
const rest = accounts.filter(
(a) => a.username.toLowerCase() !== username.toLowerCase(),
);
const next = [admin, ...rest];
await writeAccountsFile(next);
return next;
}
export async function listAccounts(): Promise<AdminAccountRecord[]> {
const file = await readRawFile();
const accounts = await ensureAdminBootstrapped(file?.accounts ?? []);
return accounts;
}
export async function listSessionAccounts(): Promise<SessionAccount[]> {
const accounts = await listAccounts();
return accounts.map(toSessionAccount);
}
export async function findAccountByUsername(
username: string,
): Promise<AdminAccountRecord | null> {
const needle = username.trim().toLowerCase();
if (!needle) return null;
const accounts = await listAccounts();
return (
accounts.find((a) => a.username.toLowerCase() === needle) ?? null
);
}
export async function findAccountById(
id: string,
): Promise<AdminAccountRecord | null> {
const accounts = await listAccounts();
return accounts.find((a) => a.id === id) ?? null;
}
export async function verifyAccountCredentials(
username: string,
password: string,
): Promise<SessionAccount | null> {
const account = await findAccountByUsername(username);
if (!account) return null;
if (!verifyPassword(password, account.passwordHash)) return null;
return toSessionAccount(account);
}
export async function createAccount(input: {
username: string;
password: string;
permissions: PagePermissions;
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
const username = input.username.trim();
if (!username) return { ok: false, error: "missingUsername" };
if (!input.password) return { ok: false, error: "missingPassword" };
return enqueueWrite(async () => {
const accounts = await listAccounts();
if (
accounts.some((a) => a.username.toLowerCase() === username.toLowerCase())
) {
return { ok: false, error: "duplicate" };
}
const record: AdminAccountRecord = {
id: randomUUID(),
username,
passwordHash: hashPassword(input.password),
isAdmin: false,
permissions: normalizePagePermissions(input.permissions),
};
await writeAccountsFile([...accounts, record]);
return { ok: true, account: toSessionAccount(record) };
});
}
export async function updateAccount(input: {
id: string;
username?: string;
password?: string;
permissions?: PagePermissions;
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
return enqueueWrite(async () => {
const accounts = await listAccounts();
const idx = accounts.findIndex((a) => a.id === input.id);
if (idx < 0) return { ok: false, error: "notFound" };
const current = accounts[idx]!;
let username = current.username;
if (!current.isAdmin && input.username !== undefined) {
const nextName = input.username.trim();
if (!nextName) return { ok: false, error: "missingUsername" };
if (
accounts.some(
(a) =>
a.id !== current.id &&
a.username.toLowerCase() === nextName.toLowerCase(),
)
) {
return { ok: false, error: "duplicate" };
}
username = nextName;
}
let passwordHash = current.passwordHash;
if (input.password !== undefined && input.password !== "") {
passwordHash = hashPassword(input.password);
}
let permissions = current.permissions;
if (!current.isAdmin && input.permissions !== undefined) {
permissions = normalizePagePermissions(input.permissions);
} else if (current.isAdmin) {
permissions = fullPagePermissions();
}
const next: AdminAccountRecord = {
...current,
username,
passwordHash,
permissions,
};
const updated = [...accounts];
updated[idx] = next;
await writeAccountsFile(updated);
return { ok: true, account: toSessionAccount(next) };
});
}
export async function deleteAccount(
id: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
return enqueueWrite(async () => {
const accounts = await listAccounts();
const target = accounts.find((a) => a.id === id);
if (!target) return { ok: false, error: "notFound" };
if (target.isAdmin) return { ok: false, error: "cannotDeleteAdmin" };
await writeAccountsFile(accounts.filter((a) => a.id !== id));
return { ok: true };
});
}
+140
View File
@@ -0,0 +1,140 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
export type AuditLogEntry = {
id: string;
at: string;
username: string;
accountId: string | null;
action: string;
summary: string;
details?: Record<string, unknown>;
ip?: string | null;
/** Short device label (e.g. "Desktop · Windows · Chrome"). */
device?: string | null;
/** Raw User-Agent (truncated), mainly for login audits. */
userAgent?: string | null;
};
export type AuditLogInput = {
username: string;
accountId?: string | null;
action: string;
summary: string;
details?: Record<string, unknown>;
ip?: string | null;
device?: string | null;
userAgent?: string | null;
};
const DEFAULT_READ_LIMIT = 500;
function auditLogPath(): string {
const override = process.env.ADMIN_AUDIT_LOG_PATH?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), "data", "admin-audit.jsonl");
}
let writeChain: Promise<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
const next = writeChain.then(fn, fn);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
/** Append one audit entry. Never throws to callers (best-effort). */
export async function appendAuditLog(
input: AuditLogInput,
): Promise<void> {
try {
await enqueueWrite(async () => {
const filePath = auditLogPath();
await mkdir(path.dirname(filePath), { recursive: true });
const entry: AuditLogEntry = {
id: randomUUID(),
at: new Date().toISOString(),
username: input.username,
accountId: input.accountId ?? null,
action: input.action,
summary: input.summary,
...(input.details ? { details: input.details } : {}),
...(input.ip != null ? { ip: input.ip } : {}),
...(input.device != null ? { device: input.device } : {}),
...(input.userAgent != null ? { userAgent: input.userAgent } : {}),
};
await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
});
} catch (err) {
console.error("[audit-log] failed to append", err);
}
}
function parseLine(line: string): AuditLogEntry | null {
const trimmed = line.trim();
if (!trimmed) return null;
try {
const raw = JSON.parse(trimmed) as Partial<AuditLogEntry>;
if (
typeof raw.id !== "string" ||
typeof raw.at !== "string" ||
typeof raw.username !== "string" ||
typeof raw.action !== "string" ||
typeof raw.summary !== "string"
) {
return null;
}
return {
id: raw.id,
at: raw.at,
username: raw.username,
accountId:
typeof raw.accountId === "string" ? raw.accountId : null,
action: raw.action,
summary: raw.summary,
...(raw.details && typeof raw.details === "object"
? { details: raw.details as Record<string, unknown> }
: {}),
...(typeof raw.ip === "string" || raw.ip === null
? { ip: raw.ip }
: {}),
...(typeof raw.device === "string" || raw.device === null
? { device: raw.device }
: {}),
...(typeof raw.userAgent === "string" || raw.userAgent === null
? { userAgent: raw.userAgent }
: {}),
};
} catch {
return null;
}
}
/** Newest-first. Caps at `limit` entries. */
export async function readAuditLog(
limit = DEFAULT_READ_LIMIT,
): Promise<AuditLogEntry[]> {
const filePath = auditLogPath();
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return [];
throw err;
}
const lines = text.split("\n");
const entries: AuditLogEntry[] = [];
for (let i = lines.length - 1; i >= 0; i--) {
const entry = parseLine(lines[i]!);
if (!entry) continue;
entries.push(entry);
if (entries.length >= limit) break;
}
return entries;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Best-effort device / browser label from a User-Agent string.
* Not for security decisions — display and audit only.
*/
export function parseDeviceFromUserAgent(
userAgent: string | null | undefined,
): { device: string; userAgent: string | null } {
const ua = userAgent?.trim() || null;
if (!ua) {
return { device: "Unknown device", userAgent: null };
}
const os =
/Windows NT/i.test(ua)
? "Windows"
: /Android/i.test(ua)
? "Android"
: /iPhone|iPad|iPod/i.test(ua)
? "iOS"
: /Mac OS X|Macintosh/i.test(ua)
? "macOS"
: /CrOS/i.test(ua)
? "Chrome OS"
: /Linux/i.test(ua)
? "Linux"
: null;
const browser =
/Edg\//i.test(ua)
? "Edge"
: /OPR\/|Opera/i.test(ua)
? "Opera"
: /Firefox\//i.test(ua)
? "Firefox"
: /Chrome\//i.test(ua) && !/Edg\//i.test(ua)
? "Chrome"
: /Safari\//i.test(ua) && !/Chrome\//i.test(ua)
? "Safari"
: /curl\//i.test(ua)
? "curl"
: null;
const formFactor = /Mobile|Android.*Mobile|iPhone|iPod/i.test(ua)
? "Mobile"
: /iPad|Tablet|Android(?!.*Mobile)/i.test(ua)
? "Tablet"
: "Desktop";
const parts = [formFactor, os, browser].filter(Boolean);
return {
device: parts.length > 0 ? parts.join(" · ") : "Unknown device",
userAgent: ua.slice(0, 512),
};
}
export function getRequestUserAgent(request: Request): string | null {
const ua = request.headers.get("user-agent")?.trim();
return ua || null;
}
+5 -48
View File
@@ -1,52 +1,9 @@
import { timingSafeEqual } from "node:crypto"; import { verifyAccountCredentials } from "@/lib/auth/accounts-store";
import type { AdminRole } from "@/lib/auth/roles"; import type { SessionAccount } from "@/lib/auth/permissions";
function safeEqual(a: string, b: string): boolean { export async function verifyCredentials(
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 getSupervisorUsername(): string {
return process.env.SUPERVISOR_USERNAME?.trim() || "supervisor";
}
export function getSupervisorPassword(): string | null {
const password = process.env.SUPERVISOR_PASSWORD?.trim();
return password || null;
}
export function verifyCredentials(
username: string, username: string,
password: string, password: string,
): AdminRole | null { ): Promise<SessionAccount | null> {
const adminPassword = getAdminPassword(); return verifyAccountCredentials(username, password);
if (
adminPassword &&
safeEqual(username, getAdminUsername()) &&
safeEqual(password, adminPassword)
) {
return "admin";
}
const supervisorPassword = getSupervisorPassword();
if (
supervisorPassword &&
safeEqual(username, getSupervisorUsername()) &&
safeEqual(password, supervisorPassword)
) {
return "supervisor";
}
return null;
} }
+47
View File
@@ -0,0 +1,47 @@
import { headers } from "next/headers";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { parseDeviceFromUserAgent } from "@/lib/auth/client-device";
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
import type { SessionAccount } from "@/lib/auth/permissions";
function clientIpFromHeaders(h: Headers): string {
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = h.get("x-real-ip")?.trim();
if (realIp) return realIp;
return "unknown";
}
/** Record that an account loaded a panel page. Best-effort; never throws. */
export async function logPageAccess(
account: SessionAccount,
page: string,
extraDetails?: Record<string, unknown>,
): Promise<void> {
try {
const h = await headers();
const ip = clientIpFromHeaders(h);
const { device, userAgent } = parseDeviceFromUserAgent(
h.get("user-agent"),
);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "access",
summary: `Accessed ${pageAccessLabel(page)}`,
ip,
device,
userAgent,
details: {
page,
...(extraDetails ?? {}),
},
});
} catch (err) {
console.error("[audit-log] page access failed", err);
}
}
+17
View File
@@ -0,0 +1,17 @@
const PAGE_LABELS: Record<string, string> = {
overview: "Overview",
players: "Players",
matches: "Matches",
analysis: "Analysis",
matchmaker: "Matchmaker",
ledger: "Ledger",
logs: "System logs",
settings: "Settings",
"ledger-book": "Ledger book",
"match-log": "Match log",
"matchmaker-logs": "Matchmaker logs",
};
export function pageAccessLabel(page: string): string {
return PAGE_LABELS[page] ?? page;
}
+101
View File
@@ -0,0 +1,101 @@
export const PAGE_KEYS = [
"players",
"matches",
"analysis",
"matchmaker",
"ledger",
"logs",
] as const;
export type PageKey = (typeof PAGE_KEYS)[number];
/** Pages that only support read access (no write checkbox). */
export const READ_ONLY_PAGE_KEYS = ["logs"] as const satisfies readonly PageKey[];
export type PagePermission = {
read: boolean;
write: boolean;
};
export type PagePermissions = Record<PageKey, PagePermission>;
export function emptyPagePermissions(): PagePermissions {
return {
players: { read: false, write: false },
matches: { read: false, write: false },
analysis: { read: false, write: false },
matchmaker: { read: false, write: false },
ledger: { read: false, write: false },
logs: { read: false, write: false },
};
}
export function fullPagePermissions(): PagePermissions {
return {
players: { read: true, write: true },
matches: { read: true, write: true },
analysis: { read: true, write: true },
matchmaker: { read: true, write: true },
ledger: { read: true, write: true },
logs: { read: true, write: false },
};
}
/** Normalize permissions: write implies read. Logs never grants write. */
export function normalizePagePermissions(
input: Partial<Record<PageKey, Partial<PagePermission>>> | null | undefined,
): PagePermissions {
const base = emptyPagePermissions();
for (const key of PAGE_KEYS) {
const raw = input?.[key];
if (key === "logs") {
base[key] = { read: Boolean(raw?.read), write: false };
continue;
}
const write = Boolean(raw?.write);
const read = write || Boolean(raw?.read);
base[key] = { read, write };
}
return base;
}
export type SessionAccount = {
id: string;
username: string;
isAdmin: boolean;
permissions: PagePermissions;
};
export function canReadPage(
account: SessionAccount,
page: PageKey,
): boolean {
if (account.isAdmin) return true;
return account.permissions[page].read;
}
export function canWritePage(
account: SessionAccount,
page: PageKey,
): boolean {
if (account.isAdmin) return true;
if (page === "logs") return false;
return account.permissions[page].write;
}
/** Map dashboard tab (except overview) to a permission page key. */
export function tabToPageKey(
tab: string,
): PageKey | null {
if (
tab === "players" ||
tab === "matches" ||
tab === "analysis" ||
tab === "matchmaker" ||
tab === "ledger" ||
tab === "logs"
) {
return tab;
}
return null;
}
+54 -17
View File
@@ -1,30 +1,67 @@
import { findAccountByUsername } from "@/lib/auth/accounts-store";
import {
canReadPage,
canWritePage,
fullPagePermissions,
normalizePagePermissions,
type PageKey,
type SessionAccount,
} from "@/lib/auth/permissions";
import { parseSessionToken } from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import {
isReadOnlyRole,
parseSessionRole,
type AdminRole,
} from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
export async function getSessionRole(): Promise<AdminRole | null> { export async function getSessionAccount(): Promise<SessionAccount | null> {
const cookieStore = await cookies(); const cookieStore = await cookies();
return parseSessionRole(cookieStore.get(ADMIN_SESSION_COOKIE)?.value); const username = parseSessionToken(
cookieStore.get(ADMIN_SESSION_COOKIE)?.value,
);
if (!username) return null;
const record = await findAccountByUsername(username);
if (!record) return null;
return {
id: record.id,
username: record.username,
isAdmin: record.isAdmin,
permissions: record.isAdmin
? fullPagePermissions()
: normalizePagePermissions(record.permissions),
};
} }
export async function requireSession(): Promise<AdminRole> { export async function requireSession(): Promise<SessionAccount> {
const role = await getSessionRole(); const account = await getSessionAccount();
if (!role) { if (!account) {
redirect("/login"); redirect("/login");
} }
return role; return account;
} }
/** For server actions and routes that modify database state. */ export async function requireAdmin(): Promise<SessionAccount> {
export async function requireWriteAccess(): Promise<AdminRole> { const account = await requireSession();
const role = await requireSession(); if (!account.isAdmin) {
if (isReadOnlyRole(role)) {
redirect("/"); redirect("/");
} }
return role; return account;
}
export async function requirePageRead(
page: PageKey,
): Promise<SessionAccount> {
const account = await requireSession();
if (!canReadPage(account, page)) {
redirect("/");
}
return account;
}
export async function requirePageWrite(
page: PageKey,
): Promise<SessionAccount> {
const account = await requireSession();
if (!canWritePage(account, page)) {
redirect("/");
}
return account;
} }
+48 -15
View File
@@ -1,25 +1,58 @@
export type AdminRole = "admin" | "supervisor"; import { createHmac, timingSafeEqual } from "node:crypto";
/** Legacy session cookie value; treated as admin. */ /** Legacy cookie values from the old role-based session (invalidate on parse). */
export const LEGACY_ADMIN_SESSION_VALUE = "1"; export const LEGACY_ADMIN_SESSION_VALUE = "1";
export function roleToSessionCookieValue(role: AdminRole): string { function getSessionSecret(): string {
return role; const explicit = process.env.ADMIN_SESSION_SECRET?.trim();
if (explicit) return explicit;
const bootstrap = process.env.ADMIN_PASSWORD?.trim();
if (bootstrap) return `kickkings-session:${bootstrap}`;
return "kickkings-dev-session-secret";
} }
export function parseSessionRole( function signUsername(username: string): string {
value: string | undefined, return createHmac("sha256", getSessionSecret())
): AdminRole | null { .update(username)
.digest("base64url");
}
/** Build cookie payload: `username.signature`. */
export function createSessionToken(username: string): string {
const u = username.trim();
return `${u}.${signUsername(u)}`;
}
/**
* Parse and verify a signed session cookie.
* Returns the username, or null if invalid / legacy.
*/
export function parseSessionToken(value: string | undefined): string | null {
if (!value) return null; if (!value) return null;
if (value === LEGACY_ADMIN_SESSION_VALUE || value === "admin") { // Invalidate old role cookies immediately.
return "admin"; if (
value === LEGACY_ADMIN_SESSION_VALUE ||
value === "admin" ||
value === "supervisor"
) {
return null;
} }
if (value === "supervisor") {
return "supervisor"; const dot = value.lastIndexOf(".");
} if (dot <= 0 || dot === value.length - 1) return null;
return null; const username = value.slice(0, dot);
const sig = value.slice(dot + 1);
if (!username || !sig) return null;
const expected = signUsername(username);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) return null;
if (!timingSafeEqual(a, b)) return null;
return username;
} }
export function isReadOnlyRole(role: AdminRole): boolean { /** Whether the cookie looks like a valid signed session (for middleware/proxy). */
return role === "supervisor"; export function hasValidSessionToken(value: string | undefined): boolean {
return parseSessionToken(value) != null;
} }
+3 -7
View File
@@ -1,10 +1,6 @@
import type { NextResponse } from "next/server"; import type { NextResponse } from "next/server";
import { import { createSessionToken } from "@/lib/auth/roles";
roleToSessionCookieValue,
type AdminRole,
} from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session"; import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
import { getRequestOrigin } from "@/lib/request-public-url"; import { getRequestOrigin } from "@/lib/request-public-url";
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */ /** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
@@ -54,11 +50,11 @@ export function getSessionCookieClearOptions(request: Request) {
export function applySessionCookie( export function applySessionCookie(
res: NextResponse, res: NextResponse,
request: Request, request: Request,
role: AdminRole, username: string,
) { ) {
res.cookies.set( res.cookies.set(
ADMIN_SESSION_COOKIE, ADMIN_SESSION_COOKIE,
roleToSessionCookieValue(role), createSessionToken(username),
getSessionCookieSetOptions(request), getSessionCookieSetOptions(request),
); );
} }
+17 -1
View File
@@ -7,7 +7,8 @@ export type AdminDashboardTab =
| "matches" | "matches"
| "matchmaker" | "matchmaker"
| "ledger" | "ledger"
| "analysis"; | "analysis"
| "logs";
export type DashboardUrlQuery = { export type DashboardUrlQuery = {
tab: AdminDashboardTab; tab: AdminDashboardTab;
@@ -32,6 +33,10 @@ export type DashboardUrlQuery = {
analysisTo?: string | null; analysisTo?: string | null;
/** Analysis tab: comma-separated player ids (`aplayers`). */ /** Analysis tab: comma-separated player ids (`aplayers`). */
analysisPlayers?: string | null; analysisPlayers?: string | null;
/** Matches tab: local date-only bounds (`mfrom` / `mto`) + tz offset minutes (`mtz`). */
matchesFrom?: string | null;
matchesTo?: string | null;
matchesTzOffsetMinutes?: number | null;
}; };
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */ /** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
@@ -42,6 +47,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
else if (q.tab === "matchmaker") p.set("tab", "matchmaker"); else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
else if (q.tab === "ledger") p.set("tab", "ledger"); else if (q.tab === "ledger") p.set("tab", "ledger");
else if (q.tab === "analysis") p.set("tab", "analysis"); else if (q.tab === "analysis") p.set("tab", "analysis");
else if (q.tab === "logs") p.set("tab", "logs");
if (q.highlightId) p.set("highlight", q.highlightId); if (q.highlightId) p.set("highlight", q.highlightId);
if (q.participantRaw) p.set("participant", q.participantRaw); if (q.participantRaw) p.set("participant", q.participantRaw);
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") { if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
@@ -72,6 +78,16 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
if (q.analysisTo) p.set("ato", q.analysisTo); if (q.analysisTo) p.set("ato", q.analysisTo);
if (q.analysisPlayers) p.set("aplayers", q.analysisPlayers); if (q.analysisPlayers) p.set("aplayers", q.analysisPlayers);
} }
if (q.tab === "matches") {
if (q.matchesFrom) p.set("mfrom", q.matchesFrom);
if (q.matchesTo) p.set("mto", q.matchesTo);
if (
q.matchesTzOffsetMinutes != null &&
Number.isFinite(q.matchesTzOffsetMinutes)
) {
p.set("mtz", String(Math.trunc(q.matchesTzOffsetMinutes)));
}
}
if (q.editId != null && Number.isFinite(q.editId)) { if (q.editId != null && Number.isFinite(q.editId)) {
p.set("edit", String(q.editId)); p.set("edit", String(q.editId));
} }
+101
View File
@@ -0,0 +1,101 @@
import {
parseIsoDateOnly,
utcLast30DaysDateRange,
} from "@/lib/ledger-utc-date-range";
/** Rolling last 30 local calendar days (browser / given tz offset). */
export function localLast30DaysDateRange(
tzOffsetMinutes: number,
): { from: string; to: string } {
const now = new Date();
// Instant that has the same Y-M-D components in UTC as "now" in the local zone.
const localNowMs = now.getTime() - tzOffsetMinutes * 60_000;
const localNow = new Date(localNowMs);
const y = localNow.getUTCFullYear();
const m = localNow.getUTCMonth();
const d = localNow.getUTCDate();
const to = new Date(Date.UTC(y, m, d));
const from = new Date(to);
from.setUTCDate(from.getUTCDate() - 29);
return {
from: from.toISOString().slice(0, 10),
to: to.toISOString().slice(0, 10),
};
}
/**
* Parse `getTimezoneOffset()`-style minutes from a query param.
* Returns null when absent or invalid.
*/
export function parseTzOffsetMinutes(raw: string | null): number | null {
if (raw == null || raw.trim() === "") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return null;
const trunc = Math.trunc(n);
// Valid JS offsets are roughly ±14h; allow a little slack.
if (trunc < -16 * 60 || trunc > 16 * 60) return null;
return trunc;
}
/**
* Inclusive local calendar-day bounds → UTC ISO instants for DB filtering.
* `tzOffsetMinutes` is `Date#getTimezoneOffset()` (minutes to add to local to get UTC).
*/
export function localDateRangeToUtcIsoBounds(
from: string,
to: string,
tzOffsetMinutes: number,
): { rangeStartIso: string; rangeEndIso: string } {
const [fy, fm, fd] = from.split("-").map(Number) as [number, number, number];
const [ty, tm, td] = to.split("-").map(Number) as [number, number, number];
const startUtcMs =
Date.UTC(fy, fm - 1, fd, 0, 0, 0, 0) + tzOffsetMinutes * 60_000;
const endUtcMs =
Date.UTC(ty, tm - 1, td, 23, 59, 59, 999) + tzOffsetMinutes * 60_000;
return {
rangeStartIso: new Date(startUtcMs).toISOString(),
rangeEndIso: new Date(endUtcMs).toISOString(),
};
}
/** Normalize match date bounds; uses local last-30 when tz is known, else UTC last-30. */
export function normalizeMatchesDateRange(
fromRaw: string | null,
toRaw: string | null,
tzOffsetMinutes: number | null,
): { from: string; to: string } {
const fallback =
tzOffsetMinutes != null
? localLast30DaysDateRange(tzOffsetMinutes)
: utcLast30DaysDateRange();
let from = (fromRaw ?? "").trim();
let to = (toRaw ?? "").trim();
if (!parseIsoDateOnly(from) || !parseIsoDateOnly(to)) {
return fallback;
}
if (from > to) {
const t = from;
from = to;
to = t;
}
return { from, to };
}
/** Format an ISO timestamp in the viewer's local timezone (client-only). */
export function formatLocalTimestamp(value: string | null): string {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
const y = d.getFullYear();
const m = pad(d.getMonth() + 1);
const day = pad(d.getDate());
const h = pad(d.getHours());
const min = pad(d.getMinutes());
const s = pad(d.getSeconds());
return `${y}-${m}-${day} ${h}:${min}:${s}`;
} catch {
return value;
}
}
+38 -24
View File
@@ -30,6 +30,9 @@ export type MatchEntryHoldAndFeePerPlayerCoins = {
feePerPlayer: Map<number, bigint>; feePerPlayer: Map<number, bigint>;
}; };
/** PostgREST / Supabase default max rows per request. */
const TX_PAGE_SIZE = 1000;
/** /**
* For each match_id, average debit amount for `entry_hold` and `entry_fee` * For each match_id, average debit amount for `entry_hold` and `entry_fee`
* (per player when two rows exist; sum/count handles odd counts). * (per player when two rows exist; sum/count handles odd counts).
@@ -44,15 +47,6 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
}; };
if (matchIds.length === 0) return empty; if (matchIds.length === 0) return empty;
const { data, error } = await supabase
.from("transactions")
.select("match_id, amount, remarks")
.in("match_id", matchIds);
if (error) {
return empty;
}
const holdAgg: PerRemarkAgg = { const holdAgg: PerRemarkAgg = {
sums: new Map(), sums: new Map(),
counts: new Map(), counts: new Map(),
@@ -62,21 +56,41 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
counts: new Map(), counts: new Map(),
}; };
for (const row of (data ?? []) as Pick< let offset = 0;
DbTransaction, for (;;) {
"match_id" | "amount" | "remarks" const { data, error } = await supabase
>[]) { .from("transactions")
const rmk = normRemark(row.remarks); .select("match_id, amount, remarks")
if (rmk !== "entry_hold" && rmk !== "entry_fee") continue; .in("match_id", matchIds)
const midRaw = row.match_id; .in("remarks", ["entry_hold", "entry_fee"])
if (midRaw == null) continue; .order("id", { ascending: true })
const mid = .range(offset, offset + TX_PAGE_SIZE - 1);
typeof midRaw === "number" ? midRaw : Math.trunc(Number(midRaw));
if (!Number.isFinite(mid)) continue; if (error) {
const a = txAmountToBigInt(row.amount); return empty;
if (a === BigInt(0)) continue; }
if (rmk === "entry_hold") bumpAgg(holdAgg, mid, a);
else bumpAgg(feeAgg, mid, a); const batch = (data ?? []) as Pick<
DbTransaction,
"match_id" | "amount" | "remarks"
>[];
for (const row of batch) {
const rmk = normRemark(row.remarks);
if (rmk !== "entry_hold" && rmk !== "entry_fee") continue;
const midRaw = row.match_id;
if (midRaw == null) continue;
const mid =
typeof midRaw === "number" ? midRaw : Math.trunc(Number(midRaw));
if (!Number.isFinite(mid)) continue;
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) continue;
if (rmk === "entry_hold") bumpAgg(holdAgg, mid, a);
else bumpAgg(feeAgg, mid, a);
}
if (batch.length < TX_PAGE_SIZE) break;
offset += TX_PAGE_SIZE;
} }
return { return {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation"; import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation";
import type { DbPingReport } from "@/types/database"; import type { DbPingReport } from "@/types/database";
const BAD_PING_THRESHOLD_MS = 120; const BAD_PING_THRESHOLD_MS = 200;
const TOP_ROWS_LIMIT = 20; const TOP_ROWS_LIMIT = 20;
type PingReportWithUser = DbPingReport & { type PingReportWithUser = DbPingReport & {
+4 -2
View File
@@ -1,11 +1,13 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { parseSessionRole } from "@/lib/auth/roles"; import { hasValidSessionToken } from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { publicRequestUrl } from "@/lib/request-public-url"; import { publicRequestUrl } from "@/lib/request-public-url";
function hasValidSession(request: NextRequest): boolean { function hasValidSession(request: NextRequest): boolean {
return parseSessionRole(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) != null; return hasValidSessionToken(
request.cookies.get(ADMIN_SESSION_COOKIE)?.value,
);
} }
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {