diff --git a/.env.example b/.env.example index 594ab76..42087cb 100644 --- a/.env.example +++ b/.env.example @@ -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_PASSWORD=change-me-to-a-strong-password -# Read-only supervisor login (server only; can view all data but cannot modify) -SUPERVISOR_USERNAME=supervisor -SUPERVISOR_PASSWORD=change-me-to-a-strong-password +# Optional: HMAC secret for signed session cookies. Defaults to a value derived +# from ADMIN_PASSWORD when unset. +# ADMIN_SESSION_SECRET= + +# Optional: absolute path for the accounts JSON file (default: /data/admin-accounts.json) +# ADMIN_ACCOUNTS_PATH= + +# Optional: absolute path for the panel audit log (default: /data/admin-audit.jsonl) +# ADMIN_AUDIT_LOG_PATH= # Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects). # Example: https://kickkings.playpoolstudios.com diff --git a/.gitignore b/.gitignore index 5ef6a52..a3813a4 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# admin panel accounts (passwords hashed; still keep private) +/data/admin-accounts.json +/data/admin-audit.jsonl +/data/*.tmp diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/app/actions/account-actions.ts b/src/app/actions/account-actions.ts new file mode 100644 index 0000000..18e21e0 --- /dev/null +++ b/src/app/actions/account-actions.ts @@ -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> = {}; + 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"); +} diff --git a/src/app/actions/add-system-supply.ts b/src/app/actions/add-system-supply.ts index 6f48bb0..e031677 100644 --- a/src/app/actions/add-system-supply.ts +++ b/src/app/actions/add-system-supply.ts @@ -1,7 +1,8 @@ "use server"; 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 { buildDashboardHref, @@ -24,7 +25,7 @@ function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null { } export async function addSystemSupply(formData: FormData) { - await requireWriteAccess(); + const actor = await requirePageWrite("ledger"); const tabRaw = String(formData.get("tab") ?? ""); 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)); } diff --git a/src/app/actions/settings-actions.ts b/src/app/actions/settings-actions.ts index ed5ca5e..175eba5 100644 --- a/src/app/actions/settings-actions.ts +++ b/src/app/actions/settings-actions.ts @@ -1,7 +1,8 @@ "use server"; 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 { createAdminSupabase } from "@/lib/supabase/admin"; @@ -37,7 +38,7 @@ function normalizeSettingValue( } export async function updateSetting(formData: FormData) { - await requireWriteAccess(); + const actor = await requireAdmin(); const key = String(formData.get("key") ?? "").trim(); if (!key) { @@ -63,11 +64,19 @@ export async function updateSetting(formData: FormData) { redirect("/settings?saveError=1"); } + await appendAuditLog({ + username: actor.username, + accountId: actor.id, + action: "settings.update", + summary: `Updated setting “${key}”`, + details: { key, value }, + }); + redirect("/settings"); } export async function insertSetting(formData: FormData) { - await requireWriteAccess(); + const actor = await requireAdmin(); const key = String(formData.get("newKey") ?? "").trim(); const value = normalizeValue(formData.get("newValue")); @@ -91,11 +100,19 @@ export async function insertSetting(formData: FormData) { redirect(`/settings?addError=${code}`); } + await appendAuditLog({ + username: actor.username, + accountId: actor.id, + action: "settings.insert", + summary: `Created setting “${key}”`, + details: { key, value }, + }); + redirect("/settings"); } export async function deleteSetting(formData: FormData) { - await requireWriteAccess(); + const actor = await requireAdmin(); const key = String(formData.get("key") ?? "").trim(); if (!key) { @@ -112,5 +129,13 @@ export async function deleteSetting(formData: FormData) { redirect("/settings?saveError=1"); } + await appendAuditLog({ + username: actor.username, + accountId: actor.id, + action: "settings.delete", + summary: `Deleted setting “${key}”`, + details: { key }, + }); + redirect("/settings"); } diff --git a/src/app/actions/update-user-cc-rc.ts b/src/app/actions/update-user-cc-rc.ts index a786129..fea9a4b 100644 --- a/src/app/actions/update-user-cc-rc.ts +++ b/src/app/actions/update-user-cc-rc.ts @@ -1,7 +1,8 @@ "use server"; 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 { buildDashboardHref, type AdminDashboardTab, @@ -18,7 +19,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null { } export async function updateUserCcRc(formData: FormData) { - await requireWriteAccess(); + const actor = await requirePageWrite("players"); const userId = Number(formData.get("userId")); 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)); } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c6aa0e2..d8e8f7b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,9 +1,12 @@ 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 { verifyCredentials } from "@/lib/auth/credentials"; -import { - roleToSessionCookieValue, -} from "@/lib/auth/roles"; +import { createSessionToken } from "@/lib/auth/roles"; import { checkLoginRateLimit, clearLoginAttempts, @@ -28,6 +31,9 @@ function invalidJson() { export async function POST(request: Request) { const contentType = request.headers.get("content-type") ?? ""; const clientIp = getClientIp(request); + const { device, userAgent } = parseDeviceFromUserAgent( + getRequestUserAgent(request), + ); const rateLimit = checkLoginRateLimit(clientIp); if (!rateLimit.allowed) { @@ -57,8 +63,22 @@ export async function POST(request: Request) { password = String(formData.get("password") ?? ""); } - const role = verifyCredentials(username, password); - if (!role) { + let account; + 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); if (contentType.includes("application/json")) { return invalidJson(); @@ -68,17 +88,32 @@ export async function POST(request: Request) { 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")) { const res = NextResponse.json({ ok: true }); res.cookies.set( ADMIN_SESSION_COOKIE, - roleToSessionCookieValue(role), + createSessionToken(account.username), getSessionCookieSetOptions(request), ); return res; } const res = NextResponse.redirect(publicRequestUrl(request, "/")); - applySessionCookie(res, request, role); + applySessionCookie(res, request, account.username); return res; } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index faec7b0..1f07eca 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,8 +1,33 @@ 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 { getSessionCookieClearOptions } from "@/lib/auth/session-cookie"; 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 }); res.cookies.set( ADMIN_SESSION_COOKIE, diff --git a/src/app/api/match-logs/[matchId]/route.ts b/src/app/api/match-logs/[matchId]/route.ts index 6e6c233..512b59d 100644 --- a/src/app/api/match-logs/[matchId]/route.ts +++ b/src/app/api/match-logs/[matchId]/route.ts @@ -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 { parseMatchIdParam, readMatchLogFile, @@ -8,8 +9,11 @@ export async function GET( _request: Request, context: { params: Promise<{ matchId: string }> }, ) { - const role = await getSessionRole(); - if (!role) { + const account = await getSessionAccount(); + if ( + !account || + (!canReadPage(account, "matches") && !canReadPage(account, "analysis")) + ) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/matchmaker-logs/route.ts b/src/app/api/matchmaker-logs/route.ts index 236133f..903283a 100644 --- a/src/app/api/matchmaker-logs/route.ts +++ b/src/app/api/matchmaker-logs/route.ts @@ -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 { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; const NO_STORE = { "Cache-Control": "no-store, max-age=0" }; export async function GET(request: Request) { - const role = await getSessionRole(); - if (!role) { + const account = await getSessionAccount(); + if (!account || !canReadPage(account, "matchmaker")) { return Response.json( { error: "Unauthorized" }, { status: 401, headers: NO_STORE }, diff --git a/src/app/ledger-book/page.tsx b/src/app/ledger-book/page.tsx index 3d868de..350e1f0 100644 --- a/src/app/ledger-book/page.tsx +++ b/src/app/ledger-book/page.tsx @@ -3,7 +3,8 @@ import type { Metadata } from "next"; import Link from "next/link"; import { redirect } from "next/navigation"; 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 { formatRcDecimalFromCoinsBigInt, formatRcLabelFromCoinsBigInt, @@ -57,7 +58,8 @@ export default async function LedgerBookPage({ }: { searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>; }) { - await requireSession(); + const account = await requirePageRead("ledger"); + await logPageAccess(account, "ledger-book"); const sp = await searchParams; let fromStr = firstSearchParam(sp.from); @@ -147,7 +149,7 @@ export default async function LedgerBookPage({ return (
- +

diff --git a/src/app/match-logs/[matchId]/page.tsx b/src/app/match-logs/[matchId]/page.tsx index c10e78b..8416445 100644 --- a/src/app/match-logs/[matchId]/page.tsx +++ b/src/app/match-logs/[matchId]/page.tsx @@ -1,12 +1,15 @@ import type { Metadata } from "next"; import Link from "next/link"; 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 { MatchLogColoredBody } from "@/components/match-log-colored-body"; import { parseMatchIdParam, readMatchLogFile, } from "@/lib/match-logs-server"; +import { redirect } from "next/navigation"; export async function generateMetadata({ params, @@ -24,7 +27,13 @@ export default async function MatchLogPage({ }: { 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 = parseMatchIdParam(raw); @@ -32,6 +41,8 @@ export default async function MatchLogPage({ notFound(); } + await logPageAccess(account, "match-log", { matchId }); + const result = await readMatchLogFile(matchId); return ( diff --git a/src/app/matchmaker-logs/page.tsx b/src/app/matchmaker-logs/page.tsx index e10922c..228882c 100644 --- a/src/app/matchmaker-logs/page.tsx +++ b/src/app/matchmaker-logs/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; 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 { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; @@ -15,7 +16,8 @@ export default async function MatchmakerLogsPage({ }: { searchParams: Promise<{ mklog?: string | string[] }>; }) { - await requireSession(); + const account = await requirePageRead("matchmaker"); + await logPageAccess(account, "matchmaker-logs"); const sp = await searchParams; const raw = diff --git a/src/app/page.tsx b/src/app/page.tsx index 4cefac1..e270d86 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,23 @@ import { AdminDashboard } from "@/components/admin-dashboard"; import { AdminHeader } from "@/components/admin-header"; 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 { loadDashboardStatsBundle } from "@/lib/dashboard-stats"; import { normalizeLedgerDateRange, + parseIsoDateOnly, utcLast30DaysDateRange, } from "@/lib/ledger-utc-date-range"; +import { + localDateRangeToUtcIsoBounds, + normalizeMatchesDateRange, + parseTzOffsetMinutes, +} from "@/lib/local-date-range"; import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server"; import { serializeLedgerGlobalSummary, @@ -51,6 +61,9 @@ import { type LedgerSortKey, type LedgerSortOrder, } 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"; @@ -82,14 +95,26 @@ export default async function Home({ afrom?: string | string[]; ato?: string | string[]; aplayers?: string | string[]; + mfrom?: string | string[]; + mto?: string | string[]; + mtz?: string | string[]; }>; }) { - const role = await requireSession(); - const readOnly = isReadOnlyRole(role); + const account = await requireSession(); + 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 tabParam = firstSearchParam(sp.tab); - const tab: AdminDashboardTab = + const requestedTab: AdminDashboardTab = tabParam === "matches" ? "matches" : tabParam === "players" @@ -100,7 +125,20 @@ export default async function Home({ ? "ledger" : tabParam === "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 participantRaw = firstSearchParam(sp.participant); const editRaw = firstSearchParam(sp.edit); @@ -129,6 +167,10 @@ export default async function Home({ let ledgerTo = utcLast30DaysDateRange().to; let analysisFrom = utcLast30DaysDateRange().from; let analysisTo = utcLast30DaysDateRange().to; + let matchesFrom = utcLast30DaysDateRange().from; + let matchesTo = utcLast30DaysDateRange().to; + let matchesTzOffsetMinutes: number | null = null; + let matchesRangeExplicit = false; let analysisPlayerIds: number[] = []; let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult(); let pingAnalytics: PingAnalyticsResult = { @@ -138,7 +180,7 @@ export default async function Home({ totalPlayers: 0, avgPing: null, p95Ping: null, - badThresholdMs: 120, + badThresholdMs: 200, badReports: 0, badRatePercent: null, }, @@ -158,6 +200,8 @@ export default async function Home({ let matchmakerSource: MatchmakerLogSource = "processed"; let matchmakerContent = ""; let matchmakerError: string | null = null; + let auditEntries: AuditLogEntry[] = []; + let auditError: string | null = null; if (tab === "matchmaker") { const mkRaw = firstSearchParam(sp.mklog); 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) { configError = "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[]; } - 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") .select("*") - .order("id", { ascending: false }) - .limit(500); + .order("id", { ascending: false }); + + 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) { matchesError = matchesRes.error.message; @@ -309,13 +396,13 @@ export default async function Home({ } 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; } return (
- + {configError ? (
{editUser ? ( ; }) { - const role = await requireSession(); - const readOnly = isReadOnlyRole(role); + const account = await requireAdmin(); + await logPageAccess(account, "settings"); const sp = await searchParams; const saveError = firstSearchParam(sp.saveError) === "1"; const addError = firstSearchParam(sp.addError); + const accountError = firstSearchParam(sp.accountError); + const accountOk = firstSearchParam(sp.accountOk); + + const accounts = await listSessionAccounts(); const supabase = createAdminSupabase(); let rows: DbSetting[] = []; @@ -60,12 +68,27 @@ export default async function SettingsPage({ return (
- -
-
+ +
+

- Global settings + Settings

+

+ Global game settings and panel account access. Admin only. +

+
+ + + +
+

+ Global settings +

Key/value rows in the{" "} @@ -93,7 +116,7 @@ export default async function SettingsPage({ rows={rows} saveError={saveError} addError={addError} - readOnly={readOnly} + readOnly={false} /> )}

diff --git a/src/components/admin-accounts-editor.tsx b/src/components/admin-accounts-editor.tsx new file mode 100644 index 0000000..2a40bed --- /dev/null +++ b/src/components/admin-accounts-editor.tsx @@ -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 = { + 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 ( +
+ + + + + + + + + + + + + + {PAGE_KEYS.map((page) => ( + + + + + + ))} + +
PageReadWrite
+ Overview + + Always enabled +
+ {PAGE_LABELS[page]} + + setPerm(page, "read", e.target.checked)} + className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600" + /> + + {page === "logs" ? ( + + — + + ) : ( + setPerm(page, "write", e.target.checked)} + className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600" + /> + )} +
+
+ ); +} + +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 ( +
+ + + + {account.isAdmin ? ( +

+ Admin account — username is fixed; only the password can be changed. +

+ ) : ( +
+ + 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" + /> +
+ )} + +
+ + 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" + /> +
+ + {!account.isAdmin ? ( + + ) : null} + +
+ + +
+ + ); +} + +function AddAccountForm() { + const [permissions, setPermissions] = useState({ + 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 ( +
+

+ Add account +

+
+
+ + +
+
+ + +
+
+ + + + + + ); +} + +export function AdminAccountsEditor({ + accounts, + accountError, + accountOk, +}: Props) { + const [editingId, setEditingId] = useState(null); + const err = errorMessage(accountError); + const ok = okMessage(accountOk); + + return ( +
+
+

+ Accounts +

+

+ Manage panel logins and per-page read/write access. Only the admin + account can manage other accounts. +

+
+ + {err ? ( +
+ {err} +
+ ) : null} + {ok ? ( +
+ {ok} +
+ ) : null} + +
+

+ Existing accounts +

+
    + {accounts.map((account) => ( +
  • +
    +
    +

    + {account.username} + {account.isAdmin ? ( + + Admin + + ) : null} +

    +

    + {permissionSummary(account)} +

    +
    +
    + + {!account.isAdmin ? ( +
    + + +
    + ) : null} +
    +
    + {editingId === account.id ? ( + setEditingId(null)} + /> + ) : null} +
  • + ))} +
+
+ + +
+ ); +} diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index 0272a60..a6540e9 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -4,7 +4,10 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ClickableUserId } from "@/components/clickable-user-id"; 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 type { AuditLogEntry } from "@/lib/auth/audit-log"; import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats"; import { formatRcBalanceWithCoins } from "@/lib/coins-rc"; import { AdminMatchAnalysis } from "@/components/admin-match-analysis"; @@ -16,6 +19,9 @@ import { buildDashboardHref, type AdminDashboardTab, } from "@/lib/dashboard-search-url"; +import { + localLast30DaysDateRange, +} from "@/lib/local-date-range"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; import type { AdminMatchRow, @@ -221,13 +227,31 @@ type Props = { matchmakerError: string | null; /** Ledger: add-system-supply action failed (URL `supplyErr=1`). */ 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; analysisTo: string; analysisPlayerIds: number[]; matchAnalysis: MatchLogAnalysisResult; pingAnalytics: PingAnalyticsResult; 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({ @@ -278,19 +302,55 @@ export function AdminDashboard({ matchmakerContent, matchmakerError, supplyError, + matchesFrom, + matchesTo, + matchesTzOffsetMinutes, + matchesRangeExplicit, analysisFrom, analysisTo, analysisPlayerIds, matchAnalysis, pingAnalytics, geolocationAnalytics, - readOnly, + auditEntries, + auditError, + pageAccess, + canWritePlayers, + canWriteLedger, + isAdmin, }: Props) { const [hideNoWinner, setHideNoWinner] = useState(true); const [playersSearch, setPlayersSearch] = useState(""); const [lbSortKey, setLbSortKey] = useState("wins"); 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 sortedLeaderboard = useMemo(() => { const rows = lbRaw ?? []; @@ -407,84 +467,112 @@ export function AdminDashboard({ > Overview - - Players ( - {totalUsersLabel.toLocaleString("en-US")} - {!statsBundle?.stats - ? ` · table ${users.length.toLocaleString("en-US")}` - : null} - ) - - - Matches ( - {totalMatchesLabel.toLocaleString("en-US")} - {!statsBundle?.stats - ? ` · table ${matches.length.toLocaleString("en-US")}` - : null} - ) - - 0 - ? analysisPlayerIds.join(",") - : null, - })} - className={tabClass(tab === "analysis")} - scroll={false} - > - Analysis - - - Matchmaker - - - Ledger - - - Settings - + {pageAccess.players ? ( + + Players ( + {totalUsersLabel.toLocaleString("en-US")} + {!statsBundle?.stats + ? ` · table ${users.length.toLocaleString("en-US")}` + : null} + ) + + ) : null} + {pageAccess.matches ? ( + + Matches ( + {totalMatchesLabel.toLocaleString("en-US")} + {!statsBundle?.stats + ? ` · table ${matches.length.toLocaleString("en-US")}` + : null} + ) + + ) : null} + {pageAccess.analysis ? ( + 0 + ? analysisPlayerIds.join(",") + : null, + })} + className={tabClass(tab === "analysis")} + scroll={false} + > + Analysis + + ) : null} + {pageAccess.matchmaker ? ( + + Matchmaker + + ) : null} + {pageAccess.ledger ? ( + + Ledger + + ) : null} + {pageAccess.logs ? ( + + System logs + + ) : null} + {isAdmin ? ( + + Settings + + ) : null}
@@ -740,7 +828,7 @@ export function AdminDashboard({
- {!readOnly ? ( + {canWritePlayers ? ( + ) : tab === "logs" ? ( + ) : (
+
{ + const form = e.currentTarget; + const mtzInput = form.elements.namedItem( + "mtz", + ) as HTMLInputElement | null; + if (mtzInput) { + mtzInput.value = String(new Date().getTimezoneOffset()); + } + }} + > + + {highlightId ? ( + + ) : null} + {participantRaw ? ( + + ) : null} + +
+ + +
+
+ + +
+ + + Reset range + +
+ +

+ Showing {matches.length.toLocaleString("en-US")}{" "} + {matches.length === 1 ? "match" : "matches"} created{" "} + + {matchesFrom}–{matchesTo} + {" "} + in your local timezone + {matches.length >= 10000 ? " · capped at 10,000 rows" : null} +

+ {participantId != null ? (
@@ -900,7 +1087,7 @@ export function AdminDashboard({ {visibleMatches.length === 0 ? (
{filteredMatches.length === 0 - ? "No matches yet." + ? "No matches in this date range." : hideNoWinner ? "No matches left after hiding no-winner matches." : "No matches for this filter."} @@ -918,7 +1105,7 @@ export function AdminDashboard({ key={m.id} matchId={m.id} statusLabel={statusLabel(m.status)} - createdAtLabel={formatTs(m.created_at)} + createdAtLabel={} entryFeeCoins={m.entryFeePerPlayerCoins} entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins} prizeCcLabel={formatPrizeCcChip(m.prize_cc)} diff --git a/src/components/admin-header.tsx b/src/components/admin-header.tsx index fe0806c..60c1cd6 100644 --- a/src/components/admin-header.tsx +++ b/src/components/admin-header.tsx @@ -1,10 +1,11 @@ "use client"; type Props = { - readOnly?: boolean; + username?: string; + isAdmin?: boolean; }; -export function AdminHeader({ readOnly = false }: Props) { +export function AdminHeader({ username, isAdmin = false }: Props) { async function logout() { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login"; @@ -16,9 +17,13 @@ export function AdminHeader({ readOnly = false }: Props) {

Kick Kings Admin Dashboard

- {readOnly ? ( -

- Read-only access + {username ? ( +

+ Signed in as{" "} + + {username} + + {isAdmin ? " · admin" : null}

) : null}
diff --git a/src/components/admin-system-logs.tsx b/src/components/admin-system-logs.tsx new file mode 100644 index 0000000..3b231ab --- /dev/null +++ b/src/components/admin-system-logs.tsx @@ -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 = { + 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(); + 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(); + 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 ( +
+
+

+ System logs +

+

+ Panel actions, page access, and who performed them — including login + IP and device. Newest first (up to 1,000 entries). +

+
+ +
+
+ + 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" + /> +
+
+ + +
+
+ + +
+ {filtersActive ? ( + + ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} + +

+ Showing {filtered.length.toLocaleString("en-US")} of{" "} + {entries.length.toLocaleString("en-US")} + {filtersActive ? " (filtered)" : ""} +

+ + {entries.length === 0 && !error ? ( +

+ No actions logged yet. +

+ ) : filtered.length === 0 ? ( +

+ No log entries match these filters. +

+ ) : ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
WhenWhoActionDetailsIPDevice
+ + + {entry.username} + + + {actionLabel(entry.action)} + + + {entry.action} + {page ? ` · ${page}` : ""} + + +

{entry.summary}

+ {entry.details && + Object.keys(entry.details).length > 0 && + !isAuthAction(entry.action) && + !isAccessAction(entry.action) ? ( +
+                          {JSON.stringify(entry.details, null, 0)}
+                        
+ ) : null} + {(isAuthAction(entry.action) || + isAccessAction(entry.action)) && + userAgent ? ( +

+ {userAgent} +

+ ) : null} +
+ {showClientMeta ? (ip ?? "—") : "—"} + + {showClientMeta ? (device ?? "—") : "—"} +
+
+ )} +
+ ); +} diff --git a/src/components/local-timestamp.tsx b/src/components/local-timestamp.tsx new file mode 100644 index 0000000..b178fb6 --- /dev/null +++ b/src/components/local-timestamp.tsx @@ -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 ; + } + + return ( + + ); +} diff --git a/src/components/match-history-battle-card.tsx b/src/components/match-history-battle-card.tsx index c59db79..2c74d36 100644 --- a/src/components/match-history-battle-card.tsx +++ b/src/components/match-history-battle-card.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import type { ReactNode } from "react"; import { ClickableUserId } from "@/components/clickable-user-id"; import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc"; @@ -49,7 +50,7 @@ type PlayerSide = { type Props = { matchId: number; statusLabel: string; - createdAtLabel: string; + createdAtLabel: ReactNode; /** Ledger `entry_fee` / player as coin string; null if unknown. */ entryFeeCoins: string | null; /** Ledger `entry_hold` / player as coin string; null if unknown. */ diff --git a/src/lib/auth/accounts-store.ts b/src/lib/auth/accounts-store.ts new file mode 100644 index 0000000..ffad030 --- /dev/null +++ b/src/lib/auth/accounts-store.ts @@ -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; + 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 + >, + ); + return { + id: o.id, + username: o.username.trim(), + passwordHash: o.passwordHash, + isAdmin, + permissions, + }; +} + +async function readRawFile(): Promise { + 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 { + 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 = Promise.resolve(); + +function enqueueWrite(fn: () => Promise): Promise { + const next = writeChain.then(fn, fn); + writeChain = next.then( + () => undefined, + () => undefined, + ); + return next; +} + +async function ensureAdminBootstrapped( + accounts: AdminAccountRecord[], +): Promise { + 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 { + const file = await readRawFile(); + const accounts = await ensureAdminBootstrapped(file?.accounts ?? []); + return accounts; +} + +export async function listSessionAccounts(): Promise { + const accounts = await listAccounts(); + return accounts.map(toSessionAccount); +} + +export async function findAccountByUsername( + username: string, +): Promise { + 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 { + const accounts = await listAccounts(); + return accounts.find((a) => a.id === id) ?? null; +} + +export async function verifyAccountCredentials( + username: string, + password: string, +): Promise { + 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 }; + }); +} diff --git a/src/lib/auth/audit-log.ts b/src/lib/auth/audit-log.ts new file mode 100644 index 0000000..27ade69 --- /dev/null +++ b/src/lib/auth/audit-log.ts @@ -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; + 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; + 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 = Promise.resolve(); + +function enqueueWrite(fn: () => Promise): Promise { + 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 { + 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; + 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 } + : {}), + ...(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 { + 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; +} diff --git a/src/lib/auth/client-device.ts b/src/lib/auth/client-device.ts new file mode 100644 index 0000000..bb5fd27 --- /dev/null +++ b/src/lib/auth/client-device.ts @@ -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; +} diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index a30c026..a495016 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -1,52 +1,9 @@ -import { timingSafeEqual } from "node:crypto"; -import type { AdminRole } from "@/lib/auth/roles"; +import { verifyAccountCredentials } from "@/lib/auth/accounts-store"; +import type { SessionAccount } from "@/lib/auth/permissions"; -function safeEqual(a: string, b: string): boolean { - const aBuf = Buffer.from(a); - const bBuf = Buffer.from(b); - if (aBuf.length !== bBuf.length) return false; - return timingSafeEqual(aBuf, bBuf); -} - -export function getAdminUsername(): string { - return process.env.ADMIN_USERNAME?.trim() || "admin"; -} - -export function getAdminPassword(): string | null { - const password = process.env.ADMIN_PASSWORD?.trim(); - return password || null; -} - -export function 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( +export async function verifyCredentials( username: string, password: string, -): AdminRole | null { - const adminPassword = getAdminPassword(); - 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; +): Promise { + return verifyAccountCredentials(username, password); } diff --git a/src/lib/auth/log-page-access.ts b/src/lib/auth/log-page-access.ts new file mode 100644 index 0000000..013c7d4 --- /dev/null +++ b/src/lib/auth/log-page-access.ts @@ -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, +): Promise { + 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); + } +} diff --git a/src/lib/auth/page-access-labels.ts b/src/lib/auth/page-access-labels.ts new file mode 100644 index 0000000..f84bc6b --- /dev/null +++ b/src/lib/auth/page-access-labels.ts @@ -0,0 +1,17 @@ +const PAGE_LABELS: Record = { + 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; +} diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts new file mode 100644 index 0000000..17864d3 --- /dev/null +++ b/src/lib/auth/permissions.ts @@ -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; + +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>> | 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; +} diff --git a/src/lib/auth/require-session.ts b/src/lib/auth/require-session.ts index 5b8609a..a4daa9a 100644 --- a/src/lib/auth/require-session.ts +++ b/src/lib/auth/require-session.ts @@ -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 { 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 { +export async function getSessionAccount(): Promise { 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 { - const role = await getSessionRole(); - if (!role) { +export async function requireSession(): Promise { + const account = await getSessionAccount(); + if (!account) { redirect("/login"); } - return role; + return account; } -/** For server actions and routes that modify database state. */ -export async function requireWriteAccess(): Promise { - const role = await requireSession(); - if (isReadOnlyRole(role)) { +export async function requireAdmin(): Promise { + const account = await requireSession(); + if (!account.isAdmin) { redirect("/"); } - return role; + return account; +} + +export async function requirePageRead( + page: PageKey, +): Promise { + const account = await requireSession(); + if (!canReadPage(account, page)) { + redirect("/"); + } + return account; +} + +export async function requirePageWrite( + page: PageKey, +): Promise { + const account = await requireSession(); + if (!canWritePage(account, page)) { + redirect("/"); + } + return account; } diff --git a/src/lib/auth/roles.ts b/src/lib/auth/roles.ts index 68b7001..1967a3f 100644 --- a/src/lib/auth/roles.ts +++ b/src/lib/auth/roles.ts @@ -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 function roleToSessionCookieValue(role: AdminRole): string { - return role; +function getSessionSecret(): string { + 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( - value: string | undefined, -): AdminRole | null { +function signUsername(username: string): string { + return createHmac("sha256", getSessionSecret()) + .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 === LEGACY_ADMIN_SESSION_VALUE || value === "admin") { - return "admin"; + // Invalidate old role cookies immediately. + if ( + value === LEGACY_ADMIN_SESSION_VALUE || + value === "admin" || + value === "supervisor" + ) { + return null; } - if (value === "supervisor") { - return "supervisor"; - } - return null; + + const dot = value.lastIndexOf("."); + if (dot <= 0 || dot === value.length - 1) 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 { - return role === "supervisor"; +/** Whether the cookie looks like a valid signed session (for middleware/proxy). */ +export function hasValidSessionToken(value: string | undefined): boolean { + return parseSessionToken(value) != null; } diff --git a/src/lib/auth/session-cookie.ts b/src/lib/auth/session-cookie.ts index abc2c4f..d102efa 100644 --- a/src/lib/auth/session-cookie.ts +++ b/src/lib/auth/session-cookie.ts @@ -1,10 +1,6 @@ import type { NextResponse } from "next/server"; -import { - roleToSessionCookieValue, - type AdminRole, -} from "@/lib/auth/roles"; +import { createSessionToken } from "@/lib/auth/roles"; import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session"; - import { getRequestOrigin } from "@/lib/request-public-url"; /** 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( res: NextResponse, request: Request, - role: AdminRole, + username: string, ) { res.cookies.set( ADMIN_SESSION_COOKIE, - roleToSessionCookieValue(role), + createSessionToken(username), getSessionCookieSetOptions(request), ); } diff --git a/src/lib/dashboard-search-url.ts b/src/lib/dashboard-search-url.ts index 8cc401e..a8cf80a 100644 --- a/src/lib/dashboard-search-url.ts +++ b/src/lib/dashboard-search-url.ts @@ -7,7 +7,8 @@ export type AdminDashboardTab = | "matches" | "matchmaker" | "ledger" - | "analysis"; + | "analysis" + | "logs"; export type DashboardUrlQuery = { tab: AdminDashboardTab; @@ -32,6 +33,10 @@ export type DashboardUrlQuery = { analysisTo?: string | null; /** Analysis tab: comma-separated player ids (`aplayers`). */ 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. */ @@ -42,6 +47,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string { else if (q.tab === "matchmaker") p.set("tab", "matchmaker"); else if (q.tab === "ledger") p.set("tab", "ledger"); 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.participantRaw) p.set("participant", q.participantRaw); 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.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)) { p.set("edit", String(q.editId)); } diff --git a/src/lib/local-date-range.ts b/src/lib/local-date-range.ts new file mode 100644 index 0000000..a966d20 --- /dev/null +++ b/src/lib/local-date-range.ts @@ -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; + } +} diff --git a/src/lib/match-entry-hold-from-ledger.ts b/src/lib/match-entry-hold-from-ledger.ts index bc82b3f..211f622 100644 --- a/src/lib/match-entry-hold-from-ledger.ts +++ b/src/lib/match-entry-hold-from-ledger.ts @@ -30,6 +30,9 @@ export type MatchEntryHoldAndFeePerPlayerCoins = { feePerPlayer: Map; }; +/** 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` * (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; - const { data, error } = await supabase - .from("transactions") - .select("match_id, amount, remarks") - .in("match_id", matchIds); - - if (error) { - return empty; - } - const holdAgg: PerRemarkAgg = { sums: new Map(), counts: new Map(), @@ -62,21 +56,41 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds( counts: new Map(), }; - for (const row of (data ?? []) as Pick< - DbTransaction, - "match_id" | "amount" | "remarks" - >[]) { - 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); + let offset = 0; + for (;;) { + const { data, error } = await supabase + .from("transactions") + .select("match_id, amount, remarks") + .in("match_id", matchIds) + .in("remarks", ["entry_hold", "entry_fee"]) + .order("id", { ascending: true }) + .range(offset, offset + TX_PAGE_SIZE - 1); + + if (error) { + return empty; + } + + 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 { diff --git a/src/lib/ping-analytics.ts b/src/lib/ping-analytics.ts index ac65bba..3929b19 100644 --- a/src/lib/ping-analytics.ts +++ b/src/lib/ping-analytics.ts @@ -2,7 +2,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation"; import type { DbPingReport } from "@/types/database"; -const BAD_PING_THRESHOLD_MS = 120; +const BAD_PING_THRESHOLD_MS = 200; const TOP_ROWS_LIMIT = 20; type PingReportWithUser = DbPingReport & { diff --git a/src/proxy.ts b/src/proxy.ts index a5dcbc9..4e9f14e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,11 +1,13 @@ import { NextResponse } 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 { publicRequestUrl } from "@/lib/request-public-url"; 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) {