2026-05-15 03:11:41 +02:00
|
|
|
import { AppShell } from "@/components/app-shell";
|
2026-05-15 17:47:42 +02:00
|
|
|
import { Avatar } from "@/components/avatar";
|
2026-05-15 03:11:41 +02:00
|
|
|
import { StatusBadge } from "@/components/status-badge";
|
|
|
|
|
import { SYSTEM_PERMISSIONS } from "@/lib/access";
|
2026-05-15 17:47:42 +02:00
|
|
|
import { prisma } from "@/lib/prisma";
|
2026-05-15 17:32:26 +02:00
|
|
|
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
|
|
|
|
import { requireInitialSetup } from "@/lib/setup";
|
2026-05-15 17:47:42 +02:00
|
|
|
import Link from "next/link";
|
2026-05-15 17:32:26 +02:00
|
|
|
import { redirect } from "next/navigation";
|
|
|
|
|
|
|
|
|
|
export default async function AdminPage() {
|
|
|
|
|
await requireInitialSetup();
|
|
|
|
|
const user = await requireCurrentUser();
|
|
|
|
|
|
|
|
|
|
if (!userIsAdmin(user)) {
|
|
|
|
|
redirect("/dashboard");
|
|
|
|
|
}
|
2026-05-15 03:11:41 +02:00
|
|
|
|
2026-05-15 17:47:42 +02:00
|
|
|
const [personalRoom, users, rooms, roles] = await Promise.all([
|
|
|
|
|
prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } }),
|
|
|
|
|
prisma.user.findMany({
|
|
|
|
|
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
|
|
|
|
|
orderBy: { createdAt: "asc" }
|
|
|
|
|
}),
|
|
|
|
|
prisma.room.findMany({
|
|
|
|
|
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
|
|
|
|
|
orderBy: { updatedAt: "desc" }
|
|
|
|
|
}),
|
|
|
|
|
prisma.role.findMany({
|
|
|
|
|
include: { _count: { select: { users: true, permissions: true } } },
|
|
|
|
|
orderBy: { name: "asc" }
|
|
|
|
|
})
|
|
|
|
|
]);
|
|
|
|
|
|
2026-05-15 03:11:41 +02:00
|
|
|
return (
|
2026-05-15 17:47:42 +02:00
|
|
|
<AppShell
|
|
|
|
|
active="Admin"
|
|
|
|
|
isAdmin
|
|
|
|
|
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
|
|
|
|
|
userName={user.displayName || user.username}
|
|
|
|
|
>
|
2026-05-15 03:11:41 +02:00
|
|
|
<header className="topbar">
|
|
|
|
|
<div className="title-block">
|
|
|
|
|
<h1>Admin</h1>
|
|
|
|
|
<p>Manage roles, rooms, permissions, and users.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<StatusBadge tone="good">Admin</StatusBadge>
|
|
|
|
|
</header>
|
|
|
|
|
<section className="room-layout">
|
|
|
|
|
<section className="panel">
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
<h2>Rooms</h2>
|
2026-05-15 17:47:42 +02:00
|
|
|
<StatusBadge>{rooms.length} total</StatusBadge>
|
2026-05-15 03:11:41 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="panel-body">
|
|
|
|
|
<table className="table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
<th>Owner</th>
|
|
|
|
|
<th>Access</th>
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{rooms.map((room) => (
|
2026-05-15 17:47:42 +02:00
|
|
|
<tr key={room.id}>
|
|
|
|
|
<td>
|
|
|
|
|
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
2026-05-15 03:11:41 +02:00
|
|
|
<td>{room.visibility}</td>
|
2026-05-15 17:47:42 +02:00
|
|
|
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
|
2026-05-15 03:11:41 +02:00
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-15 17:47:42 +02:00
|
|
|
<section className="panel">
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
<h2>Users</h2>
|
|
|
|
|
<StatusBadge>{users.length} accounts</StatusBadge>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="panel-body">
|
|
|
|
|
{users.map((account) => (
|
|
|
|
|
<div className="row" key={account.id}>
|
|
|
|
|
<div className="row-main">
|
|
|
|
|
<Avatar name={account.displayName || account.username} />
|
|
|
|
|
<div className="row-title">
|
|
|
|
|
<strong>{account.displayName || account.username}</strong>
|
|
|
|
|
<span>@{account.username} · {account._count.ownedRooms + account._count.roomMembers} rooms</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="status-row">
|
|
|
|
|
{account.roles.map((userRole) => (
|
|
|
|
|
<StatusBadge key={userRole.roleId} tone={userRole.role.name === "admin" ? "good" : undefined}>
|
|
|
|
|
{userRole.role.name}
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
))}
|
|
|
|
|
{account.roles.length === 0 ? <StatusBadge>user</StatusBadge> : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</section>
|
|
|
|
|
<section className="room-layout" style={{ marginTop: 18 }}>
|
|
|
|
|
<section className="panel">
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
<h2>Roles</h2>
|
|
|
|
|
<StatusBadge>{roles.length} defined</StatusBadge>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="panel-body">
|
|
|
|
|
{roles.map((role) => (
|
|
|
|
|
<div className="row" key={role.id}>
|
|
|
|
|
<div className="row-title">
|
|
|
|
|
<strong>{role.name}</strong>
|
|
|
|
|
<span>{role.description || `${role.scope.toLowerCase()} role`}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<StatusBadge>{role._count.users} users / {role._count.permissions} permissions</StatusBadge>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-15 03:11:41 +02:00
|
|
|
<section className="panel">
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
<h2>Permissions</h2>
|
2026-05-15 17:47:42 +02:00
|
|
|
<StatusBadge>System</StatusBadge>
|
2026-05-15 03:11:41 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="panel-body">
|
|
|
|
|
{SYSTEM_PERMISSIONS.map((permission) => (
|
|
|
|
|
<div className="row" key={permission}>
|
|
|
|
|
<div className="row-title">
|
|
|
|
|
<strong>{permission}</strong>
|
|
|
|
|
<span>Assignable to roles</span>
|
|
|
|
|
</div>
|
|
|
|
|
<StatusBadge>Enabled</StatusBadge>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</section>
|
|
|
|
|
</AppShell>
|
|
|
|
|
);
|
|
|
|
|
}
|