import { Head } from "fresh/runtime";
import { define, type SessionUser } from "../utils.ts";
import { deleteUser, setUserRole } from "@/lib/auth.ts";
import { type Account, listAccounts } from "@/lib/admin.ts";
import { CLASSES } from "@/lib/game.ts";
import { devAutoLoginEnabled } from "@/lib/dev-auth.ts";
export const handler = define.handlers({
async GET(ctx) {
if (!ctx.state.user) return ctx.redirect("/login");
if (ctx.state.user.role !== "admin") {
return new Response("Not found", { status: 404 });
}
return ctx.render(
<AdminPage
accounts={await listAccounts()}
me={ctx.state.user}
devMode={devAutoLoginEnabled()}
/>,
);
},
async POST(ctx) {
const me = ctx.state.user;
if (!me || me.role !== "admin") {
return new Response("Not found", { status: 404 });
}
const form = await ctx.req.formData();
const action = String(form.get("action") ?? "");
const userId = Number(form.get("userId"));
if (Number.isInteger(userId) && userId > 0 && userId !== me.id) {
if (action === "promote") await setUserRole(userId, "admin");
else if (action === "demote") await setUserRole(userId, "user");
else if (action === "delete") await deleteUser(userId);
}
return new Response(null, { status: 303, headers: { location: "/admin" } });
},
});
function RoleForm(
{ userId, action, label, style }: {
userId: number;
action: string;
label: string;
style: string;
},
) {
return (
<form method="post">
<input type="hidden" name="userId" value={userId} />
<button type="submit" name="action" value={action} class={style}>
{label}
</button>
</form>
);
}
function HeroCell({ hero }: { hero: Account["hero"] }) {
if (!hero) {
return (
<span class="text-xs uppercase tracking-[0.2em] text-base-content/40">
Unforged
</span>
);
}
const klass = CLASSES[hero.class];
return (
<div class="flex items-center gap-3">
<span class="shield-mount size-9 shrink-0">
<img
src={klass.art}
alt=""
class={`shield mystic-class-icon mystic-class-${klass.id}`}
/>
</span>
<div class="min-w-0">
<p class="font-display truncate text-sm tracking-[0.08em] text-accent">
{hero.name}
</p>
<p class="text-[0.6rem] uppercase tracking-[0.2em] text-base-content/55">
{klass.name}
</p>
</div>
</div>
);
}
function DeleteAction({ account }: { account: Account }) {
const modalId = `banish-${account.id}`;
return (
<>
<button
type="button"
popovertarget={modalId}
class="btn btn-error btn-soft btn-xs"
>
Banish
</button>
<div class="modal" id={modalId} popover>
{/* whitespace-normal: the popover lives inside a nowrap table cell. */}
<div class="modal-box arch-panel vellum max-w-md whitespace-normal">
<div class="corner-fleurons px-4 py-3">
<h3 class="font-heading text-center text-lg uppercase tracking-[0.25em] text-error">
Banish this soul?
</h3>
<div class="fleuron-rule my-3">
<span aria-hidden="true">☾ ✧ ☽</span>
</div>
<p class="text-center text-sm text-base-content/75">
<span class="text-accent">{account.email}</span>{" "}
and every hero, card and record bound to it will be struck from
the chronicle. This cannot be undone.
</p>
<div class="modal-action justify-center">
<button
type="button"
popovertarget={modalId}
popovertargetaction="hide"
class="btn btn-ghost btn-sm"
>
Spare them
</button>
<RoleForm
userId={account.id}
action="delete"
label="Banish"
style="btn btn-error iron-band btn-sm"
/>
</div>
</div>
</div>
<div class="modal-backdrop">
<button
type="button"
popovertarget={modalId}
popovertargetaction="hide"
>
Close
</button>
</div>
</div>
</>
);
}
export function AdminPage(
{ accounts, me, devMode }: {
accounts: Account[];
me: SessionUser;
devMode: boolean;
},
) {
const admins = accounts.filter((a) => a.role === "admin").length;
const heroes = accounts.filter((a) => a.hero).length;
return (
<div class="tapestry min-h-dvh flex flex-col">
<Head>
<title>Profectus — Administration</title>
</Head>
<div class="navbar rivets min-h-16 border-b-0 px-3 sm:px-4">
<div class="navbar-start">
<span class="font-gothic tracking-[0.18em] text-accent text-lg sm:text-xl">
Profectus
</span>
</div>
<div class="navbar-center">
<span class="text-xs uppercase tracking-[0.3em] text-base-content/65">
Chronicle of Souls
</span>
</div>
<div class="navbar-end gap-2">
<a href="/" class="btn btn-ghost btn-sm">Back to game</a>
<a href="/logout" class="btn btn-ghost btn-sm hidden sm:inline-flex">
Log out
</a>
</div>
</div>
<main class="margin-vines flex-1 px-4 py-10 sm:py-14">
<div class="relative z-1 mx-auto w-full max-w-5xl">
<header class="text-center mb-8">
<h1 class="font-gothic text-4xl md:text-5xl tracking-[0.16em] text-accent">
Administration
</h1>
<div class="fleuron-rule mt-4 w-72 mx-auto">
<span aria-hidden="true">☾ ✦ ☽</span>
</div>
</header>
{devMode && (
<div role="alert" class="alert alert-warning alert-soft mb-8">
<span class="text-sm">
Local development: unauthenticated visitors are signed in
automatically as <span class="font-bold">{me.email}</span>. Set
{" "}
<code class="kbd kbd-sm">DEV_AUTOLOGIN=off</code>{" "}
to disable this.
</span>
</div>
)}
<div class="stats stats-vertical sm:stats-horizontal w-full mb-8 bg-transparent">
<div class="stat place-items-center">
<div class="stat-title text-[0.6rem] uppercase tracking-[0.3em]">
Souls
</div>
<div class="stat-value font-display text-primary text-3xl">
{accounts.length}
</div>
</div>
<div class="stat place-items-center">
<div class="stat-title text-[0.6rem] uppercase tracking-[0.3em]">
Heroes forged
</div>
<div class="stat-value font-display text-accent text-3xl">
{heroes}
</div>
</div>
<div class="stat place-items-center">
<div class="stat-title text-[0.6rem] uppercase tracking-[0.3em]">
Wardens
</div>
<div class="stat-value font-display text-secondary text-3xl">
{admins}
</div>
</div>
</div>
<section class="arch-panel vellum card">
<div class="corner-fleurons card-body p-4 sm:p-6">
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr class="text-[0.6rem] uppercase tracking-[0.25em] text-base-content/60">
<th>Account</th>
<th>Hero</th>
<th class="text-right">Level</th>
<th class="text-right">Anima</th>
<th class="text-right">Cards</th>
<th>Standing</th>
<th class="text-right">Rites</th>
</tr>
</thead>
<tbody>
{accounts.map((account) => {
const isSelf = account.id === me.id;
return (
<tr key={account.id}>
<td>
<p class="text-sm text-base-content/90">
{account.email}
</p>
<p class="text-[0.6rem] uppercase tracking-[0.2em] text-base-content/45">
Joined {account.createdAt}
</p>
</td>
<td>
<HeroCell hero={account.hero} />
</td>
<td class="text-right font-display text-primary">
{account.hero?.level ?? "—"}
</td>
<td class="text-right">
{account.hero
? (
<span class="badge badge-sm anima-coin font-bold whitespace-nowrap">
{account.hero.anima} ✦
</span>
)
: "—"}
</td>
<td class="text-right font-display text-accent">
{account.hero?.deckSize ?? "—"}
</td>
<td>
<div class="flex items-center gap-2">
<span
class={`badge badge-sm ${
account.role === "admin"
? "badge-primary"
: "badge-ghost"
}`}
>
{account.role === "admin" ? "Warden" : "Seeker"}
</span>
{isSelf && (
<span class="badge badge-sm badge-outline">
you
</span>
)}
</div>
</td>
<td class="text-right whitespace-nowrap">
{isSelf
? <span class="text-base-content/40">—</span>
: (
<div class="inline-flex items-center gap-2">
{account.role === "admin"
? (
<RoleForm
userId={account.id}
action="demote"
label="Demote"
style="btn btn-ghost btn-xs"
/>
)
: (
<RoleForm
userId={account.id}
action="promote"
label="Anoint"
style="btn btn-ghost btn-xs"
/>
)}
<DeleteAction account={account} />
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</section>
</div>
</main>
</div>
);
}
export default AdminPage;