account system and syslogs
This commit is contained in:
@@ -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,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)}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user