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
+434
View File
@@ -0,0 +1,434 @@
"use client";
import { useState } from "react";
import {
createAdminAccount,
deleteAdminAccount,
updateAdminAccount,
} from "@/app/actions/account-actions";
import {
PAGE_KEYS,
type PageKey,
type PagePermissions,
type SessionAccount,
} from "@/lib/auth/permissions";
const PAGE_LABELS: Record<PageKey, string> = {
players: "Players",
matches: "Matches",
analysis: "Analysis",
matchmaker: "Matchmaker",
ledger: "Ledger",
logs: "System logs",
};
type Props = {
accounts: SessionAccount[];
accountError: string | null;
accountOk: string | null;
};
function errorMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "missingUsername":
return "Username is required.";
case "missingPassword":
return "Password is required.";
case "duplicate":
return "That username is already taken.";
case "notFound":
return "Account not found.";
case "cannotDeleteAdmin":
return "The admin account cannot be deleted.";
default:
return "Could not update accounts. Try again.";
}
}
function okMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "created":
return "Account created.";
case "updated":
return "Account updated.";
case "deleted":
return "Account deleted.";
default:
return "Done.";
}
}
function PermissionCheckboxes({
idPrefix,
permissions,
onChange,
}: {
idPrefix: string;
permissions: PagePermissions;
onChange: (next: PagePermissions) => void;
}) {
function setPerm(page: PageKey, field: "read" | "write", value: boolean) {
const next = { ...permissions, [page]: { ...permissions[page] } };
if (page === "logs") {
next[page] = { read: field === "read" ? value : next[page].read, write: false };
onChange(next);
return;
}
if (field === "write") {
next[page] = { write: value, read: value ? true : next[page].read };
} else {
next[page] = {
read: value,
write: value ? next[page].write : false,
};
}
onChange(next);
}
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[320px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
<th className="py-2 pr-3 font-medium">Page</th>
<th className="py-2 px-2 font-medium">Read</th>
<th className="py-2 px-2 font-medium">Write</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-zinc-100 dark:border-zinc-800">
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
Overview
</td>
<td className="py-2 px-2 text-zinc-500" colSpan={2}>
Always enabled
</td>
</tr>
{PAGE_KEYS.map((page) => (
<tr
key={page}
className="border-b border-zinc-100 dark:border-zinc-800"
>
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
{PAGE_LABELS[page]}
</td>
<td className="py-2 px-2">
<input
type="checkbox"
id={`${idPrefix}_${page}_read`}
name={`perm_${page}_read`}
value="1"
checked={permissions[page].read}
onChange={(e) => setPerm(page, "read", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
</td>
<td className="py-2 px-2">
{page === "logs" ? (
<span className="text-xs text-zinc-500 dark:text-zinc-400">
</span>
) : (
<input
type="checkbox"
id={`${idPrefix}_${page}_write`}
name={`perm_${page}_write`}
value="1"
checked={permissions[page].write}
onChange={(e) => setPerm(page, "write", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function permissionSummary(account: SessionAccount): string {
if (account.isAdmin) return "Full access";
const parts: string[] = ["Overview"];
for (const page of PAGE_KEYS) {
const p = account.permissions[page];
if (p.write) parts.push(`${PAGE_LABELS[page]} (rw)`);
else if (p.read) parts.push(`${PAGE_LABELS[page]} (r)`);
}
return parts.join(", ");
}
function AccountEditForm({
account,
onClose,
}: {
account: SessionAccount;
onClose: () => void;
}) {
const [username, setUsername] = useState(account.username);
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState(account.permissions);
return (
<form
action={updateAdminAccount}
className="mt-3 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-950/60"
>
<input type="hidden" name="id" value={account.id} />
<input
type="hidden"
name="isAdmin"
value={account.isAdmin ? "1" : "0"}
/>
{account.isAdmin ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Admin account username is fixed; only the password can be changed.
</p>
) : (
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-username-${account.id}`}
>
Username
</label>
<input
id={`edit-username-${account.id}`}
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
)}
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-password-${account.id}`}
>
{account.isAdmin ? "New password" : "New password (optional)"}
</label>
<input
id={`edit-password-${account.id}`}
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={account.isAdmin}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
{!account.isAdmin ? (
<PermissionCheckboxes
idPrefix={`edit_${account.id}`}
permissions={permissions}
onChange={setPermissions}
/>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
</div>
</form>
);
}
function AddAccountForm() {
const [permissions, setPermissions] = useState<PagePermissions>({
players: { read: false, write: false },
matches: { read: false, write: false },
analysis: { read: false, write: false },
matchmaker: { read: false, write: false },
ledger: { read: false, write: false },
logs: { read: false, write: false },
});
return (
<form
action={createAdminAccount}
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Add account
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-username"
>
Username
</label>
<input
id="new-account-username"
name="username"
required
autoComplete="off"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-password"
>
Password
</label>
<input
id="new-account-password"
name="password"
type="password"
required
autoComplete="new-password"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
</div>
<PermissionCheckboxes
idPrefix="new"
permissions={permissions}
onChange={setPermissions}
/>
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Create account
</button>
</form>
);
}
export function AdminAccountsEditor({
accounts,
accountError,
accountOk,
}: Props) {
const [editingId, setEditingId] = useState<string | null>(null);
const err = errorMessage(accountError);
const ok = okMessage(accountOk);
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Accounts
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Manage panel logins and per-page read/write access. Only the admin
account can manage other accounts.
</p>
</div>
{err ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{err}
</div>
) : null}
{ok ? (
<div
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
role="status"
>
{ok}
</div>
) : null}
<section className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing accounts
</h3>
<ul className="space-y-3">
{accounts.map((account) => (
<li
key={account.id}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-50">
{account.username}
{account.isAdmin ? (
<span className="ml-2 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
Admin
</span>
) : null}
</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{permissionSummary(account)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() =>
setEditingId((id) =>
id === account.id ? null : account.id,
)
}
className="rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{editingId === account.id ? "Close" : "Edit"}
</button>
{!account.isAdmin ? (
<form action={deleteAdminAccount}>
<input type="hidden" name="id" value={account.id} />
<button
type="submit"
className="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (
!window.confirm(
`Delete account “${account.username}”?`,
)
) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
) : null}
</div>
</div>
{editingId === account.id ? (
<AccountEditForm
account={account}
onClose={() => setEditingId(null)}
/>
) : null}
</li>
))}
</ul>
</section>
<AddAccountForm />
</div>
);
}
+272 -85
View File
@@ -4,7 +4,10 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { 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,84 +467,112 @@ export function AdminDashboard({
>
Overview
</Link>
<Link
href={buildDashboardHref({
tab: "players",
highlightId,
participantRaw,
})}
className={tabClass(tab === "players")}
scroll={false}
>
Players (
{totalUsersLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${users.length.toLocaleString("en-US")}`
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matches")}
scroll={false}
>
Matches (
{totalMatchesLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${matches.length.toLocaleString("en-US")}`
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
<Link
href={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matchmaker")}
scroll={false}
>
Matchmaker
</Link>
<Link
href={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
})}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</Link>
<Link
href="/settings"
className={tabClass(false)}
scroll={false}
>
Settings
</Link>
{pageAccess.players ? (
<Link
href={buildDashboardHref({
tab: "players",
highlightId,
participantRaw,
})}
className={tabClass(tab === "players")}
scroll={false}
>
Players (
{totalUsersLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${users.length.toLocaleString("en-US")}`
: null}
)
</Link>
) : null}
{pageAccess.matches ? (
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
})}
className={tabClass(tab === "matches")}
scroll={false}
>
Matches (
{totalMatchesLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${matches.length.toLocaleString("en-US")}`
: null}
)
</Link>
) : null}
{pageAccess.analysis ? (
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
) : null}
{pageAccess.matchmaker ? (
<Link
href={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matchmaker")}
scroll={false}
>
Matchmaker
</Link>
) : null}
{pageAccess.ledger ? (
<Link
href={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
})}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</Link>
) : null}
{pageAccess.logs ? (
<Link
href={buildDashboardHref({
tab: "logs",
highlightId,
participantRaw,
})}
className={tabClass(tab === "logs")}
scroll={false}
>
System logs
</Link>
) : null}
{isAdmin ? (
<Link
href="/settings"
className={tabClass(false)}
scroll={false}
>
Settings
</Link>
) : null}
</div>
<div 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)}
+10 -5
View File
@@ -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>
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useMemo, useState } from "react";
import { LocalTimestamp } from "@/components/local-timestamp";
import type { AuditLogEntry } from "@/lib/auth/audit-log";
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
type Props = {
entries: AuditLogEntry[];
error: string | null;
};
const ACTION_LABELS: Record<string, string> = {
access: "Page access",
"auth.login": "Sign in",
"auth.logout": "Sign out",
"players.update_cc_rc": "Update player CC/RC",
"ledger.system_supply": "System supply",
"settings.update": "Update setting",
"settings.insert": "Create setting",
"settings.delete": "Delete setting",
"accounts.create": "Create account",
"accounts.update": "Update account",
"accounts.delete": "Delete account",
};
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action;
}
function isAuthAction(action: string): boolean {
return action === "auth.login" || action === "auth.logout";
}
function isAccessAction(action: string): boolean {
return action === "access";
}
/** Prefer top-level fields; fall back to details for older/partial entries. */
function entryIp(entry: AuditLogEntry): string | null {
if (entry.ip) return entry.ip;
const d = entry.details?.ip;
return typeof d === "string" ? d : null;
}
function entryDevice(entry: AuditLogEntry): string | null {
if (entry.device) return entry.device;
const d = entry.details?.device;
return typeof d === "string" ? d : null;
}
function entryUserAgent(entry: AuditLogEntry): string | null {
if (entry.userAgent) return entry.userAgent;
const d = entry.details?.userAgent;
return typeof d === "string" ? d : null;
}
function entryPage(entry: AuditLogEntry): string | null {
const d = entry.details?.page;
return typeof d === "string" ? d : null;
}
function matchesQuery(entry: AuditLogEntry, q: string): boolean {
if (!q) return true;
const hay = [
entry.username,
entry.action,
actionLabel(entry.action),
entry.summary,
entryIp(entry) ?? "",
entryDevice(entry) ?? "",
entryUserAgent(entry) ?? "",
entryPage(entry) ?? "",
entryPage(entry) ? pageAccessLabel(entryPage(entry)!) : "",
entry.details ? JSON.stringify(entry.details) : "",
]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
export function AdminSystemLogs({ entries, error }: Props) {
const [query, setQuery] = useState("");
const [actionFilter, setActionFilter] = useState("");
const [accountFilter, setAccountFilter] = useState("");
const actionOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) set.add(e.action);
for (const known of Object.keys(ACTION_LABELS)) set.add(known);
return [...set].sort((a, b) =>
actionLabel(a).localeCompare(actionLabel(b)),
);
}, [entries]);
const accountOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) {
if (e.username) set.add(e.username);
}
return [...set].sort((a, b) => a.localeCompare(b));
}, [entries]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return entries.filter((entry) => {
if (actionFilter && entry.action !== actionFilter) return false;
if (accountFilter && entry.username !== accountFilter) return false;
return matchesQuery(entry, q);
});
}, [entries, query, actionFilter, accountFilter]);
const filtersActive = Boolean(query.trim() || actionFilter || accountFilter);
return (
<section className="mx-auto w-full max-w-[1400px] space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
System logs
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Panel actions, page access, and who performed them including login
IP and device. Newest first (up to 1,000 entries).
</p>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-[200px] flex-1">
<label
htmlFor="logs-search"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Search
</label>
<input
id="logs-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search summary, IP, device…"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
<div className="w-full sm:w-52">
<label
htmlFor="logs-action"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Action
</label>
<select
id="logs-action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
>
<option value="">All actions</option>
{actionOptions.map((action) => (
<option key={action} value={action}>
{actionLabel(action)}
</option>
))}
</select>
</div>
<div className="w-full sm:w-44">
<label
htmlFor="logs-account"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Account
</label>
<select
id="logs-account"
value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
>
<option value="">All accounts</option>
{accountOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
{filtersActive ? (
<button
type="button"
onClick={() => {
setQuery("");
setActionFilter("");
setAccountFilter("");
}}
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Clear
</button>
) : null}
</div>
{error ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{error}
</div>
) : null}
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filtered.length.toLocaleString("en-US")} of{" "}
{entries.length.toLocaleString("en-US")}
{filtersActive ? " (filtered)" : ""}
</p>
{entries.length === 0 && !error ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No actions logged yet.
</p>
) : filtered.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No log entries match these filters.
</p>
) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="w-full min-w-[900px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400">
<th className="px-4 py-3 font-medium">When</th>
<th className="px-4 py-3 font-medium">Who</th>
<th className="px-4 py-3 font-medium">Action</th>
<th className="px-4 py-3 font-medium">Details</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Device</th>
</tr>
</thead>
<tbody>
{filtered.map((entry) => {
const ip = entryIp(entry);
const device = entryDevice(entry);
const userAgent = entryUserAgent(entry);
const page = entryPage(entry);
const showClientMeta =
isAuthAction(entry.action) ||
isAccessAction(entry.action) ||
Boolean(ip || device);
return (
<tr
key={entry.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-zinc-700 dark:text-zinc-300">
<LocalTimestamp value={entry.at} />
</td>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-50">
{entry.username}
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
<span className="font-medium">
{actionLabel(entry.action)}
</span>
<span className="mt-0.5 block font-mono text-xs text-zinc-500 dark:text-zinc-400">
{entry.action}
{page ? ` · ${page}` : ""}
</span>
</td>
<td className="max-w-md px-4 py-3 text-zinc-700 dark:text-zinc-300">
<p>{entry.summary}</p>
{entry.details &&
Object.keys(entry.details).length > 0 &&
!isAuthAction(entry.action) &&
!isAccessAction(entry.action) ? (
<pre className="mt-1 max-h-24 overflow-auto rounded bg-zinc-50 px-2 py-1 font-mono text-[11px] text-zinc-600 dark:bg-zinc-950 dark:text-zinc-400">
{JSON.stringify(entry.details, null, 0)}
</pre>
) : null}
{(isAuthAction(entry.action) ||
isAccessAction(entry.action)) &&
userAgent ? (
<p
className="mt-1 break-all font-mono text-[11px] text-zinc-500 dark:text-zinc-400"
title={userAgent}
>
{userAgent}
</p>
) : null}
</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{showClientMeta ? (ip ?? "—") : "—"}
</td>
<td className="max-w-[220px] px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{showClientMeta ? (device ?? "—") : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { formatLocalTimestamp } from "@/lib/local-date-range";
type Props = {
value: string | null;
className?: string;
};
/**
* Renders timestamps in the browser's local timezone.
* Avoids SSR/client hydration mismatch by filling in after mount.
*/
export function LocalTimestamp({ value, className }: Props) {
const [label, setLabel] = useState("—");
useEffect(() => {
setLabel(formatLocalTimestamp(value));
}, [value]);
if (!value) {
return <span className={className}></span>;
}
return (
<time dateTime={value} className={className} suppressHydrationWarning>
{label}
</time>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
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. */