account system and syslogs
This commit is contained in:
+11
-4
@@ -1,10 +1,17 @@
|
|||||||
# Admin panel login (server only; required in production)
|
# Admin panel bootstrap (server only). Used once to create the admin account
|
||||||
|
# in data/admin-accounts.json. After bootstrap, change the password in Settings.
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=change-me-to-a-strong-password
|
ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||||
|
|
||||||
# Read-only supervisor login (server only; can view all data but cannot modify)
|
# Optional: HMAC secret for signed session cookies. Defaults to a value derived
|
||||||
SUPERVISOR_USERNAME=supervisor
|
# from ADMIN_PASSWORD when unset.
|
||||||
SUPERVISOR_PASSWORD=change-me-to-a-strong-password
|
# ADMIN_SESSION_SECRET=
|
||||||
|
|
||||||
|
# Optional: absolute path for the accounts JSON file (default: <cwd>/data/admin-accounts.json)
|
||||||
|
# ADMIN_ACCOUNTS_PATH=
|
||||||
|
|
||||||
|
# Optional: absolute path for the panel audit log (default: <cwd>/data/admin-audit.jsonl)
|
||||||
|
# ADMIN_AUDIT_LOG_PATH=
|
||||||
|
|
||||||
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
|
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
|
||||||
# Example: https://kickkings.playpoolstudios.com
|
# Example: https://kickkings.playpoolstudios.com
|
||||||
|
|||||||
@@ -39,3 +39,8 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# admin panel accounts (passwords hashed; still keep private)
|
||||||
|
/data/admin-accounts.json
|
||||||
|
/data/admin-audit.jsonl
|
||||||
|
/data/*.tmp
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
createAccount,
|
||||||
|
deleteAccount,
|
||||||
|
findAccountById,
|
||||||
|
updateAccount,
|
||||||
|
} from "@/lib/auth/accounts-store";
|
||||||
|
import { appendAuditLog } from "@/lib/auth/audit-log";
|
||||||
|
import {
|
||||||
|
normalizePagePermissions,
|
||||||
|
PAGE_KEYS,
|
||||||
|
type PageKey,
|
||||||
|
type PagePermissions,
|
||||||
|
} from "@/lib/auth/permissions";
|
||||||
|
import { requireAdmin } from "@/lib/auth/require-session";
|
||||||
|
|
||||||
|
function parsePermissionsFromForm(formData: FormData): PagePermissions {
|
||||||
|
const raw: Partial<Record<PageKey, { read: boolean; write: boolean }>> = {};
|
||||||
|
for (const key of PAGE_KEYS) {
|
||||||
|
raw[key] = {
|
||||||
|
read: formData.get(`perm_${key}_read`) === "1",
|
||||||
|
write: formData.get(`perm_${key}_write`) === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return normalizePagePermissions(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminAccount(formData: FormData) {
|
||||||
|
const actor = await requireAdmin();
|
||||||
|
|
||||||
|
const username = String(formData.get("username") ?? "").trim();
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
const permissions = parsePermissionsFromForm(formData);
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
redirect("/settings?accountError=missingUsername");
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
redirect("/settings?accountError=missingPassword");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createAccount({ username, password, permissions });
|
||||||
|
if (!result.ok) {
|
||||||
|
redirect(`/settings?accountError=${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "accounts.create",
|
||||||
|
summary: `Created account “${result.account.username}”`,
|
||||||
|
details: {
|
||||||
|
targetId: result.account.id,
|
||||||
|
targetUsername: result.account.username,
|
||||||
|
permissions: result.account.permissions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect("/settings?accountOk=created");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminAccount(formData: FormData) {
|
||||||
|
const actor = await requireAdmin();
|
||||||
|
|
||||||
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
|
if (!id) {
|
||||||
|
redirect("/settings?accountError=notFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await findAccountById(id);
|
||||||
|
if (!existing) {
|
||||||
|
redirect("/settings?accountError=notFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdminRow = formData.get("isAdmin") === "1";
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
const usernameRaw = String(formData.get("username") ?? "");
|
||||||
|
|
||||||
|
if (isAdminRow) {
|
||||||
|
if (!password) {
|
||||||
|
redirect("/settings?accountError=missingPassword");
|
||||||
|
}
|
||||||
|
const result = await updateAccount({ id, password });
|
||||||
|
if (!result.ok) {
|
||||||
|
redirect(`/settings?accountError=${result.error}`);
|
||||||
|
}
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "accounts.update",
|
||||||
|
summary: `Changed password for admin “${result.account.username}”`,
|
||||||
|
details: {
|
||||||
|
targetId: result.account.id,
|
||||||
|
targetUsername: result.account.username,
|
||||||
|
passwordChanged: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
redirect("/settings?accountOk=updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = parsePermissionsFromForm(formData);
|
||||||
|
const result = await updateAccount({
|
||||||
|
id,
|
||||||
|
username: usernameRaw,
|
||||||
|
password: password || undefined,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
redirect(`/settings?accountError=${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "accounts.update",
|
||||||
|
summary: `Updated account “${result.account.username}”`,
|
||||||
|
details: {
|
||||||
|
targetId: result.account.id,
|
||||||
|
targetUsername: result.account.username,
|
||||||
|
previousUsername: existing.username,
|
||||||
|
passwordChanged: Boolean(password),
|
||||||
|
permissions: result.account.permissions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect("/settings?accountOk=updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminAccount(formData: FormData) {
|
||||||
|
const actor = await requireAdmin();
|
||||||
|
|
||||||
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
|
if (!id) {
|
||||||
|
redirect("/settings?accountError=notFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await findAccountById(id);
|
||||||
|
if (!existing) {
|
||||||
|
redirect("/settings?accountError=notFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteAccount(id);
|
||||||
|
if (!result.ok) {
|
||||||
|
redirect(`/settings?accountError=${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "accounts.delete",
|
||||||
|
summary: `Deleted account “${existing.username}”`,
|
||||||
|
details: {
|
||||||
|
targetId: existing.id,
|
||||||
|
targetUsername: existing.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect("/settings?accountOk=deleted");
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
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 { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc";
|
||||||
import {
|
import {
|
||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
@@ -24,7 +25,7 @@ function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function addSystemSupply(formData: FormData) {
|
export async function addSystemSupply(formData: FormData) {
|
||||||
await requireWriteAccess();
|
const actor = await requirePageWrite("ledger");
|
||||||
|
|
||||||
const tabRaw = String(formData.get("tab") ?? "");
|
const tabRaw = String(formData.get("tab") ?? "");
|
||||||
const tab: AdminDashboardTab =
|
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));
|
redirect(buildDashboardHref(base));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
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 { parseStoredCoins } from "@/lib/coins-rc";
|
||||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ function normalizeSettingValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSetting(formData: FormData) {
|
export async function updateSetting(formData: FormData) {
|
||||||
await requireWriteAccess();
|
const actor = await requireAdmin();
|
||||||
|
|
||||||
const key = String(formData.get("key") ?? "").trim();
|
const key = String(formData.get("key") ?? "").trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -63,11 +64,19 @@ export async function updateSetting(formData: FormData) {
|
|||||||
redirect("/settings?saveError=1");
|
redirect("/settings?saveError=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "settings.update",
|
||||||
|
summary: `Updated setting “${key}”`,
|
||||||
|
details: { key, value },
|
||||||
|
});
|
||||||
|
|
||||||
redirect("/settings");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertSetting(formData: FormData) {
|
export async function insertSetting(formData: FormData) {
|
||||||
await requireWriteAccess();
|
const actor = await requireAdmin();
|
||||||
|
|
||||||
const key = String(formData.get("newKey") ?? "").trim();
|
const key = String(formData.get("newKey") ?? "").trim();
|
||||||
const value = normalizeValue(formData.get("newValue"));
|
const value = normalizeValue(formData.get("newValue"));
|
||||||
@@ -91,11 +100,19 @@ export async function insertSetting(formData: FormData) {
|
|||||||
redirect(`/settings?addError=${code}`);
|
redirect(`/settings?addError=${code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "settings.insert",
|
||||||
|
summary: `Created setting “${key}”`,
|
||||||
|
details: { key, value },
|
||||||
|
});
|
||||||
|
|
||||||
redirect("/settings");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSetting(formData: FormData) {
|
export async function deleteSetting(formData: FormData) {
|
||||||
await requireWriteAccess();
|
const actor = await requireAdmin();
|
||||||
|
|
||||||
const key = String(formData.get("key") ?? "").trim();
|
const key = String(formData.get("key") ?? "").trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -112,5 +129,13 @@ export async function deleteSetting(formData: FormData) {
|
|||||||
redirect("/settings?saveError=1");
|
redirect("/settings?saveError=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "settings.delete",
|
||||||
|
summary: `Deleted setting “${key}”`,
|
||||||
|
details: { key },
|
||||||
|
});
|
||||||
|
|
||||||
redirect("/settings");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
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 {
|
import {
|
||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
type AdminDashboardTab,
|
type AdminDashboardTab,
|
||||||
@@ -18,7 +19,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserCcRc(formData: FormData) {
|
export async function updateUserCcRc(formData: FormData) {
|
||||||
await requireWriteAccess();
|
const actor = await requirePageWrite("players");
|
||||||
|
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const tabRaw = String(formData.get("tab") ?? "");
|
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));
|
redirect(buildDashboardHref(base));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { getClientIp } from "@/lib/auth/client-ip";
|
||||||
import { verifyCredentials } from "@/lib/auth/credentials";
|
import { verifyCredentials } from "@/lib/auth/credentials";
|
||||||
import {
|
import { createSessionToken } from "@/lib/auth/roles";
|
||||||
roleToSessionCookieValue,
|
|
||||||
} from "@/lib/auth/roles";
|
|
||||||
import {
|
import {
|
||||||
checkLoginRateLimit,
|
checkLoginRateLimit,
|
||||||
clearLoginAttempts,
|
clearLoginAttempts,
|
||||||
@@ -28,6 +31,9 @@ function invalidJson() {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const contentType = request.headers.get("content-type") ?? "";
|
const contentType = request.headers.get("content-type") ?? "";
|
||||||
const clientIp = getClientIp(request);
|
const clientIp = getClientIp(request);
|
||||||
|
const { device, userAgent } = parseDeviceFromUserAgent(
|
||||||
|
getRequestUserAgent(request),
|
||||||
|
);
|
||||||
|
|
||||||
const rateLimit = checkLoginRateLimit(clientIp);
|
const rateLimit = checkLoginRateLimit(clientIp);
|
||||||
if (!rateLimit.allowed) {
|
if (!rateLimit.allowed) {
|
||||||
@@ -57,8 +63,22 @@ export async function POST(request: Request) {
|
|||||||
password = String(formData.get("password") ?? "");
|
password = String(formData.get("password") ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = verifyCredentials(username, password);
|
let account;
|
||||||
if (!role) {
|
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);
|
recordFailedLogin(clientIp);
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
return invalidJson();
|
return invalidJson();
|
||||||
@@ -68,17 +88,32 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
clearLoginAttempts(clientIp);
|
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")) {
|
if (contentType.includes("application/json")) {
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
roleToSessionCookieValue(role),
|
createSessionToken(account.username),
|
||||||
getSessionCookieSetOptions(request),
|
getSessionCookieSetOptions(request),
|
||||||
);
|
);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||||
applySessionCookie(res, request, role);
|
applySessionCookie(res, request, account.username);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
|
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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 });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getSessionRole } from "@/lib/auth/require-session";
|
import { getSessionAccount } from "@/lib/auth/require-session";
|
||||||
|
import { canReadPage } from "@/lib/auth/permissions";
|
||||||
import {
|
import {
|
||||||
parseMatchIdParam,
|
parseMatchIdParam,
|
||||||
readMatchLogFile,
|
readMatchLogFile,
|
||||||
@@ -8,8 +9,11 @@ export async function GET(
|
|||||||
_request: Request,
|
_request: Request,
|
||||||
context: { params: Promise<{ matchId: string }> },
|
context: { params: Promise<{ matchId: string }> },
|
||||||
) {
|
) {
|
||||||
const role = await getSessionRole();
|
const account = await getSessionAccount();
|
||||||
if (!role) {
|
if (
|
||||||
|
!account ||
|
||||||
|
(!canReadPage(account, "matches") && !canReadPage(account, "analysis"))
|
||||||
|
) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { getSessionRole } from "@/lib/auth/require-session";
|
import { getSessionAccount } from "@/lib/auth/require-session";
|
||||||
|
import { canReadPage } from "@/lib/auth/permissions";
|
||||||
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const role = await getSessionRole();
|
const account = await getSessionAccount();
|
||||||
if (!role) {
|
if (!account || !canReadPage(account, "matchmaker")) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Unauthorized" },
|
{ error: "Unauthorized" },
|
||||||
{ status: 401, headers: NO_STORE },
|
{ status: 401, headers: NO_STORE },
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
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 {
|
import {
|
||||||
formatRcDecimalFromCoinsBigInt,
|
formatRcDecimalFromCoinsBigInt,
|
||||||
formatRcLabelFromCoinsBigInt,
|
formatRcLabelFromCoinsBigInt,
|
||||||
@@ -57,7 +58,8 @@ export default async function LedgerBookPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
|
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
|
||||||
}) {
|
}) {
|
||||||
await requireSession();
|
const account = await requirePageRead("ledger");
|
||||||
|
await logPageAccess(account, "ledger-book");
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
let fromStr = firstSearchParam(sp.from);
|
let fromStr = firstSearchParam(sp.from);
|
||||||
@@ -147,7 +149,7 @@ export default async function LedgerBookPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950">
|
<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">
|
<main className="flex-1 space-y-6 px-6 py-8">
|
||||||
<div className="mx-auto max-w-[1200px] space-y-2">
|
<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">
|
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
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 { requireSession } from "@/lib/auth/require-session";
|
||||||
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||||
import {
|
import {
|
||||||
parseMatchIdParam,
|
parseMatchIdParam,
|
||||||
readMatchLogFile,
|
readMatchLogFile,
|
||||||
} from "@/lib/match-logs-server";
|
} from "@/lib/match-logs-server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -24,7 +27,13 @@ export default async function MatchLogPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ matchId: string }>;
|
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: raw } = await params;
|
||||||
const matchId = parseMatchIdParam(raw);
|
const matchId = parseMatchIdParam(raw);
|
||||||
@@ -32,6 +41,8 @@ export default async function MatchLogPage({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await logPageAccess(account, "match-log", { matchId });
|
||||||
|
|
||||||
const result = await readMatchLogFile(matchId);
|
const result = await readMatchLogFile(matchId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
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 { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ export default async function MatchmakerLogsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ mklog?: string | string[] }>;
|
searchParams: Promise<{ mklog?: string | string[] }>;
|
||||||
}) {
|
}) {
|
||||||
await requireSession();
|
const account = await requirePageRead("matchmaker");
|
||||||
|
await logPageAccess(account, "matchmaker-logs");
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const raw =
|
const raw =
|
||||||
|
|||||||
+107
-11
@@ -1,13 +1,23 @@
|
|||||||
import { AdminDashboard } from "@/components/admin-dashboard";
|
import { AdminDashboard } from "@/components/admin-dashboard";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
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 { requireSession } from "@/lib/auth/require-session";
|
||||||
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||||
import {
|
import {
|
||||||
normalizeLedgerDateRange,
|
normalizeLedgerDateRange,
|
||||||
|
parseIsoDateOnly,
|
||||||
utcLast30DaysDateRange,
|
utcLast30DaysDateRange,
|
||||||
} from "@/lib/ledger-utc-date-range";
|
} from "@/lib/ledger-utc-date-range";
|
||||||
|
import {
|
||||||
|
localDateRangeToUtcIsoBounds,
|
||||||
|
normalizeMatchesDateRange,
|
||||||
|
parseTzOffsetMinutes,
|
||||||
|
} from "@/lib/local-date-range";
|
||||||
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
|
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
|
||||||
import {
|
import {
|
||||||
serializeLedgerGlobalSummary,
|
serializeLedgerGlobalSummary,
|
||||||
@@ -51,6 +61,9 @@ import {
|
|||||||
type LedgerSortKey,
|
type LedgerSortKey,
|
||||||
type LedgerSortOrder,
|
type LedgerSortOrder,
|
||||||
} from "@/lib/ledger-table-view";
|
} 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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -82,14 +95,26 @@ export default async function Home({
|
|||||||
afrom?: string | string[];
|
afrom?: string | string[];
|
||||||
ato?: string | string[];
|
ato?: string | string[];
|
||||||
aplayers?: string | string[];
|
aplayers?: string | string[];
|
||||||
|
mfrom?: string | string[];
|
||||||
|
mto?: string | string[];
|
||||||
|
mtz?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const role = await requireSession();
|
const account = await requireSession();
|
||||||
const readOnly = isReadOnlyRole(role);
|
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 sp = await searchParams;
|
||||||
const tabParam = firstSearchParam(sp.tab);
|
const tabParam = firstSearchParam(sp.tab);
|
||||||
const tab: AdminDashboardTab =
|
const requestedTab: AdminDashboardTab =
|
||||||
tabParam === "matches"
|
tabParam === "matches"
|
||||||
? "matches"
|
? "matches"
|
||||||
: tabParam === "players"
|
: tabParam === "players"
|
||||||
@@ -100,7 +125,20 @@ export default async function Home({
|
|||||||
? "ledger"
|
? "ledger"
|
||||||
: tabParam === "analysis"
|
: tabParam === "analysis"
|
||||||
? "analysis"
|
? "analysis"
|
||||||
|
: tabParam === "logs"
|
||||||
|
? "logs"
|
||||||
: "dashboard";
|
: "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 highlightId = firstSearchParam(sp.highlight);
|
||||||
const participantRaw = firstSearchParam(sp.participant);
|
const participantRaw = firstSearchParam(sp.participant);
|
||||||
const editRaw = firstSearchParam(sp.edit);
|
const editRaw = firstSearchParam(sp.edit);
|
||||||
@@ -129,6 +167,10 @@ export default async function Home({
|
|||||||
let ledgerTo = utcLast30DaysDateRange().to;
|
let ledgerTo = utcLast30DaysDateRange().to;
|
||||||
let analysisFrom = utcLast30DaysDateRange().from;
|
let analysisFrom = utcLast30DaysDateRange().from;
|
||||||
let analysisTo = utcLast30DaysDateRange().to;
|
let analysisTo = utcLast30DaysDateRange().to;
|
||||||
|
let matchesFrom = utcLast30DaysDateRange().from;
|
||||||
|
let matchesTo = utcLast30DaysDateRange().to;
|
||||||
|
let matchesTzOffsetMinutes: number | null = null;
|
||||||
|
let matchesRangeExplicit = false;
|
||||||
let analysisPlayerIds: number[] = [];
|
let analysisPlayerIds: number[] = [];
|
||||||
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
|
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
|
||||||
let pingAnalytics: PingAnalyticsResult = {
|
let pingAnalytics: PingAnalyticsResult = {
|
||||||
@@ -138,7 +180,7 @@ export default async function Home({
|
|||||||
totalPlayers: 0,
|
totalPlayers: 0,
|
||||||
avgPing: null,
|
avgPing: null,
|
||||||
p95Ping: null,
|
p95Ping: null,
|
||||||
badThresholdMs: 120,
|
badThresholdMs: 200,
|
||||||
badReports: 0,
|
badReports: 0,
|
||||||
badRatePercent: null,
|
badRatePercent: null,
|
||||||
},
|
},
|
||||||
@@ -158,6 +200,8 @@ export default async function Home({
|
|||||||
let matchmakerSource: MatchmakerLogSource = "processed";
|
let matchmakerSource: MatchmakerLogSource = "processed";
|
||||||
let matchmakerContent = "";
|
let matchmakerContent = "";
|
||||||
let matchmakerError: string | null = null;
|
let matchmakerError: string | null = null;
|
||||||
|
let auditEntries: AuditLogEntry[] = [];
|
||||||
|
let auditError: string | null = null;
|
||||||
if (tab === "matchmaker") {
|
if (tab === "matchmaker") {
|
||||||
const mkRaw = firstSearchParam(sp.mklog);
|
const mkRaw = firstSearchParam(sp.mklog);
|
||||||
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
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) {
|
if (!supabase) {
|
||||||
configError =
|
configError =
|
||||||
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
"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[];
|
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")
|
.from("matches")
|
||||||
.select("*")
|
.select("*")
|
||||||
.order("id", { ascending: false })
|
.order("id", { ascending: false });
|
||||||
.limit(500);
|
|
||||||
|
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) {
|
if (matchesRes.error) {
|
||||||
matchesError = matchesRes.error.message;
|
matchesError = matchesRes.error.message;
|
||||||
@@ -309,13 +396,13 @@ export default async function Home({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let editUser: DbUser | null = null;
|
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;
|
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
<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 ? (
|
{configError ? (
|
||||||
<div className="px-6 pt-8">
|
<div className="px-6 pt-8">
|
||||||
<div
|
<div
|
||||||
@@ -352,13 +439,22 @@ export default async function Home({
|
|||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
ledgerTo={ledgerTo}
|
||||||
supplyError={supplyError}
|
supplyError={supplyError}
|
||||||
|
matchesFrom={matchesFrom}
|
||||||
|
matchesTo={matchesTo}
|
||||||
|
matchesTzOffsetMinutes={matchesTzOffsetMinutes}
|
||||||
|
matchesRangeExplicit={matchesRangeExplicit}
|
||||||
analysisFrom={analysisFrom}
|
analysisFrom={analysisFrom}
|
||||||
analysisTo={analysisTo}
|
analysisTo={analysisTo}
|
||||||
analysisPlayerIds={analysisPlayerIds}
|
analysisPlayerIds={analysisPlayerIds}
|
||||||
matchAnalysis={matchAnalysis}
|
matchAnalysis={matchAnalysis}
|
||||||
pingAnalytics={pingAnalytics}
|
pingAnalytics={pingAnalytics}
|
||||||
geolocationAnalytics={geolocationAnalytics}
|
geolocationAnalytics={geolocationAnalytics}
|
||||||
readOnly={readOnly}
|
auditEntries={auditEntries}
|
||||||
|
auditError={auditError}
|
||||||
|
pageAccess={pageAccess}
|
||||||
|
canWritePlayers={canWritePlayers}
|
||||||
|
canWriteLedger={canWriteLedger}
|
||||||
|
isAdmin={account.isAdmin}
|
||||||
/>
|
/>
|
||||||
{editUser ? (
|
{editUser ? (
|
||||||
<EditUserCcRcOverlay
|
<EditUserCcRcOverlay
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { requireSession } from "@/lib/auth/require-session";
|
import { requireAdmin } from "@/lib/auth/require-session";
|
||||||
import { isReadOnlyRole } from "@/lib/auth/roles";
|
import { listSessionAccounts } from "@/lib/auth/accounts-store";
|
||||||
|
import { logPageAccess } from "@/lib/auth/log-page-access";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
||||||
|
import { AdminAccountsEditor } from "@/components/admin-accounts-editor";
|
||||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
import type { DbSetting } from "@/types/database";
|
import type { DbSetting } from "@/types/database";
|
||||||
|
|
||||||
@@ -28,14 +30,20 @@ export default async function SettingsPage({
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
saveError?: string | string[];
|
saveError?: string | string[];
|
||||||
addError?: string | string[];
|
addError?: string | string[];
|
||||||
|
accountError?: string | string[];
|
||||||
|
accountOk?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const role = await requireSession();
|
const account = await requireAdmin();
|
||||||
const readOnly = isReadOnlyRole(role);
|
await logPageAccess(account, "settings");
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||||
const addError = firstSearchParam(sp.addError);
|
const addError = firstSearchParam(sp.addError);
|
||||||
|
const accountError = firstSearchParam(sp.accountError);
|
||||||
|
const accountOk = firstSearchParam(sp.accountOk);
|
||||||
|
|
||||||
|
const accounts = await listSessionAccounts();
|
||||||
|
|
||||||
const supabase = createAdminSupabase();
|
const supabase = createAdminSupabase();
|
||||||
let rows: DbSetting[] = [];
|
let rows: DbSetting[] = [];
|
||||||
@@ -60,12 +68,27 @@ export default async function SettingsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
<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} />
|
||||||
<main className="flex-1 px-6 py-8">
|
<main className="flex-1 space-y-12 px-6 py-8">
|
||||||
<div className="mx-auto mb-8 max-w-[900px]">
|
<div className="mx-auto max-w-[900px]">
|
||||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
Global settings
|
Settings
|
||||||
</h1>
|
</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">
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
Key/value rows in the{" "}
|
Key/value rows in the{" "}
|
||||||
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||||
@@ -93,7 +116,7 @@ export default async function SettingsPage({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
saveError={saveError}
|
saveError={saveError}
|
||||||
addError={addError}
|
addError={addError}
|
||||||
readOnly={readOnly}
|
readOnly={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
createAdminAccount,
|
||||||
|
deleteAdminAccount,
|
||||||
|
updateAdminAccount,
|
||||||
|
} from "@/app/actions/account-actions";
|
||||||
|
import {
|
||||||
|
PAGE_KEYS,
|
||||||
|
type PageKey,
|
||||||
|
type PagePermissions,
|
||||||
|
type SessionAccount,
|
||||||
|
} from "@/lib/auth/permissions";
|
||||||
|
|
||||||
|
const PAGE_LABELS: Record<PageKey, string> = {
|
||||||
|
players: "Players",
|
||||||
|
matches: "Matches",
|
||||||
|
analysis: "Analysis",
|
||||||
|
matchmaker: "Matchmaker",
|
||||||
|
ledger: "Ledger",
|
||||||
|
logs: "System logs",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
accounts: SessionAccount[];
|
||||||
|
accountError: string | null;
|
||||||
|
accountOk: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorMessage(code: string | null): string | null {
|
||||||
|
if (!code) return null;
|
||||||
|
switch (code) {
|
||||||
|
case "missingUsername":
|
||||||
|
return "Username is required.";
|
||||||
|
case "missingPassword":
|
||||||
|
return "Password is required.";
|
||||||
|
case "duplicate":
|
||||||
|
return "That username is already taken.";
|
||||||
|
case "notFound":
|
||||||
|
return "Account not found.";
|
||||||
|
case "cannotDeleteAdmin":
|
||||||
|
return "The admin account cannot be deleted.";
|
||||||
|
default:
|
||||||
|
return "Could not update accounts. Try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function okMessage(code: string | null): string | null {
|
||||||
|
if (!code) return null;
|
||||||
|
switch (code) {
|
||||||
|
case "created":
|
||||||
|
return "Account created.";
|
||||||
|
case "updated":
|
||||||
|
return "Account updated.";
|
||||||
|
case "deleted":
|
||||||
|
return "Account deleted.";
|
||||||
|
default:
|
||||||
|
return "Done.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionCheckboxes({
|
||||||
|
idPrefix,
|
||||||
|
permissions,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
idPrefix: string;
|
||||||
|
permissions: PagePermissions;
|
||||||
|
onChange: (next: PagePermissions) => void;
|
||||||
|
}) {
|
||||||
|
function setPerm(page: PageKey, field: "read" | "write", value: boolean) {
|
||||||
|
const next = { ...permissions, [page]: { ...permissions[page] } };
|
||||||
|
if (page === "logs") {
|
||||||
|
next[page] = { read: field === "read" ? value : next[page].read, write: false };
|
||||||
|
onChange(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "write") {
|
||||||
|
next[page] = { write: value, read: value ? true : next[page].read };
|
||||||
|
} else {
|
||||||
|
next[page] = {
|
||||||
|
read: value,
|
||||||
|
write: value ? next[page].write : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[320px] text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-200 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||||
|
<th className="py-2 pr-3 font-medium">Page</th>
|
||||||
|
<th className="py-2 px-2 font-medium">Read</th>
|
||||||
|
<th className="py-2 px-2 font-medium">Write</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
|
||||||
|
Overview
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-zinc-500" colSpan={2}>
|
||||||
|
Always enabled
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{PAGE_KEYS.map((page) => (
|
||||||
|
<tr
|
||||||
|
key={page}
|
||||||
|
className="border-b border-zinc-100 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
|
||||||
|
{PAGE_LABELS[page]}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${idPrefix}_${page}_read`}
|
||||||
|
name={`perm_${page}_read`}
|
||||||
|
value="1"
|
||||||
|
checked={permissions[page].read}
|
||||||
|
onChange={(e) => setPerm(page, "read", e.target.checked)}
|
||||||
|
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
{page === "logs" ? (
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${idPrefix}_${page}_write`}
|
||||||
|
name={`perm_${page}_write`}
|
||||||
|
value="1"
|
||||||
|
checked={permissions[page].write}
|
||||||
|
onChange={(e) => setPerm(page, "write", e.target.checked)}
|
||||||
|
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionSummary(account: SessionAccount): string {
|
||||||
|
if (account.isAdmin) return "Full access";
|
||||||
|
const parts: string[] = ["Overview"];
|
||||||
|
for (const page of PAGE_KEYS) {
|
||||||
|
const p = account.permissions[page];
|
||||||
|
if (p.write) parts.push(`${PAGE_LABELS[page]} (rw)`);
|
||||||
|
else if (p.read) parts.push(`${PAGE_LABELS[page]} (r)`);
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountEditForm({
|
||||||
|
account,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
account: SessionAccount;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState(account.username);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [permissions, setPermissions] = useState(account.permissions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={updateAdminAccount}
|
||||||
|
className="mt-3 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-950/60"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={account.id} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="isAdmin"
|
||||||
|
value={account.isAdmin ? "1" : "0"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{account.isAdmin ? (
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Admin account — username is fixed; only the password can be changed.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
|
htmlFor={`edit-username-${account.id}`}
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-username-${account.id}`}
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
|
htmlFor={`edit-password-${account.id}`}
|
||||||
|
>
|
||||||
|
{account.isAdmin ? "New password" : "New password (optional)"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-password-${account.id}`}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required={account.isAdmin}
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!account.isAdmin ? (
|
||||||
|
<PermissionCheckboxes
|
||||||
|
idPrefix={`edit_${account.id}`}
|
||||||
|
permissions={permissions}
|
||||||
|
onChange={setPermissions}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddAccountForm() {
|
||||||
|
const [permissions, setPermissions] = useState<PagePermissions>({
|
||||||
|
players: { read: false, write: false },
|
||||||
|
matches: { read: false, write: false },
|
||||||
|
analysis: { read: false, write: false },
|
||||||
|
matchmaker: { read: false, write: false },
|
||||||
|
ledger: { read: false, write: false },
|
||||||
|
logs: { read: false, write: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={createAdminAccount}
|
||||||
|
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Add account
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
|
htmlFor="new-account-username"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-account-username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
|
htmlFor="new-account-password"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-account-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PermissionCheckboxes
|
||||||
|
idPrefix="new"
|
||||||
|
permissions={permissions}
|
||||||
|
onChange={setPermissions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAccountsEditor({
|
||||||
|
accounts,
|
||||||
|
accountError,
|
||||||
|
accountOk,
|
||||||
|
}: Props) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const err = errorMessage(accountError);
|
||||||
|
const ok = okMessage(accountOk);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
Accounts
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Manage panel logins and per-page read/write access. Only the admin
|
||||||
|
account can manage other accounts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{ok ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{ok}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
Existing accounts
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<li
|
||||||
|
key={account.id}
|
||||||
|
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-zinc-900 dark:text-zinc-50">
|
||||||
|
{account.username}
|
||||||
|
{account.isAdmin ? (
|
||||||
|
<span className="ml-2 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{permissionSummary(account)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setEditingId((id) =>
|
||||||
|
id === account.id ? null : account.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{editingId === account.id ? "Close" : "Edit"}
|
||||||
|
</button>
|
||||||
|
{!account.isAdmin ? (
|
||||||
|
<form action={deleteAdminAccount}>
|
||||||
|
<input type="hidden" name="id" value={account.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Delete account “${account.username}”?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editingId === account.id ? (
|
||||||
|
<AccountEditForm
|
||||||
|
account={account}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AddAccountForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import Link from "next/link";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||||
import { AdminLedger } from "@/components/admin-ledger";
|
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 { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||||||
|
import type { AuditLogEntry } from "@/lib/auth/audit-log";
|
||||||
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
||||||
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
||||||
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
||||||
@@ -16,6 +19,9 @@ import {
|
|||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
type AdminDashboardTab,
|
type AdminDashboardTab,
|
||||||
} from "@/lib/dashboard-search-url";
|
} from "@/lib/dashboard-search-url";
|
||||||
|
import {
|
||||||
|
localLast30DaysDateRange,
|
||||||
|
} from "@/lib/local-date-range";
|
||||||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||||
import type {
|
import type {
|
||||||
AdminMatchRow,
|
AdminMatchRow,
|
||||||
@@ -221,13 +227,31 @@ type Props = {
|
|||||||
matchmakerError: string | null;
|
matchmakerError: string | null;
|
||||||
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
||||||
supplyError: boolean;
|
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;
|
analysisFrom: string;
|
||||||
analysisTo: string;
|
analysisTo: string;
|
||||||
analysisPlayerIds: number[];
|
analysisPlayerIds: number[];
|
||||||
matchAnalysis: MatchLogAnalysisResult;
|
matchAnalysis: MatchLogAnalysisResult;
|
||||||
pingAnalytics: PingAnalyticsResult;
|
pingAnalytics: PingAnalyticsResult;
|
||||||
geolocationAnalytics: GeolocationAnalyticsResult;
|
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({
|
function StatCard({
|
||||||
@@ -278,19 +302,55 @@ export function AdminDashboard({
|
|||||||
matchmakerContent,
|
matchmakerContent,
|
||||||
matchmakerError,
|
matchmakerError,
|
||||||
supplyError,
|
supplyError,
|
||||||
|
matchesFrom,
|
||||||
|
matchesTo,
|
||||||
|
matchesTzOffsetMinutes,
|
||||||
|
matchesRangeExplicit,
|
||||||
analysisFrom,
|
analysisFrom,
|
||||||
analysisTo,
|
analysisTo,
|
||||||
analysisPlayerIds,
|
analysisPlayerIds,
|
||||||
matchAnalysis,
|
matchAnalysis,
|
||||||
pingAnalytics,
|
pingAnalytics,
|
||||||
geolocationAnalytics,
|
geolocationAnalytics,
|
||||||
readOnly,
|
auditEntries,
|
||||||
|
auditError,
|
||||||
|
pageAccess,
|
||||||
|
canWritePlayers,
|
||||||
|
canWriteLedger,
|
||||||
|
isAdmin,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||||
const [playersSearch, setPlayersSearch] = useState("");
|
const [playersSearch, setPlayersSearch] = useState("");
|
||||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||||
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
|
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 lbRaw = statsBundle?.leaderboard;
|
||||||
const sortedLeaderboard = useMemo(() => {
|
const sortedLeaderboard = useMemo(() => {
|
||||||
const rows = lbRaw ?? [];
|
const rows = lbRaw ?? [];
|
||||||
@@ -407,6 +467,7 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Overview
|
Overview
|
||||||
</Link>
|
</Link>
|
||||||
|
{pageAccess.players ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "players",
|
tab: "players",
|
||||||
@@ -423,11 +484,16 @@ export function AdminDashboard({
|
|||||||
: null}
|
: null}
|
||||||
)
|
)
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{pageAccess.matches ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "matches",
|
tab: "matches",
|
||||||
highlightId,
|
highlightId,
|
||||||
participantRaw,
|
participantRaw,
|
||||||
|
matchesFrom,
|
||||||
|
matchesTo,
|
||||||
|
matchesTzOffsetMinutes,
|
||||||
})}
|
})}
|
||||||
className={tabClass(tab === "matches")}
|
className={tabClass(tab === "matches")}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
@@ -439,6 +505,8 @@ export function AdminDashboard({
|
|||||||
: null}
|
: null}
|
||||||
)
|
)
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{pageAccess.analysis ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "analysis",
|
tab: "analysis",
|
||||||
@@ -456,6 +524,8 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Analysis
|
Analysis
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{pageAccess.matchmaker ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "matchmaker",
|
tab: "matchmaker",
|
||||||
@@ -467,6 +537,8 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Matchmaker
|
Matchmaker
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{pageAccess.ledger ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "ledger",
|
tab: "ledger",
|
||||||
@@ -478,6 +550,21 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Ledger
|
Ledger
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{pageAccess.logs ? (
|
||||||
|
<Link
|
||||||
|
href={buildDashboardHref({
|
||||||
|
tab: "logs",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
})}
|
||||||
|
className={tabClass(tab === "logs")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
System logs
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{isAdmin ? (
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className={tabClass(false)}
|
className={tabClass(false)}
|
||||||
@@ -485,6 +572,7 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-[1400px]">
|
<div className="mx-auto w-full max-w-[1400px]">
|
||||||
@@ -740,7 +828,7 @@ export function AdminDashboard({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 whitespace-nowrap">
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{!readOnly ? (
|
{canWritePlayers ? (
|
||||||
<Link
|
<Link
|
||||||
href={editHref(u)}
|
href={editHref(u)}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
@@ -839,10 +927,102 @@ export function AdminDashboard({
|
|||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
ledgerTo={ledgerTo}
|
||||||
supplyError={supplyError}
|
supplyError={supplyError}
|
||||||
readOnly={readOnly}
|
readOnly={!canWriteLedger}
|
||||||
/>
|
/>
|
||||||
|
) : tab === "logs" ? (
|
||||||
|
<AdminSystemLogs entries={auditEntries} error={auditError} />
|
||||||
) : (
|
) : (
|
||||||
<section className="space-y-3">
|
<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 ? (
|
{participantId != null ? (
|
||||||
<div
|
<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"
|
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})
|
{filteredMatches.length} of {matches.length})
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<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"
|
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}
|
scroll={false}
|
||||||
>
|
>
|
||||||
@@ -900,7 +1087,7 @@ export function AdminDashboard({
|
|||||||
{visibleMatches.length === 0 ? (
|
{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">
|
<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
|
{filteredMatches.length === 0
|
||||||
? "No matches yet."
|
? "No matches in this date range."
|
||||||
: hideNoWinner
|
: hideNoWinner
|
||||||
? "No matches left after hiding no-winner matches."
|
? "No matches left after hiding no-winner matches."
|
||||||
: "No matches for this filter."}
|
: "No matches for this filter."}
|
||||||
@@ -918,7 +1105,7 @@ export function AdminDashboard({
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
matchId={m.id}
|
matchId={m.id}
|
||||||
statusLabel={statusLabel(m.status)}
|
statusLabel={statusLabel(m.status)}
|
||||||
createdAtLabel={formatTs(m.created_at)}
|
createdAtLabel={<LocalTimestamp value={m.created_at} />}
|
||||||
entryFeeCoins={m.entryFeePerPlayerCoins}
|
entryFeeCoins={m.entryFeePerPlayerCoins}
|
||||||
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
||||||
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readOnly?: boolean;
|
username?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminHeader({ readOnly = false }: Props) {
|
export function AdminHeader({ username, isAdmin = false }: Props) {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
window.location.href = "/login";
|
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">
|
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
Kick Kings Admin Dashboard
|
Kick Kings Admin Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
{readOnly ? (
|
{username ? (
|
||||||
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
Read-only access
|
Signed in as{" "}
|
||||||
|
<span className="font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
{isAdmin ? " · admin" : null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||||
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
|
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ type PlayerSide = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
matchId: number;
|
matchId: number;
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
createdAtLabel: string;
|
createdAtLabel: ReactNode;
|
||||||
/** Ledger `entry_fee` / player as coin string; null if unknown. */
|
/** Ledger `entry_fee` / player as coin string; null if unknown. */
|
||||||
entryFeeCoins: string | null;
|
entryFeeCoins: string | null;
|
||||||
/** Ledger `entry_hold` / player as coin string; null if unknown. */
|
/** Ledger `entry_hold` / player as coin string; null if unknown. */
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import {
|
||||||
|
randomBytes,
|
||||||
|
randomUUID,
|
||||||
|
scryptSync,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
fullPagePermissions,
|
||||||
|
normalizePagePermissions,
|
||||||
|
type PagePermissions,
|
||||||
|
type SessionAccount,
|
||||||
|
} from "@/lib/auth/permissions";
|
||||||
|
|
||||||
|
const SCRYPT_OPTS = {
|
||||||
|
N: 16384,
|
||||||
|
r: 8,
|
||||||
|
p: 1,
|
||||||
|
maxmem: 64 * 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
const HASH_KEYLEN = 64;
|
||||||
|
|
||||||
|
export type AdminAccountRecord = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
permissions: PagePermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountsFile = {
|
||||||
|
version: 1;
|
||||||
|
accounts: AdminAccountRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function accountsFilePath(): string {
|
||||||
|
const override = process.env.ADMIN_ACCOUNTS_PATH?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
return path.join(process.cwd(), "data", "admin-accounts.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBootstrapAdminUsername(): string {
|
||||||
|
return process.env.ADMIN_USERNAME?.trim() || "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBootstrapAdminPassword(): string | null {
|
||||||
|
const password = process.env.ADMIN_PASSWORD?.trim();
|
||||||
|
return password || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const hash = scryptSync(password, salt, HASH_KEYLEN, SCRYPT_OPTS);
|
||||||
|
return `scrypt:${salt.toString("base64")}:${hash.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
stored: string,
|
||||||
|
): boolean {
|
||||||
|
const parts = stored.split(":");
|
||||||
|
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||||
|
let salt: Buffer;
|
||||||
|
let expected: Buffer;
|
||||||
|
try {
|
||||||
|
salt = Buffer.from(parts[1]!, "base64");
|
||||||
|
expected = Buffer.from(parts[2]!, "base64");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (salt.length === 0 || expected.length === 0) return false;
|
||||||
|
const actual = scryptSync(password, salt, expected.length, SCRYPT_OPTS);
|
||||||
|
if (actual.length !== expected.length) return false;
|
||||||
|
return timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSessionAccount(record: AdminAccountRecord): SessionAccount {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
username: record.username,
|
||||||
|
isAdmin: record.isAdmin,
|
||||||
|
permissions: record.isAdmin
|
||||||
|
? fullPagePermissions()
|
||||||
|
: normalizePagePermissions(record.permissions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecord(raw: unknown): AdminAccountRecord | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (typeof o.id !== "string" || !o.id) return null;
|
||||||
|
if (typeof o.username !== "string" || !o.username.trim()) return null;
|
||||||
|
if (typeof o.passwordHash !== "string" || !o.passwordHash) return null;
|
||||||
|
const isAdmin = Boolean(o.isAdmin);
|
||||||
|
const permissions = isAdmin
|
||||||
|
? fullPagePermissions()
|
||||||
|
: normalizePagePermissions(
|
||||||
|
o.permissions as Partial<
|
||||||
|
Record<string, { read?: boolean; write?: boolean }>
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: o.id,
|
||||||
|
username: o.username.trim(),
|
||||||
|
passwordHash: o.passwordHash,
|
||||||
|
isAdmin,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRawFile(): Promise<AccountsFile | null> {
|
||||||
|
const filePath = accountsFilePath();
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(filePath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === "ENOENT") return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
const accountsRaw = (parsed as { accounts?: unknown }).accounts;
|
||||||
|
if (!Array.isArray(accountsRaw)) return { version: 1, accounts: [] };
|
||||||
|
const accounts: AdminAccountRecord[] = [];
|
||||||
|
for (const item of accountsRaw) {
|
||||||
|
const rec = normalizeRecord(item);
|
||||||
|
if (rec) accounts.push(rec);
|
||||||
|
}
|
||||||
|
return { version: 1, accounts };
|
||||||
|
} catch {
|
||||||
|
return { version: 1, accounts: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAccountsFile(accounts: AdminAccountRecord[]): Promise<void> {
|
||||||
|
const filePath = accountsFilePath();
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const payload: AccountsFile = { version: 1, accounts };
|
||||||
|
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||||
|
await rename(tmp, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let writeChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const next = writeChain.then(fn, fn);
|
||||||
|
writeChain = next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAdminBootstrapped(
|
||||||
|
accounts: AdminAccountRecord[],
|
||||||
|
): Promise<AdminAccountRecord[]> {
|
||||||
|
if (accounts.some((a) => a.isAdmin)) return accounts;
|
||||||
|
|
||||||
|
const password = getBootstrapAdminPassword();
|
||||||
|
if (!password) {
|
||||||
|
throw new Error(
|
||||||
|
"ADMIN_PASSWORD is required to bootstrap the admin account.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = getBootstrapAdminUsername();
|
||||||
|
const admin: AdminAccountRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
username,
|
||||||
|
passwordHash: hashPassword(password),
|
||||||
|
isAdmin: true,
|
||||||
|
permissions: fullPagePermissions(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop any non-admin that collides with the bootstrap username.
|
||||||
|
const rest = accounts.filter(
|
||||||
|
(a) => a.username.toLowerCase() !== username.toLowerCase(),
|
||||||
|
);
|
||||||
|
const next = [admin, ...rest];
|
||||||
|
await writeAccountsFile(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAccounts(): Promise<AdminAccountRecord[]> {
|
||||||
|
const file = await readRawFile();
|
||||||
|
const accounts = await ensureAdminBootstrapped(file?.accounts ?? []);
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessionAccounts(): Promise<SessionAccount[]> {
|
||||||
|
const accounts = await listAccounts();
|
||||||
|
return accounts.map(toSessionAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findAccountByUsername(
|
||||||
|
username: string,
|
||||||
|
): Promise<AdminAccountRecord | null> {
|
||||||
|
const needle = username.trim().toLowerCase();
|
||||||
|
if (!needle) return null;
|
||||||
|
const accounts = await listAccounts();
|
||||||
|
return (
|
||||||
|
accounts.find((a) => a.username.toLowerCase() === needle) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findAccountById(
|
||||||
|
id: string,
|
||||||
|
): Promise<AdminAccountRecord | null> {
|
||||||
|
const accounts = await listAccounts();
|
||||||
|
return accounts.find((a) => a.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAccountCredentials(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<SessionAccount | null> {
|
||||||
|
const account = await findAccountByUsername(username);
|
||||||
|
if (!account) return null;
|
||||||
|
if (!verifyPassword(password, account.passwordHash)) return null;
|
||||||
|
return toSessionAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccount(input: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
permissions: PagePermissions;
|
||||||
|
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
|
||||||
|
const username = input.username.trim();
|
||||||
|
if (!username) return { ok: false, error: "missingUsername" };
|
||||||
|
if (!input.password) return { ok: false, error: "missingPassword" };
|
||||||
|
|
||||||
|
return enqueueWrite(async () => {
|
||||||
|
const accounts = await listAccounts();
|
||||||
|
if (
|
||||||
|
accounts.some((a) => a.username.toLowerCase() === username.toLowerCase())
|
||||||
|
) {
|
||||||
|
return { ok: false, error: "duplicate" };
|
||||||
|
}
|
||||||
|
const record: AdminAccountRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
username,
|
||||||
|
passwordHash: hashPassword(input.password),
|
||||||
|
isAdmin: false,
|
||||||
|
permissions: normalizePagePermissions(input.permissions),
|
||||||
|
};
|
||||||
|
await writeAccountsFile([...accounts, record]);
|
||||||
|
return { ok: true, account: toSessionAccount(record) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccount(input: {
|
||||||
|
id: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
permissions?: PagePermissions;
|
||||||
|
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
|
||||||
|
return enqueueWrite(async () => {
|
||||||
|
const accounts = await listAccounts();
|
||||||
|
const idx = accounts.findIndex((a) => a.id === input.id);
|
||||||
|
if (idx < 0) return { ok: false, error: "notFound" };
|
||||||
|
const current = accounts[idx]!;
|
||||||
|
|
||||||
|
let username = current.username;
|
||||||
|
if (!current.isAdmin && input.username !== undefined) {
|
||||||
|
const nextName = input.username.trim();
|
||||||
|
if (!nextName) return { ok: false, error: "missingUsername" };
|
||||||
|
if (
|
||||||
|
accounts.some(
|
||||||
|
(a) =>
|
||||||
|
a.id !== current.id &&
|
||||||
|
a.username.toLowerCase() === nextName.toLowerCase(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { ok: false, error: "duplicate" };
|
||||||
|
}
|
||||||
|
username = nextName;
|
||||||
|
}
|
||||||
|
|
||||||
|
let passwordHash = current.passwordHash;
|
||||||
|
if (input.password !== undefined && input.password !== "") {
|
||||||
|
passwordHash = hashPassword(input.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
let permissions = current.permissions;
|
||||||
|
if (!current.isAdmin && input.permissions !== undefined) {
|
||||||
|
permissions = normalizePagePermissions(input.permissions);
|
||||||
|
} else if (current.isAdmin) {
|
||||||
|
permissions = fullPagePermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: AdminAccountRecord = {
|
||||||
|
...current,
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
const updated = [...accounts];
|
||||||
|
updated[idx] = next;
|
||||||
|
await writeAccountsFile(updated);
|
||||||
|
return { ok: true, account: toSessionAccount(next) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount(
|
||||||
|
id: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
return enqueueWrite(async () => {
|
||||||
|
const accounts = await listAccounts();
|
||||||
|
const target = accounts.find((a) => a.id === id);
|
||||||
|
if (!target) return { ok: false, error: "notFound" };
|
||||||
|
if (target.isAdmin) return { ok: false, error: "cannotDeleteAdmin" };
|
||||||
|
await writeAccountsFile(accounts.filter((a) => a.id !== id));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type AuditLogEntry = {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
username: string;
|
||||||
|
accountId: string | null;
|
||||||
|
action: string;
|
||||||
|
summary: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
ip?: string | null;
|
||||||
|
/** Short device label (e.g. "Desktop · Windows · Chrome"). */
|
||||||
|
device?: string | null;
|
||||||
|
/** Raw User-Agent (truncated), mainly for login audits. */
|
||||||
|
userAgent?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogInput = {
|
||||||
|
username: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
action: string;
|
||||||
|
summary: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
ip?: string | null;
|
||||||
|
device?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_READ_LIMIT = 500;
|
||||||
|
|
||||||
|
function auditLogPath(): string {
|
||||||
|
const override = process.env.ADMIN_AUDIT_LOG_PATH?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
return path.join(process.cwd(), "data", "admin-audit.jsonl");
|
||||||
|
}
|
||||||
|
|
||||||
|
let writeChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const next = writeChain.then(fn, fn);
|
||||||
|
writeChain = next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append one audit entry. Never throws to callers (best-effort). */
|
||||||
|
export async function appendAuditLog(
|
||||||
|
input: AuditLogInput,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await enqueueWrite(async () => {
|
||||||
|
const filePath = auditLogPath();
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const entry: AuditLogEntry = {
|
||||||
|
id: randomUUID(),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
username: input.username,
|
||||||
|
accountId: input.accountId ?? null,
|
||||||
|
action: input.action,
|
||||||
|
summary: input.summary,
|
||||||
|
...(input.details ? { details: input.details } : {}),
|
||||||
|
...(input.ip != null ? { ip: input.ip } : {}),
|
||||||
|
...(input.device != null ? { device: input.device } : {}),
|
||||||
|
...(input.userAgent != null ? { userAgent: input.userAgent } : {}),
|
||||||
|
};
|
||||||
|
await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[audit-log] failed to append", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLine(line: string): AuditLogEntry | null {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(trimmed) as Partial<AuditLogEntry>;
|
||||||
|
if (
|
||||||
|
typeof raw.id !== "string" ||
|
||||||
|
typeof raw.at !== "string" ||
|
||||||
|
typeof raw.username !== "string" ||
|
||||||
|
typeof raw.action !== "string" ||
|
||||||
|
typeof raw.summary !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
at: raw.at,
|
||||||
|
username: raw.username,
|
||||||
|
accountId:
|
||||||
|
typeof raw.accountId === "string" ? raw.accountId : null,
|
||||||
|
action: raw.action,
|
||||||
|
summary: raw.summary,
|
||||||
|
...(raw.details && typeof raw.details === "object"
|
||||||
|
? { details: raw.details as Record<string, unknown> }
|
||||||
|
: {}),
|
||||||
|
...(typeof raw.ip === "string" || raw.ip === null
|
||||||
|
? { ip: raw.ip }
|
||||||
|
: {}),
|
||||||
|
...(typeof raw.device === "string" || raw.device === null
|
||||||
|
? { device: raw.device }
|
||||||
|
: {}),
|
||||||
|
...(typeof raw.userAgent === "string" || raw.userAgent === null
|
||||||
|
? { userAgent: raw.userAgent }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest-first. Caps at `limit` entries. */
|
||||||
|
export async function readAuditLog(
|
||||||
|
limit = DEFAULT_READ_LIMIT,
|
||||||
|
): Promise<AuditLogEntry[]> {
|
||||||
|
const filePath = auditLogPath();
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(filePath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === "ENOENT") return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const entries: AuditLogEntry[] = [];
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
const entry = parseLine(lines[i]!);
|
||||||
|
if (!entry) continue;
|
||||||
|
entries.push(entry);
|
||||||
|
if (entries.length >= limit) break;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Best-effort device / browser label from a User-Agent string.
|
||||||
|
* Not for security decisions — display and audit only.
|
||||||
|
*/
|
||||||
|
export function parseDeviceFromUserAgent(
|
||||||
|
userAgent: string | null | undefined,
|
||||||
|
): { device: string; userAgent: string | null } {
|
||||||
|
const ua = userAgent?.trim() || null;
|
||||||
|
if (!ua) {
|
||||||
|
return { device: "Unknown device", userAgent: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const os =
|
||||||
|
/Windows NT/i.test(ua)
|
||||||
|
? "Windows"
|
||||||
|
: /Android/i.test(ua)
|
||||||
|
? "Android"
|
||||||
|
: /iPhone|iPad|iPod/i.test(ua)
|
||||||
|
? "iOS"
|
||||||
|
: /Mac OS X|Macintosh/i.test(ua)
|
||||||
|
? "macOS"
|
||||||
|
: /CrOS/i.test(ua)
|
||||||
|
? "Chrome OS"
|
||||||
|
: /Linux/i.test(ua)
|
||||||
|
? "Linux"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const browser =
|
||||||
|
/Edg\//i.test(ua)
|
||||||
|
? "Edge"
|
||||||
|
: /OPR\/|Opera/i.test(ua)
|
||||||
|
? "Opera"
|
||||||
|
: /Firefox\//i.test(ua)
|
||||||
|
? "Firefox"
|
||||||
|
: /Chrome\//i.test(ua) && !/Edg\//i.test(ua)
|
||||||
|
? "Chrome"
|
||||||
|
: /Safari\//i.test(ua) && !/Chrome\//i.test(ua)
|
||||||
|
? "Safari"
|
||||||
|
: /curl\//i.test(ua)
|
||||||
|
? "curl"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const formFactor = /Mobile|Android.*Mobile|iPhone|iPod/i.test(ua)
|
||||||
|
? "Mobile"
|
||||||
|
: /iPad|Tablet|Android(?!.*Mobile)/i.test(ua)
|
||||||
|
? "Tablet"
|
||||||
|
: "Desktop";
|
||||||
|
|
||||||
|
const parts = [formFactor, os, browser].filter(Boolean);
|
||||||
|
return {
|
||||||
|
device: parts.length > 0 ? parts.join(" · ") : "Unknown device",
|
||||||
|
userAgent: ua.slice(0, 512),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestUserAgent(request: Request): string | null {
|
||||||
|
const ua = request.headers.get("user-agent")?.trim();
|
||||||
|
return ua || null;
|
||||||
|
}
|
||||||
@@ -1,52 +1,9 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { verifyAccountCredentials } from "@/lib/auth/accounts-store";
|
||||||
import type { AdminRole } from "@/lib/auth/roles";
|
import type { SessionAccount } from "@/lib/auth/permissions";
|
||||||
|
|
||||||
function safeEqual(a: string, b: string): boolean {
|
export async function verifyCredentials(
|
||||||
const aBuf = Buffer.from(a);
|
|
||||||
const bBuf = Buffer.from(b);
|
|
||||||
if (aBuf.length !== bBuf.length) return false;
|
|
||||||
return timingSafeEqual(aBuf, bBuf);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminUsername(): string {
|
|
||||||
return process.env.ADMIN_USERNAME?.trim() || "admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminPassword(): string | null {
|
|
||||||
const password = process.env.ADMIN_PASSWORD?.trim();
|
|
||||||
return password || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSupervisorUsername(): string {
|
|
||||||
return process.env.SUPERVISOR_USERNAME?.trim() || "supervisor";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSupervisorPassword(): string | null {
|
|
||||||
const password = process.env.SUPERVISOR_PASSWORD?.trim();
|
|
||||||
return password || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyCredentials(
|
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): AdminRole | null {
|
): Promise<SessionAccount | null> {
|
||||||
const adminPassword = getAdminPassword();
|
return verifyAccountCredentials(username, password);
|
||||||
if (
|
|
||||||
adminPassword &&
|
|
||||||
safeEqual(username, getAdminUsername()) &&
|
|
||||||
safeEqual(password, adminPassword)
|
|
||||||
) {
|
|
||||||
return "admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
const supervisorPassword = getSupervisorPassword();
|
|
||||||
if (
|
|
||||||
supervisorPassword &&
|
|
||||||
safeEqual(username, getSupervisorUsername()) &&
|
|
||||||
safeEqual(password, supervisorPassword)
|
|
||||||
) {
|
|
||||||
return "supervisor";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { appendAuditLog } from "@/lib/auth/audit-log";
|
||||||
|
import { parseDeviceFromUserAgent } from "@/lib/auth/client-device";
|
||||||
|
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
|
||||||
|
import type { SessionAccount } from "@/lib/auth/permissions";
|
||||||
|
|
||||||
|
function clientIpFromHeaders(h: Headers): string {
|
||||||
|
const forwarded = h.get("x-forwarded-for");
|
||||||
|
if (forwarded) {
|
||||||
|
const first = forwarded.split(",")[0]?.trim();
|
||||||
|
if (first) return first;
|
||||||
|
}
|
||||||
|
const realIp = h.get("x-real-ip")?.trim();
|
||||||
|
if (realIp) return realIp;
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record that an account loaded a panel page. Best-effort; never throws. */
|
||||||
|
export async function logPageAccess(
|
||||||
|
account: SessionAccount,
|
||||||
|
page: string,
|
||||||
|
extraDetails?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const h = await headers();
|
||||||
|
const ip = clientIpFromHeaders(h);
|
||||||
|
const { device, userAgent } = parseDeviceFromUserAgent(
|
||||||
|
h.get("user-agent"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: account.username,
|
||||||
|
accountId: account.id,
|
||||||
|
action: "access",
|
||||||
|
summary: `Accessed ${pageAccessLabel(page)}`,
|
||||||
|
ip,
|
||||||
|
device,
|
||||||
|
userAgent,
|
||||||
|
details: {
|
||||||
|
page,
|
||||||
|
...(extraDetails ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[audit-log] page access failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const PAGE_LABELS: Record<string, string> = {
|
||||||
|
overview: "Overview",
|
||||||
|
players: "Players",
|
||||||
|
matches: "Matches",
|
||||||
|
analysis: "Analysis",
|
||||||
|
matchmaker: "Matchmaker",
|
||||||
|
ledger: "Ledger",
|
||||||
|
logs: "System logs",
|
||||||
|
settings: "Settings",
|
||||||
|
"ledger-book": "Ledger book",
|
||||||
|
"match-log": "Match log",
|
||||||
|
"matchmaker-logs": "Matchmaker logs",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pageAccessLabel(page: string): string {
|
||||||
|
return PAGE_LABELS[page] ?? page;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export const PAGE_KEYS = [
|
||||||
|
"players",
|
||||||
|
"matches",
|
||||||
|
"analysis",
|
||||||
|
"matchmaker",
|
||||||
|
"ledger",
|
||||||
|
"logs",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PageKey = (typeof PAGE_KEYS)[number];
|
||||||
|
|
||||||
|
/** Pages that only support read access (no write checkbox). */
|
||||||
|
export const READ_ONLY_PAGE_KEYS = ["logs"] as const satisfies readonly PageKey[];
|
||||||
|
|
||||||
|
export type PagePermission = {
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagePermissions = Record<PageKey, PagePermission>;
|
||||||
|
|
||||||
|
export function emptyPagePermissions(): PagePermissions {
|
||||||
|
return {
|
||||||
|
players: { read: false, write: false },
|
||||||
|
matches: { read: false, write: false },
|
||||||
|
analysis: { read: false, write: false },
|
||||||
|
matchmaker: { read: false, write: false },
|
||||||
|
ledger: { read: false, write: false },
|
||||||
|
logs: { read: false, write: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullPagePermissions(): PagePermissions {
|
||||||
|
return {
|
||||||
|
players: { read: true, write: true },
|
||||||
|
matches: { read: true, write: true },
|
||||||
|
analysis: { read: true, write: true },
|
||||||
|
matchmaker: { read: true, write: true },
|
||||||
|
ledger: { read: true, write: true },
|
||||||
|
logs: { read: true, write: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize permissions: write implies read. Logs never grants write. */
|
||||||
|
export function normalizePagePermissions(
|
||||||
|
input: Partial<Record<PageKey, Partial<PagePermission>>> | null | undefined,
|
||||||
|
): PagePermissions {
|
||||||
|
const base = emptyPagePermissions();
|
||||||
|
for (const key of PAGE_KEYS) {
|
||||||
|
const raw = input?.[key];
|
||||||
|
if (key === "logs") {
|
||||||
|
base[key] = { read: Boolean(raw?.read), write: false };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const write = Boolean(raw?.write);
|
||||||
|
const read = write || Boolean(raw?.read);
|
||||||
|
base[key] = { read, write };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionAccount = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
permissions: PagePermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canReadPage(
|
||||||
|
account: SessionAccount,
|
||||||
|
page: PageKey,
|
||||||
|
): boolean {
|
||||||
|
if (account.isAdmin) return true;
|
||||||
|
return account.permissions[page].read;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canWritePage(
|
||||||
|
account: SessionAccount,
|
||||||
|
page: PageKey,
|
||||||
|
): boolean {
|
||||||
|
if (account.isAdmin) return true;
|
||||||
|
if (page === "logs") return false;
|
||||||
|
return account.permissions[page].write;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map dashboard tab (except overview) to a permission page key. */
|
||||||
|
export function tabToPageKey(
|
||||||
|
tab: string,
|
||||||
|
): PageKey | null {
|
||||||
|
if (
|
||||||
|
tab === "players" ||
|
||||||
|
tab === "matches" ||
|
||||||
|
tab === "analysis" ||
|
||||||
|
tab === "matchmaker" ||
|
||||||
|
tab === "ledger" ||
|
||||||
|
tab === "logs"
|
||||||
|
) {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,30 +1,67 @@
|
|||||||
|
import { findAccountByUsername } from "@/lib/auth/accounts-store";
|
||||||
|
import {
|
||||||
|
canReadPage,
|
||||||
|
canWritePage,
|
||||||
|
fullPagePermissions,
|
||||||
|
normalizePagePermissions,
|
||||||
|
type PageKey,
|
||||||
|
type SessionAccount,
|
||||||
|
} from "@/lib/auth/permissions";
|
||||||
|
import { parseSessionToken } from "@/lib/auth/roles";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
|
||||||
isReadOnlyRole,
|
|
||||||
parseSessionRole,
|
|
||||||
type AdminRole,
|
|
||||||
} from "@/lib/auth/roles";
|
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
|
||||||
|
|
||||||
export async function getSessionRole(): Promise<AdminRole | null> {
|
export async function getSessionAccount(): Promise<SessionAccount | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
return parseSessionRole(cookieStore.get(ADMIN_SESSION_COOKIE)?.value);
|
const username = parseSessionToken(
|
||||||
|
cookieStore.get(ADMIN_SESSION_COOKIE)?.value,
|
||||||
|
);
|
||||||
|
if (!username) return null;
|
||||||
|
const record = await findAccountByUsername(username);
|
||||||
|
if (!record) return null;
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
username: record.username,
|
||||||
|
isAdmin: record.isAdmin,
|
||||||
|
permissions: record.isAdmin
|
||||||
|
? fullPagePermissions()
|
||||||
|
: normalizePagePermissions(record.permissions),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireSession(): Promise<AdminRole> {
|
export async function requireSession(): Promise<SessionAccount> {
|
||||||
const role = await getSessionRole();
|
const account = await getSessionAccount();
|
||||||
if (!role) {
|
if (!account) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
return role;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** For server actions and routes that modify database state. */
|
export async function requireAdmin(): Promise<SessionAccount> {
|
||||||
export async function requireWriteAccess(): Promise<AdminRole> {
|
const account = await requireSession();
|
||||||
const role = await requireSession();
|
if (!account.isAdmin) {
|
||||||
if (isReadOnlyRole(role)) {
|
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
return role;
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePageRead(
|
||||||
|
page: PageKey,
|
||||||
|
): Promise<SessionAccount> {
|
||||||
|
const account = await requireSession();
|
||||||
|
if (!canReadPage(account, page)) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePageWrite(
|
||||||
|
page: PageKey,
|
||||||
|
): Promise<SessionAccount> {
|
||||||
|
const account = await requireSession();
|
||||||
|
if (!canWritePage(account, page)) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-15
@@ -1,25 +1,58 @@
|
|||||||
export type AdminRole = "admin" | "supervisor";
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
/** Legacy session cookie value; treated as admin. */
|
/** Legacy cookie values from the old role-based session (invalidate on parse). */
|
||||||
export const LEGACY_ADMIN_SESSION_VALUE = "1";
|
export const LEGACY_ADMIN_SESSION_VALUE = "1";
|
||||||
|
|
||||||
export function roleToSessionCookieValue(role: AdminRole): string {
|
function getSessionSecret(): string {
|
||||||
return role;
|
const explicit = process.env.ADMIN_SESSION_SECRET?.trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const bootstrap = process.env.ADMIN_PASSWORD?.trim();
|
||||||
|
if (bootstrap) return `kickkings-session:${bootstrap}`;
|
||||||
|
return "kickkings-dev-session-secret";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSessionRole(
|
function signUsername(username: string): string {
|
||||||
value: string | undefined,
|
return createHmac("sha256", getSessionSecret())
|
||||||
): AdminRole | null {
|
.update(username)
|
||||||
|
.digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build cookie payload: `username.signature`. */
|
||||||
|
export function createSessionToken(username: string): string {
|
||||||
|
const u = username.trim();
|
||||||
|
return `${u}.${signUsername(u)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and verify a signed session cookie.
|
||||||
|
* Returns the username, or null if invalid / legacy.
|
||||||
|
*/
|
||||||
|
export function parseSessionToken(value: string | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (value === LEGACY_ADMIN_SESSION_VALUE || value === "admin") {
|
// Invalidate old role cookies immediately.
|
||||||
return "admin";
|
if (
|
||||||
}
|
value === LEGACY_ADMIN_SESSION_VALUE ||
|
||||||
if (value === "supervisor") {
|
value === "admin" ||
|
||||||
return "supervisor";
|
value === "supervisor"
|
||||||
}
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = value.lastIndexOf(".");
|
||||||
|
if (dot <= 0 || dot === value.length - 1) return null;
|
||||||
|
const username = value.slice(0, dot);
|
||||||
|
const sig = value.slice(dot + 1);
|
||||||
|
if (!username || !sig) return null;
|
||||||
|
|
||||||
|
const expected = signUsername(username);
|
||||||
|
const a = Buffer.from(sig);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length) return null;
|
||||||
|
if (!timingSafeEqual(a, b)) return null;
|
||||||
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isReadOnlyRole(role: AdminRole): boolean {
|
/** Whether the cookie looks like a valid signed session (for middleware/proxy). */
|
||||||
return role === "supervisor";
|
export function hasValidSessionToken(value: string | undefined): boolean {
|
||||||
|
return parseSessionToken(value) != null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { NextResponse } from "next/server";
|
import type { NextResponse } from "next/server";
|
||||||
import {
|
import { createSessionToken } from "@/lib/auth/roles";
|
||||||
roleToSessionCookieValue,
|
|
||||||
type AdminRole,
|
|
||||||
} from "@/lib/auth/roles";
|
|
||||||
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
|
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
|
||||||
|
|
||||||
import { getRequestOrigin } from "@/lib/request-public-url";
|
import { getRequestOrigin } from "@/lib/request-public-url";
|
||||||
|
|
||||||
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
|
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
|
||||||
@@ -54,11 +50,11 @@ export function getSessionCookieClearOptions(request: Request) {
|
|||||||
export function applySessionCookie(
|
export function applySessionCookie(
|
||||||
res: NextResponse,
|
res: NextResponse,
|
||||||
request: Request,
|
request: Request,
|
||||||
role: AdminRole,
|
username: string,
|
||||||
) {
|
) {
|
||||||
res.cookies.set(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
roleToSessionCookieValue(role),
|
createSessionToken(username),
|
||||||
getSessionCookieSetOptions(request),
|
getSessionCookieSetOptions(request),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type AdminDashboardTab =
|
|||||||
| "matches"
|
| "matches"
|
||||||
| "matchmaker"
|
| "matchmaker"
|
||||||
| "ledger"
|
| "ledger"
|
||||||
| "analysis";
|
| "analysis"
|
||||||
|
| "logs";
|
||||||
|
|
||||||
export type DashboardUrlQuery = {
|
export type DashboardUrlQuery = {
|
||||||
tab: AdminDashboardTab;
|
tab: AdminDashboardTab;
|
||||||
@@ -32,6 +33,10 @@ export type DashboardUrlQuery = {
|
|||||||
analysisTo?: string | null;
|
analysisTo?: string | null;
|
||||||
/** Analysis tab: comma-separated player ids (`aplayers`). */
|
/** Analysis tab: comma-separated player ids (`aplayers`). */
|
||||||
analysisPlayers?: string | null;
|
analysisPlayers?: string | null;
|
||||||
|
/** Matches tab: local date-only bounds (`mfrom` / `mto`) + tz offset minutes (`mtz`). */
|
||||||
|
matchesFrom?: string | null;
|
||||||
|
matchesTo?: string | null;
|
||||||
|
matchesTzOffsetMinutes?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
|
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
|
||||||
@@ -42,6 +47,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
|
|||||||
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
||||||
else if (q.tab === "ledger") p.set("tab", "ledger");
|
else if (q.tab === "ledger") p.set("tab", "ledger");
|
||||||
else if (q.tab === "analysis") p.set("tab", "analysis");
|
else if (q.tab === "analysis") p.set("tab", "analysis");
|
||||||
|
else if (q.tab === "logs") p.set("tab", "logs");
|
||||||
if (q.highlightId) p.set("highlight", q.highlightId);
|
if (q.highlightId) p.set("highlight", q.highlightId);
|
||||||
if (q.participantRaw) p.set("participant", q.participantRaw);
|
if (q.participantRaw) p.set("participant", q.participantRaw);
|
||||||
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
|
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
|
||||||
@@ -72,6 +78,16 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
|
|||||||
if (q.analysisTo) p.set("ato", q.analysisTo);
|
if (q.analysisTo) p.set("ato", q.analysisTo);
|
||||||
if (q.analysisPlayers) p.set("aplayers", q.analysisPlayers);
|
if (q.analysisPlayers) p.set("aplayers", q.analysisPlayers);
|
||||||
}
|
}
|
||||||
|
if (q.tab === "matches") {
|
||||||
|
if (q.matchesFrom) p.set("mfrom", q.matchesFrom);
|
||||||
|
if (q.matchesTo) p.set("mto", q.matchesTo);
|
||||||
|
if (
|
||||||
|
q.matchesTzOffsetMinutes != null &&
|
||||||
|
Number.isFinite(q.matchesTzOffsetMinutes)
|
||||||
|
) {
|
||||||
|
p.set("mtz", String(Math.trunc(q.matchesTzOffsetMinutes)));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (q.editId != null && Number.isFinite(q.editId)) {
|
if (q.editId != null && Number.isFinite(q.editId)) {
|
||||||
p.set("edit", String(q.editId));
|
p.set("edit", String(q.editId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
parseIsoDateOnly,
|
||||||
|
utcLast30DaysDateRange,
|
||||||
|
} from "@/lib/ledger-utc-date-range";
|
||||||
|
|
||||||
|
/** Rolling last 30 local calendar days (browser / given tz offset). */
|
||||||
|
export function localLast30DaysDateRange(
|
||||||
|
tzOffsetMinutes: number,
|
||||||
|
): { from: string; to: string } {
|
||||||
|
const now = new Date();
|
||||||
|
// Instant that has the same Y-M-D components in UTC as "now" in the local zone.
|
||||||
|
const localNowMs = now.getTime() - tzOffsetMinutes * 60_000;
|
||||||
|
const localNow = new Date(localNowMs);
|
||||||
|
const y = localNow.getUTCFullYear();
|
||||||
|
const m = localNow.getUTCMonth();
|
||||||
|
const d = localNow.getUTCDate();
|
||||||
|
const to = new Date(Date.UTC(y, m, d));
|
||||||
|
const from = new Date(to);
|
||||||
|
from.setUTCDate(from.getUTCDate() - 29);
|
||||||
|
return {
|
||||||
|
from: from.toISOString().slice(0, 10),
|
||||||
|
to: to.toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `getTimezoneOffset()`-style minutes from a query param.
|
||||||
|
* Returns null when absent or invalid.
|
||||||
|
*/
|
||||||
|
export function parseTzOffsetMinutes(raw: string | null): number | null {
|
||||||
|
if (raw == null || raw.trim() === "") return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
const trunc = Math.trunc(n);
|
||||||
|
// Valid JS offsets are roughly ±14h; allow a little slack.
|
||||||
|
if (trunc < -16 * 60 || trunc > 16 * 60) return null;
|
||||||
|
return trunc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive local calendar-day bounds → UTC ISO instants for DB filtering.
|
||||||
|
* `tzOffsetMinutes` is `Date#getTimezoneOffset()` (minutes to add to local to get UTC).
|
||||||
|
*/
|
||||||
|
export function localDateRangeToUtcIsoBounds(
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
tzOffsetMinutes: number,
|
||||||
|
): { rangeStartIso: string; rangeEndIso: string } {
|
||||||
|
const [fy, fm, fd] = from.split("-").map(Number) as [number, number, number];
|
||||||
|
const [ty, tm, td] = to.split("-").map(Number) as [number, number, number];
|
||||||
|
const startUtcMs =
|
||||||
|
Date.UTC(fy, fm - 1, fd, 0, 0, 0, 0) + tzOffsetMinutes * 60_000;
|
||||||
|
const endUtcMs =
|
||||||
|
Date.UTC(ty, tm - 1, td, 23, 59, 59, 999) + tzOffsetMinutes * 60_000;
|
||||||
|
return {
|
||||||
|
rangeStartIso: new Date(startUtcMs).toISOString(),
|
||||||
|
rangeEndIso: new Date(endUtcMs).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize match date bounds; uses local last-30 when tz is known, else UTC last-30. */
|
||||||
|
export function normalizeMatchesDateRange(
|
||||||
|
fromRaw: string | null,
|
||||||
|
toRaw: string | null,
|
||||||
|
tzOffsetMinutes: number | null,
|
||||||
|
): { from: string; to: string } {
|
||||||
|
const fallback =
|
||||||
|
tzOffsetMinutes != null
|
||||||
|
? localLast30DaysDateRange(tzOffsetMinutes)
|
||||||
|
: utcLast30DaysDateRange();
|
||||||
|
let from = (fromRaw ?? "").trim();
|
||||||
|
let to = (toRaw ?? "").trim();
|
||||||
|
if (!parseIsoDateOnly(from) || !parseIsoDateOnly(to)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (from > to) {
|
||||||
|
const t = from;
|
||||||
|
from = to;
|
||||||
|
to = t;
|
||||||
|
}
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format an ISO timestamp in the viewer's local timezone (client-only). */
|
||||||
|
export function formatLocalTimestamp(value: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = pad(d.getMonth() + 1);
|
||||||
|
const day = pad(d.getDate());
|
||||||
|
const h = pad(d.getHours());
|
||||||
|
const min = pad(d.getMinutes());
|
||||||
|
const s = pad(d.getSeconds());
|
||||||
|
return `${y}-${m}-${day} ${h}:${min}:${s}`;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ export type MatchEntryHoldAndFeePerPlayerCoins = {
|
|||||||
feePerPlayer: Map<number, bigint>;
|
feePerPlayer: Map<number, bigint>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** PostgREST / Supabase default max rows per request. */
|
||||||
|
const TX_PAGE_SIZE = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For each match_id, average debit amount for `entry_hold` and `entry_fee`
|
* For each match_id, average debit amount for `entry_hold` and `entry_fee`
|
||||||
* (per player when two rows exist; sum/count handles odd counts).
|
* (per player when two rows exist; sum/count handles odd counts).
|
||||||
@@ -44,15 +47,6 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
|||||||
};
|
};
|
||||||
if (matchIds.length === 0) return empty;
|
if (matchIds.length === 0) return empty;
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("transactions")
|
|
||||||
.select("match_id, amount, remarks")
|
|
||||||
.in("match_id", matchIds);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
const holdAgg: PerRemarkAgg = {
|
const holdAgg: PerRemarkAgg = {
|
||||||
sums: new Map(),
|
sums: new Map(),
|
||||||
counts: new Map(),
|
counts: new Map(),
|
||||||
@@ -62,10 +56,26 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
|||||||
counts: new Map(),
|
counts: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const row of (data ?? []) as Pick<
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("transactions")
|
||||||
|
.select("match_id, amount, remarks")
|
||||||
|
.in("match_id", matchIds)
|
||||||
|
.in("remarks", ["entry_hold", "entry_fee"])
|
||||||
|
.order("id", { ascending: true })
|
||||||
|
.range(offset, offset + TX_PAGE_SIZE - 1);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = (data ?? []) as Pick<
|
||||||
DbTransaction,
|
DbTransaction,
|
||||||
"match_id" | "amount" | "remarks"
|
"match_id" | "amount" | "remarks"
|
||||||
>[]) {
|
>[];
|
||||||
|
|
||||||
|
for (const row of batch) {
|
||||||
const rmk = normRemark(row.remarks);
|
const rmk = normRemark(row.remarks);
|
||||||
if (rmk !== "entry_hold" && rmk !== "entry_fee") continue;
|
if (rmk !== "entry_hold" && rmk !== "entry_fee") continue;
|
||||||
const midRaw = row.match_id;
|
const midRaw = row.match_id;
|
||||||
@@ -79,6 +89,10 @@ export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
|||||||
else bumpAgg(feeAgg, mid, a);
|
else bumpAgg(feeAgg, mid, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batch.length < TX_PAGE_SIZE) break;
|
||||||
|
offset += TX_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
holdPerPlayer: avgPerMatch(holdAgg),
|
holdPerPlayer: avgPerMatch(holdAgg),
|
||||||
feePerPlayer: avgPerMatch(feeAgg),
|
feePerPlayer: avgPerMatch(feeAgg),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
|
|||||||
import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation";
|
import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation";
|
||||||
import type { DbPingReport } from "@/types/database";
|
import type { DbPingReport } from "@/types/database";
|
||||||
|
|
||||||
const BAD_PING_THRESHOLD_MS = 120;
|
const BAD_PING_THRESHOLD_MS = 200;
|
||||||
const TOP_ROWS_LIMIT = 20;
|
const TOP_ROWS_LIMIT = 20;
|
||||||
|
|
||||||
type PingReportWithUser = DbPingReport & {
|
type PingReportWithUser = DbPingReport & {
|
||||||
|
|||||||
+4
-2
@@ -1,11 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { parseSessionRole } from "@/lib/auth/roles";
|
import { hasValidSessionToken } from "@/lib/auth/roles";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
import { publicRequestUrl } from "@/lib/request-public-url";
|
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||||
|
|
||||||
function hasValidSession(request: NextRequest): boolean {
|
function hasValidSession(request: NextRequest): boolean {
|
||||||
return parseSessionRole(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) != null;
|
return hasValidSessionToken(
|
||||||
|
request.cookies.get(ADMIN_SESSION_COOKIE)?.value,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
|
|||||||
Reference in New Issue
Block a user