account system and syslogs

This commit is contained in:
2026-07-26 18:31:43 +00:00
parent 540f6e0af1
commit 6f7af751e1
37 changed files with 2475 additions and 258 deletions
+161
View File
@@ -0,0 +1,161 @@
"use server";
import { redirect } from "next/navigation";
import {
createAccount,
deleteAccount,
findAccountById,
updateAccount,
} from "@/lib/auth/accounts-store";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
normalizePagePermissions,
PAGE_KEYS,
type PageKey,
type PagePermissions,
} from "@/lib/auth/permissions";
import { requireAdmin } from "@/lib/auth/require-session";
function parsePermissionsFromForm(formData: FormData): PagePermissions {
const raw: Partial<Record<PageKey, { read: boolean; write: boolean }>> = {};
for (const key of PAGE_KEYS) {
raw[key] = {
read: formData.get(`perm_${key}_read`) === "1",
write: formData.get(`perm_${key}_write`) === "1",
};
}
return normalizePagePermissions(raw);
}
export async function createAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
const permissions = parsePermissionsFromForm(formData);
if (!username) {
redirect("/settings?accountError=missingUsername");
}
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await createAccount({ username, password, permissions });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.create",
summary: `Created account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=created");
}
export async function updateAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const isAdminRow = formData.get("isAdmin") === "1";
const password = String(formData.get("password") ?? "");
const usernameRaw = String(formData.get("username") ?? "");
if (isAdminRow) {
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await updateAccount({ id, password });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Changed password for admin “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
passwordChanged: true,
},
});
redirect("/settings?accountOk=updated");
}
const permissions = parsePermissionsFromForm(formData);
const result = await updateAccount({
id,
username: usernameRaw,
password: password || undefined,
permissions,
});
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Updated account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
previousUsername: existing.username,
passwordChanged: Boolean(password),
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=updated");
}
export async function deleteAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const result = await deleteAccount(id);
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.delete",
summary: `Deleted account “${existing.username}`,
details: {
targetId: existing.id,
targetUsername: existing.username,
},
});
redirect("/settings?accountOk=deleted");
}
+11 -2
View File
@@ -1,7 +1,8 @@
"use server";
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));
}
+29 -4
View File
@@ -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");
}
+11 -2
View File
@@ -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));
}
+42 -7
View File
@@ -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;
}
+25
View File
@@ -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,
+7 -3
View File
@@ -1,4 +1,5 @@
import { getSessionRole } from "@/lib/auth/require-session";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import {
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 });
}
+4 -3
View File
@@ -1,12 +1,13 @@
import { getSessionRole } from "@/lib/auth/require-session";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
import { 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 },
+5 -3
View File
@@ -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">
+12 -1
View File
@@ -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 (
+4 -2
View File
@@ -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
View File
@@ -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
+32 -9
View File
@@ -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>