account system and syslogs
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
createAdminAccount,
|
||||
deleteAdminAccount,
|
||||
updateAdminAccount,
|
||||
} from "@/app/actions/account-actions";
|
||||
import {
|
||||
PAGE_KEYS,
|
||||
type PageKey,
|
||||
type PagePermissions,
|
||||
type SessionAccount,
|
||||
} from "@/lib/auth/permissions";
|
||||
|
||||
const PAGE_LABELS: Record<PageKey, string> = {
|
||||
players: "Players",
|
||||
matches: "Matches",
|
||||
analysis: "Analysis",
|
||||
matchmaker: "Matchmaker",
|
||||
ledger: "Ledger",
|
||||
logs: "System logs",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
accounts: SessionAccount[];
|
||||
accountError: string | null;
|
||||
accountOk: string | null;
|
||||
};
|
||||
|
||||
function errorMessage(code: string | null): string | null {
|
||||
if (!code) return null;
|
||||
switch (code) {
|
||||
case "missingUsername":
|
||||
return "Username is required.";
|
||||
case "missingPassword":
|
||||
return "Password is required.";
|
||||
case "duplicate":
|
||||
return "That username is already taken.";
|
||||
case "notFound":
|
||||
return "Account not found.";
|
||||
case "cannotDeleteAdmin":
|
||||
return "The admin account cannot be deleted.";
|
||||
default:
|
||||
return "Could not update accounts. Try again.";
|
||||
}
|
||||
}
|
||||
|
||||
function okMessage(code: string | null): string | null {
|
||||
if (!code) return null;
|
||||
switch (code) {
|
||||
case "created":
|
||||
return "Account created.";
|
||||
case "updated":
|
||||
return "Account updated.";
|
||||
case "deleted":
|
||||
return "Account deleted.";
|
||||
default:
|
||||
return "Done.";
|
||||
}
|
||||
}
|
||||
|
||||
function PermissionCheckboxes({
|
||||
idPrefix,
|
||||
permissions,
|
||||
onChange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
permissions: PagePermissions;
|
||||
onChange: (next: PagePermissions) => void;
|
||||
}) {
|
||||
function setPerm(page: PageKey, field: "read" | "write", value: boolean) {
|
||||
const next = { ...permissions, [page]: { ...permissions[page] } };
|
||||
if (page === "logs") {
|
||||
next[page] = { read: field === "read" ? value : next[page].read, write: false };
|
||||
onChange(next);
|
||||
return;
|
||||
}
|
||||
if (field === "write") {
|
||||
next[page] = { write: value, read: value ? true : next[page].read };
|
||||
} else {
|
||||
next[page] = {
|
||||
read: value,
|
||||
write: value ? next[page].write : false,
|
||||
};
|
||||
}
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[320px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
<th className="py-2 pr-3 font-medium">Page</th>
|
||||
<th className="py-2 px-2 font-medium">Read</th>
|
||||
<th className="py-2 px-2 font-medium">Write</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-zinc-100 dark:border-zinc-800">
|
||||
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
|
||||
Overview
|
||||
</td>
|
||||
<td className="py-2 px-2 text-zinc-500" colSpan={2}>
|
||||
Always enabled
|
||||
</td>
|
||||
</tr>
|
||||
{PAGE_KEYS.map((page) => (
|
||||
<tr
|
||||
key={page}
|
||||
className="border-b border-zinc-100 dark:border-zinc-800"
|
||||
>
|
||||
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
|
||||
{PAGE_LABELS[page]}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`${idPrefix}_${page}_read`}
|
||||
name={`perm_${page}_read`}
|
||||
value="1"
|
||||
checked={permissions[page].read}
|
||||
onChange={(e) => setPerm(page, "read", e.target.checked)}
|
||||
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
{page === "logs" ? (
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`${idPrefix}_${page}_write`}
|
||||
name={`perm_${page}_write`}
|
||||
value="1"
|
||||
checked={permissions[page].write}
|
||||
onChange={(e) => setPerm(page, "write", e.target.checked)}
|
||||
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function permissionSummary(account: SessionAccount): string {
|
||||
if (account.isAdmin) return "Full access";
|
||||
const parts: string[] = ["Overview"];
|
||||
for (const page of PAGE_KEYS) {
|
||||
const p = account.permissions[page];
|
||||
if (p.write) parts.push(`${PAGE_LABELS[page]} (rw)`);
|
||||
else if (p.read) parts.push(`${PAGE_LABELS[page]} (r)`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function AccountEditForm({
|
||||
account,
|
||||
onClose,
|
||||
}: {
|
||||
account: SessionAccount;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState(account.username);
|
||||
const [password, setPassword] = useState("");
|
||||
const [permissions, setPermissions] = useState(account.permissions);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={updateAdminAccount}
|
||||
className="mt-3 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-950/60"
|
||||
>
|
||||
<input type="hidden" name="id" value={account.id} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="isAdmin"
|
||||
value={account.isAdmin ? "1" : "0"}
|
||||
/>
|
||||
|
||||
{account.isAdmin ? (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Admin account — username is fixed; only the password can be changed.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`edit-username-${account.id}`}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id={`edit-username-${account.id}`}
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`edit-password-${account.id}`}
|
||||
>
|
||||
{account.isAdmin ? "New password" : "New password (optional)"}
|
||||
</label>
|
||||
<input
|
||||
id={`edit-password-${account.id}`}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={account.isAdmin}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!account.isAdmin ? (
|
||||
<PermissionCheckboxes
|
||||
idPrefix={`edit_${account.id}`}
|
||||
permissions={permissions}
|
||||
onChange={setPermissions}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAccountForm() {
|
||||
const [permissions, setPermissions] = useState<PagePermissions>({
|
||||
players: { read: false, write: false },
|
||||
matches: { read: false, write: false },
|
||||
analysis: { read: false, write: false },
|
||||
matchmaker: { read: false, write: false },
|
||||
ledger: { read: false, write: false },
|
||||
logs: { read: false, write: false },
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
action={createAdminAccount}
|
||||
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Add account
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor="new-account-username"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="new-account-username"
|
||||
name="username"
|
||||
required
|
||||
autoComplete="off"
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor="new-account-password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="new-account-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PermissionCheckboxes
|
||||
idPrefix="new"
|
||||
permissions={permissions}
|
||||
onChange={setPermissions}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminAccountsEditor({
|
||||
accounts,
|
||||
accountError,
|
||||
accountOk,
|
||||
}: Props) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const err = errorMessage(accountError);
|
||||
const ok = okMessage(accountOk);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Accounts
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Manage panel logins and per-page read/write access. Only the admin
|
||||
account can manage other accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<div
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
|
||||
role="alert"
|
||||
>
|
||||
{err}
|
||||
</div>
|
||||
) : null}
|
||||
{ok ? (
|
||||
<div
|
||||
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
role="status"
|
||||
>
|
||||
{ok}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
Existing accounts
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<li
|
||||
key={account.id}
|
||||
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{account.username}
|
||||
{account.isAdmin ? (
|
||||
<span className="ml-2 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
Admin
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{permissionSummary(account)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditingId((id) =>
|
||||
id === account.id ? null : account.id,
|
||||
)
|
||||
}
|
||||
className="rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{editingId === account.id ? "Close" : "Edit"}
|
||||
</button>
|
||||
{!account.isAdmin ? (
|
||||
<form action={deleteAdminAccount}>
|
||||
<input type="hidden" name="id" value={account.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||
onClick={(e) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete account “${account.username}”?`,
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{editingId === account.id ? (
|
||||
<AccountEditForm
|
||||
account={account}
|
||||
onClose={() => setEditingId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<AddAccountForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user