Files
kickkingsadmin/src/components/admin-accounts-editor.tsx
T
2026-09-14 13:23:14 +00:00

432 lines
14 KiB
TypeScript

"use client";
import { useState } from "react";
import {
createAdminAccount,
deleteAdminAccount,
updateAdminAccount,
} from "@/app/actions/account-actions";
import {
emptyPagePermissions,
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",
"founders-card": "Founders card",
};
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>(() =>
emptyPagePermissions(),
);
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>
);
}