432 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|