account system and syslogs
This commit is contained in:
@@ -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 =
|
||||
|
||||
+108
-12
@@ -1,13 +1,23 @@
|
||||
import { AdminDashboard } from "@/components/admin-dashboard";
|
||||
import { AdminHeader } from "@/components/admin-header";
|
||||
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
||||
import { isReadOnlyRole } from "@/lib/auth/roles";
|
||||
import {
|
||||
canReadPage,
|
||||
canWritePage,
|
||||
tabToPageKey,
|
||||
} from "@/lib/auth/permissions";
|
||||
import { requireSession } from "@/lib/auth/require-session";
|
||||
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||
import {
|
||||
normalizeLedgerDateRange,
|
||||
parseIsoDateOnly,
|
||||
utcLast30DaysDateRange,
|
||||
} from "@/lib/ledger-utc-date-range";
|
||||
import {
|
||||
localDateRangeToUtcIsoBounds,
|
||||
normalizeMatchesDateRange,
|
||||
parseTzOffsetMinutes,
|
||||
} from "@/lib/local-date-range";
|
||||
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
|
||||
import {
|
||||
serializeLedgerGlobalSummary,
|
||||
@@ -51,6 +61,9 @@ import {
|
||||
type LedgerSortKey,
|
||||
type LedgerSortOrder,
|
||||
} from "@/lib/ledger-table-view";
|
||||
import { redirect } from "next/navigation";
|
||||
import { readAuditLog, type AuditLogEntry } from "@/lib/auth/audit-log";
|
||||
import { logPageAccess } from "@/lib/auth/log-page-access";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -82,14 +95,26 @@ export default async function Home({
|
||||
afrom?: string | string[];
|
||||
ato?: string | string[];
|
||||
aplayers?: string | string[];
|
||||
mfrom?: string | string[];
|
||||
mto?: string | string[];
|
||||
mtz?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const role = await requireSession();
|
||||
const readOnly = isReadOnlyRole(role);
|
||||
const account = await requireSession();
|
||||
const canWritePlayers = canWritePage(account, "players");
|
||||
const canWriteLedger = canWritePage(account, "ledger");
|
||||
const pageAccess = {
|
||||
players: canReadPage(account, "players"),
|
||||
matches: canReadPage(account, "matches"),
|
||||
analysis: canReadPage(account, "analysis"),
|
||||
matchmaker: canReadPage(account, "matchmaker"),
|
||||
ledger: canReadPage(account, "ledger"),
|
||||
logs: canReadPage(account, "logs"),
|
||||
};
|
||||
|
||||
const sp = await searchParams;
|
||||
const tabParam = firstSearchParam(sp.tab);
|
||||
const tab: AdminDashboardTab =
|
||||
const requestedTab: AdminDashboardTab =
|
||||
tabParam === "matches"
|
||||
? "matches"
|
||||
: tabParam === "players"
|
||||
@@ -100,7 +125,20 @@ export default async function Home({
|
||||
? "ledger"
|
||||
: tabParam === "analysis"
|
||||
? "analysis"
|
||||
: "dashboard";
|
||||
: tabParam === "logs"
|
||||
? "logs"
|
||||
: "dashboard";
|
||||
const pageKey = tabToPageKey(requestedTab);
|
||||
if (pageKey && !canReadPage(account, pageKey)) {
|
||||
redirect("/");
|
||||
}
|
||||
const tab = requestedTab;
|
||||
const accessPage =
|
||||
tab === "dashboard"
|
||||
? "overview"
|
||||
: tab;
|
||||
await logPageAccess(account, accessPage);
|
||||
|
||||
const highlightId = firstSearchParam(sp.highlight);
|
||||
const participantRaw = firstSearchParam(sp.participant);
|
||||
const editRaw = firstSearchParam(sp.edit);
|
||||
@@ -129,6 +167,10 @@ export default async function Home({
|
||||
let ledgerTo = utcLast30DaysDateRange().to;
|
||||
let analysisFrom = utcLast30DaysDateRange().from;
|
||||
let analysisTo = utcLast30DaysDateRange().to;
|
||||
let matchesFrom = utcLast30DaysDateRange().from;
|
||||
let matchesTo = utcLast30DaysDateRange().to;
|
||||
let matchesTzOffsetMinutes: number | null = null;
|
||||
let matchesRangeExplicit = false;
|
||||
let analysisPlayerIds: number[] = [];
|
||||
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
|
||||
let pingAnalytics: PingAnalyticsResult = {
|
||||
@@ -138,7 +180,7 @@ export default async function Home({
|
||||
totalPlayers: 0,
|
||||
avgPing: null,
|
||||
p95Ping: null,
|
||||
badThresholdMs: 120,
|
||||
badThresholdMs: 200,
|
||||
badReports: 0,
|
||||
badRatePercent: null,
|
||||
},
|
||||
@@ -158,6 +200,8 @@ export default async function Home({
|
||||
let matchmakerSource: MatchmakerLogSource = "processed";
|
||||
let matchmakerContent = "";
|
||||
let matchmakerError: string | null = null;
|
||||
let auditEntries: AuditLogEntry[] = [];
|
||||
let auditError: string | null = null;
|
||||
if (tab === "matchmaker") {
|
||||
const mkRaw = firstSearchParam(sp.mklog);
|
||||
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
||||
@@ -169,6 +213,15 @@ export default async function Home({
|
||||
}
|
||||
}
|
||||
|
||||
if (tab === "logs") {
|
||||
try {
|
||||
auditEntries = await readAuditLog(1000);
|
||||
} catch (err) {
|
||||
auditError =
|
||||
err instanceof Error ? err.message : "Failed to read system logs.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
configError =
|
||||
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||
@@ -185,11 +238,45 @@ export default async function Home({
|
||||
users = (usersRes.data ?? []) as DbUser[];
|
||||
}
|
||||
|
||||
const matchesRes = await supabase
|
||||
const matchesTzRaw = firstSearchParam(sp.mtz);
|
||||
matchesTzOffsetMinutes = parseTzOffsetMinutes(matchesTzRaw);
|
||||
const mfromRaw = firstSearchParam(sp.mfrom);
|
||||
const mtoRaw = firstSearchParam(sp.mto);
|
||||
matchesRangeExplicit = Boolean(
|
||||
mfromRaw &&
|
||||
mtoRaw &&
|
||||
parseIsoDateOnly(mfromRaw.trim()) &&
|
||||
parseIsoDateOnly(mtoRaw.trim()),
|
||||
);
|
||||
const matchesRange = normalizeMatchesDateRange(
|
||||
mfromRaw,
|
||||
mtoRaw,
|
||||
matchesTzOffsetMinutes,
|
||||
);
|
||||
matchesFrom = matchesRange.from;
|
||||
matchesTo = matchesRange.to;
|
||||
|
||||
let matchesQuery = supabase
|
||||
.from("matches")
|
||||
.select("*")
|
||||
.order("id", { ascending: false })
|
||||
.limit(500);
|
||||
.order("id", { ascending: false });
|
||||
|
||||
if (tab === "matches") {
|
||||
const tz = matchesTzOffsetMinutes ?? 0;
|
||||
const { rangeStartIso, rangeEndIso } = localDateRangeToUtcIsoBounds(
|
||||
matchesFrom,
|
||||
matchesTo,
|
||||
tz,
|
||||
);
|
||||
matchesQuery = matchesQuery
|
||||
.gte("created_at", rangeStartIso)
|
||||
.lte("created_at", rangeEndIso)
|
||||
.limit(10000);
|
||||
} else {
|
||||
matchesQuery = matchesQuery.limit(500);
|
||||
}
|
||||
|
||||
const matchesRes = await matchesQuery;
|
||||
|
||||
if (matchesRes.error) {
|
||||
matchesError = matchesRes.error.message;
|
||||
@@ -309,13 +396,13 @@ export default async function Home({
|
||||
}
|
||||
|
||||
let editUser: DbUser | null = null;
|
||||
if (!readOnly && Number.isInteger(editIdNum) && editIdNum >= 1) {
|
||||
if (canWritePlayers && Number.isInteger(editIdNum) && editIdNum >= 1) {
|
||||
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user