Add register + logout auth flows, and batch-5 resources
Auth (hand-built, uses the auth core): - /register + register() action: validates, hashes with argon2id (hashPassword), creates an emulator-compatible users row (accountCreated/ipRegister/ipCurrent/ look), redirect kept outside try so NEXT_REDIRECT propagates. Login/register cross-links; header shows Register (logged-out) and a Logout (signOut) button. Batch 5 (parallel agents): /admin/rooms (+[id]) search+detail, /admin/vouchers (CRUD), /admin/subscriptions (read), /admin/calendar (campaigns+rewards read), /marketplace (public). Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build exit 0 (52 page routes).
This commit is contained in:
1 parent
e8be0461d8
commit
08a9882be8
13 files changed
+1001
-4
No files matched your search
@@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminRoomDetail({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
// rooms is an emulator-owned table → Int id.
|
||||
const roomId = Number(id);
|
||||
|
||||
if (!Number.isInteger(roomId) || roomId <= 0) notFound();
|
||||
|
||||
let room: Awaited<ReturnType<typeof prisma.rooms.findUnique>> = null;
|
||||
try {
|
||||
room = await prisma.rooms.findUnique({ where: { id: roomId } });
|
||||
} catch {
|
||||
room = null;
|
||||
}
|
||||
if (!room) notFound();
|
||||
|
||||
const fields: Array<[string, string]> = [
|
||||
["ID", String(room.id)],
|
||||
["Name", room.name || "(unnamed)"],
|
||||
["Description", room.description || "—"],
|
||||
["Owner", `${room.ownerName || "—"} (#${room.ownerId})`],
|
||||
["State", room.state],
|
||||
["Users", `${room.users} / ${room.usersMax}`],
|
||||
["Category", String(room.category)],
|
||||
["Score", String(room.score)],
|
||||
["Model", room.model],
|
||||
["Guild ID", room.guildId ? String(room.guildId) : "—"],
|
||||
["Tags", room.tags || "—"],
|
||||
["Password", room.password ? "yes" : "—"],
|
||||
["Public", room.isPublic === "1" ? "yes" : "no"],
|
||||
["Staff picked", room.isStaffPicked === "1" ? "yes" : "no"],
|
||||
["For sale", room.isForSale === "1" ? "yes" : "no"],
|
||||
["Trade mode", String(room.tradeMode)],
|
||||
];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/rooms">← Rooms</Link>
|
||||
</p>
|
||||
<h1>{room.name || "(unnamed)"}</h1>
|
||||
<p className="muted">
|
||||
ID {room.id} · owner {room.ownerName || "—"} · {room.users}/{room.usersMax}{" "}
|
||||
users · {room.state}
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
<table>
|
||||
<tbody>
|
||||
{fields.map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<th style={{ width: "35%", whiteSpace: "nowrap" }}>{label}</th>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{room.ownerId ? (
|
||||
<p className="muted" style={{ marginTop: "1rem" }}>
|
||||
<Link href={`/admin/users/${room.ownerId}`}>View owner →</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PER_PAGE = 25;
|
||||
|
||||
type RoomRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
ownerName: string;
|
||||
users: number;
|
||||
usersMax: number;
|
||||
state: string;
|
||||
category: number;
|
||||
};
|
||||
|
||||
export default async function AdminRooms({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const q = (sp.q ?? "").trim();
|
||||
const page = Math.max(1, Number(sp.page ?? "1") || 1);
|
||||
|
||||
// Search matches room name OR owner username (ownerName is denormalised on the
|
||||
// emulator rooms table, so no join is needed).
|
||||
const where = q
|
||||
? { OR: [{ name: { contains: q } }, { ownerName: { contains: q } }] }
|
||||
: {};
|
||||
|
||||
let rooms: RoomRow[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
[rooms, total] = await Promise.all([
|
||||
prisma.rooms.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerName: true,
|
||||
users: true,
|
||||
usersMax: true,
|
||||
state: true,
|
||||
category: true,
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
skip: (page - 1) * PER_PAGE,
|
||||
take: PER_PAGE,
|
||||
}),
|
||||
prisma.rooms.count({ where }),
|
||||
]);
|
||||
} catch {
|
||||
rooms = [];
|
||||
total = 0;
|
||||
}
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
const qs = (p: number) =>
|
||||
`/admin/rooms?q=${encodeURIComponent(q)}&page=${p}`;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Rooms</h1>
|
||||
|
||||
<form style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||
<input name="q" defaultValue={q} placeholder="Search room or owner" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Users</th>
|
||||
<th>State</th>
|
||||
<th>Cat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/rooms/${r.id}`}>{r.name || "(unnamed)"}</Link>
|
||||
</td>
|
||||
<td>{r.ownerName || "—"}</td>
|
||||
<td>
|
||||
{r.users} / {r.usersMax}
|
||||
</td>
|
||||
<td>{r.state}</td>
|
||||
<td>{r.category}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rooms.length === 0 ? (
|
||||
<p className="muted" style={{ marginTop: "1rem" }}>
|
||||
No rooms found.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="muted" style={{ marginTop: "1rem" }}>
|
||||
Page {page} / {pages} · {total} rooms
|
||||
{page > 1 ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={qs(page - 1)}>← Prev</Link>
|
||||
</>
|
||||
) : null}
|
||||
{page < pages ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={qs(page + 1)}>Next →</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user