account system and syslogs
This commit is contained in:
+11
-4
@@ -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: <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).
|
||||
# Example: https://kickkings.playpoolstudios.com
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+107
-11
@@ -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"
|
||||
: 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 (
|
||||
<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 ? (
|
||||
<div className="px-6 pt-8">
|
||||
<div
|
||||
@@ -352,13 +439,22 @@ export default async function Home({
|
||||
ledgerFrom={ledgerFrom}
|
||||
ledgerTo={ledgerTo}
|
||||
supplyError={supplyError}
|
||||
matchesFrom={matchesFrom}
|
||||
matchesTo={matchesTo}
|
||||
matchesTzOffsetMinutes={matchesTzOffsetMinutes}
|
||||
matchesRangeExplicit={matchesRangeExplicit}
|
||||
analysisFrom={analysisFrom}
|
||||
analysisTo={analysisTo}
|
||||
analysisPlayerIds={analysisPlayerIds}
|
||||
matchAnalysis={matchAnalysis}
|
||||
pingAnalytics={pingAnalytics}
|
||||
geolocationAnalytics={geolocationAnalytics}
|
||||
readOnly={readOnly}
|
||||
auditEntries={auditEntries}
|
||||
auditError={auditError}
|
||||
pageAccess={pageAccess}
|
||||
canWritePlayers={canWritePlayers}
|
||||
canWriteLedger={canWriteLedger}
|
||||
isAdmin={account.isAdmin}
|
||||
/>
|
||||
{editUser ? (
|
||||
<EditUserCcRcOverlay
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireSession } from "@/lib/auth/require-session";
|
||||
import { isReadOnlyRole } from "@/lib/auth/roles";
|
||||
import { requireAdmin } from "@/lib/auth/require-session";
|
||||
import { listSessionAccounts } from "@/lib/auth/accounts-store";
|
||||
import { logPageAccess } from "@/lib/auth/log-page-access";
|
||||
import { AdminHeader } from "@/components/admin-header";
|
||||
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
||||
import { AdminAccountsEditor } from "@/components/admin-accounts-editor";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
import type { DbSetting } from "@/types/database";
|
||||
|
||||
@@ -28,14 +30,20 @@ export default async function SettingsPage({
|
||||
searchParams: Promise<{
|
||||
saveError?: string | string[];
|
||||
addError?: string | string[];
|
||||
accountError?: string | string[];
|
||||
accountOk?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||
<AdminHeader readOnly={readOnly} />
|
||||
<main className="flex-1 px-6 py-8">
|
||||
<div className="mx-auto mb-8 max-w-[900px]">
|
||||
<AdminHeader username={account.username} isAdmin={account.isAdmin} />
|
||||
<main className="flex-1 space-y-12 px-6 py-8">
|
||||
<div className="mx-auto max-w-[900px]">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Global settings
|
||||
Settings
|
||||
</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">
|
||||
Key/value rows in the{" "}
|
||||
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||
@@ -93,7 +116,7 @@ export default async function SettingsPage({
|
||||
rows={rows}
|
||||
saveError={saveError}
|
||||
addError={addError}
|
||||
readOnly={readOnly}
|
||||
readOnly={false}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<LeaderboardSortKey>("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,6 +467,7 @@ export function AdminDashboard({
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
{pageAccess.players ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "players",
|
||||
@@ -423,11 +484,16 @@ export function AdminDashboard({
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matches ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matches",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
matchesFrom,
|
||||
matchesTo,
|
||||
matchesTzOffsetMinutes,
|
||||
})}
|
||||
className={tabClass(tab === "matches")}
|
||||
scroll={false}
|
||||
@@ -439,6 +505,8 @@ export function AdminDashboard({
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.analysis ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "analysis",
|
||||
@@ -456,6 +524,8 @@ export function AdminDashboard({
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matchmaker ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matchmaker",
|
||||
@@ -467,6 +537,8 @@ export function AdminDashboard({
|
||||
>
|
||||
Matchmaker
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.ledger ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "ledger",
|
||||
@@ -478,6 +550,21 @@ export function AdminDashboard({
|
||||
>
|
||||
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)}
|
||||
@@ -485,6 +572,7 @@ export function AdminDashboard({
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-[1400px]">
|
||||
@@ -740,7 +828,7 @@ export function AdminDashboard({
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!readOnly ? (
|
||||
{canWritePlayers ? (
|
||||
<Link
|
||||
href={editHref(u)}
|
||||
scroll={false}
|
||||
@@ -839,10 +927,102 @@ export function AdminDashboard({
|
||||
ledgerFrom={ledgerFrom}
|
||||
ledgerTo={ledgerTo}
|
||||
supplyError={supplyError}
|
||||
readOnly={readOnly}
|
||||
readOnly={!canWriteLedger}
|
||||
/>
|
||||
) : tab === "logs" ? (
|
||||
<AdminSystemLogs entries={auditEntries} error={auditError} />
|
||||
) : (
|
||||
<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 ? (
|
||||
<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"
|
||||
@@ -864,7 +1044,14 @@ export function AdminDashboard({
|
||||
{filteredMatches.length} of {matches.length})
|
||||
</span>
|
||||
<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"
|
||||
scroll={false}
|
||||
>
|
||||
@@ -900,7 +1087,7 @@ export function AdminDashboard({
|
||||
{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">
|
||||
{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={<LocalTimestamp value={m.created_at} />}
|
||||
entryFeeCoins={m.entryFeePerPlayerCoins}
|
||||
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
||||
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
||||
|
||||
@@ -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) {
|
||||
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Kick Kings Admin Dashboard
|
||||
</h1>
|
||||
{readOnly ? (
|
||||
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
Read-only access
|
||||
{username ? (
|
||||
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Signed in as{" "}
|
||||
<span className="font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{username}
|
||||
</span>
|
||||
{isAdmin ? " · admin" : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SessionAccount | null> {
|
||||
return verifyAccountCredentials(username, password);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<AdminRole | null> {
|
||||
export async function getSessionAccount(): Promise<SessionAccount | null> {
|
||||
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> {
|
||||
const role = await getSessionRole();
|
||||
if (!role) {
|
||||
export async function requireSession(): Promise<SessionAccount> {
|
||||
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<AdminRole> {
|
||||
const role = await requireSession();
|
||||
if (isReadOnlyRole(role)) {
|
||||
export async function requireAdmin(): Promise<SessionAccount> {
|
||||
const account = await requireSession();
|
||||
if (!account.isAdmin) {
|
||||
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
@@ -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";
|
||||
}
|
||||
if (value === "supervisor") {
|
||||
return "supervisor";
|
||||
}
|
||||
// Invalidate old role cookies immediately.
|
||||
if (
|
||||
value === LEGACY_ADMIN_SESSION_VALUE ||
|
||||
value === "admin" ||
|
||||
value === "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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ export type MatchEntryHoldAndFeePerPlayerCoins = {
|
||||
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`
|
||||
* (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,10 +56,26 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
||||
counts: new Map(),
|
||||
};
|
||||
|
||||
for (const row of (data ?? []) as Pick<
|
||||
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;
|
||||
@@ -79,6 +89,10 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
||||
else bumpAgg(feeAgg, mid, a);
|
||||
}
|
||||
|
||||
if (batch.length < TX_PAGE_SIZE) break;
|
||||
offset += TX_PAGE_SIZE;
|
||||
}
|
||||
|
||||
return {
|
||||
holdPerPlayer: avgPerMatch(holdAgg),
|
||||
feePerPlayer: avgPerMatch(feeAgg),
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user