Redesign WatchLink application UI
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Database, LockKeyhole, Plus, Search, Shield, UsersRound } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { SYSTEM_PERMISSIONS } from "@/lib/access";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getShellContext } from "@/lib/shell";
|
||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||
import { requireInitialSetup } from "@/lib/setup";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"];
|
||||
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string }> }) {
|
||||
await requireInitialSetup();
|
||||
const user = await requireCurrentUser();
|
||||
|
||||
@@ -18,8 +23,12 @@ export default async function AdminPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const [personalRoom, users, rooms, roles] = await Promise.all([
|
||||
prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } }),
|
||||
const { tab = "Users", q = "" } = await searchParams;
|
||||
const activeTab = adminTabs.includes(tab) ? tab : "Users";
|
||||
const query = q.trim().toLowerCase();
|
||||
const shell = await getShellContext(user);
|
||||
|
||||
const [users, rooms, roles, pendingRequests] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
|
||||
orderBy: { createdAt: "asc" }
|
||||
@@ -31,118 +40,292 @@ export default async function AdminPage() {
|
||||
prisma.role.findMany({
|
||||
include: { _count: { select: { users: true, permissions: true } } },
|
||||
orderBy: { name: "asc" }
|
||||
})
|
||||
}),
|
||||
prisma.friendship.count({ where: { status: "PENDING" } })
|
||||
]);
|
||||
const filteredUsers = query
|
||||
? users.filter((account) =>
|
||||
[account.username, account.displayName || "", ...account.roles.map((role) => role.role.name)].join(" ").toLowerCase().includes(query)
|
||||
)
|
||||
: users;
|
||||
const filteredRooms = query
|
||||
? rooms.filter((room) =>
|
||||
[room.name, room.slug, room.visibility, room.owner?.username || "", room.owner?.displayName || ""].join(" ").toLowerCase().includes(query)
|
||||
)
|
||||
: rooms;
|
||||
const filteredRoles = query
|
||||
? roles.filter((role) => [role.name, role.description || "", role.scope].join(" ").toLowerCase().includes(query))
|
||||
: roles;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
active="Admin"
|
||||
isAdmin
|
||||
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
|
||||
userName={user.displayName || user.username}
|
||||
>
|
||||
<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>
|
||||
<StatusBadge>{rooms.length} total</StatusBadge>
|
||||
</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) => (
|
||||
<tr key={room.id}>
|
||||
<td>
|
||||
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
||||
</td>
|
||||
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||
<td>{room.visibility}</td>
|
||||
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<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}>
|
||||
<AppShell active="Admin" {...shell}>
|
||||
<PageHeader
|
||||
title="Admin"
|
||||
description="Operational management for accounts, rooms, roles, instance state, and security controls."
|
||||
meta={
|
||||
<>
|
||||
<StatusDot tone="good" label="admin session" />
|
||||
<StatusDot tone="good" label={`${users.length} users`} />
|
||||
<StatusDot tone={pendingRequests > 0 ? "warn" : "neutral"} label={`${pendingRequests} pending requests`} />
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<button className="button" type="button" disabled title="Invite records are not implemented yet."><Plus size={16} /> Invite</button>
|
||||
<button className="button" type="button" disabled title="Audit events need a persisted event table first."><Shield size={16} /> Audit</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="metrics-grid">
|
||||
<MetricTile label="Users" value={users.length} detail="local accounts" tone="good" />
|
||||
<MetricTile label="Rooms" value={rooms.length} detail="persistent room records" tone="info" />
|
||||
<MetricTile label="Roles" value={roles.length} detail="system and room scopes" />
|
||||
<MetricTile label="Permissions" value={SYSTEM_PERMISSIONS.length} detail="known permission keys" tone="warn" />
|
||||
</section>
|
||||
|
||||
<Panel
|
||||
title="Admin Workspace"
|
||||
eyebrow="Management"
|
||||
actions={
|
||||
<form className="toolbar" action="/admin">
|
||||
<input type="hidden" name="tab" value={activeTab} />
|
||||
<label className="search-field">
|
||||
<Search size={15} />
|
||||
<input name="q" placeholder={`Search ${activeTab.toLowerCase()}`} defaultValue={q} />
|
||||
</label>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<Tabs active={activeTab} items={adminTabs.map((item) => ({ label: item, href: `/admin?tab=${encodeURIComponent(item)}` }))} />
|
||||
|
||||
{activeTab === "Users" ? <UsersTable users={filteredUsers} /> : null}
|
||||
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
|
||||
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
|
||||
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null}
|
||||
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} /> : null}
|
||||
{activeTab === "Security" ? <SecurityPanel /> : null}
|
||||
</Panel>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersTable({
|
||||
users
|
||||
}: {
|
||||
users: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
createdAt: Date;
|
||||
roles: Array<{ roleId: string; role: { name: string } }>;
|
||||
_count: { ownedRooms: number; roomMembers: number };
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<DataTable>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Roles</th>
|
||||
<th>Rooms</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((account) => (
|
||||
<tr key={account.id}>
|
||||
<td>
|
||||
<div className="row-main">
|
||||
<Avatar name={account.displayName || account.username} />
|
||||
<Avatar name={account.displayName || account.username} size={30} />
|
||||
<div className="row-title">
|
||||
<strong>{account.displayName || account.username}</strong>
|
||||
<span>@{account.username} · {account._count.ownedRooms + account._count.roomMembers} rooms</span>
|
||||
<span>@{account.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="status-row">
|
||||
{account.roles.length === 0 ? <StatusBadge>user</StatusBadge> : null}
|
||||
{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>
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Permissions</h2>
|
||||
<StatusBadge>System</StatusBadge>
|
||||
</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>
|
||||
</td>
|
||||
<td>{account._count.ownedRooms + account._count.roomMembers}</td>
|
||||
<td>{formatDate(account.createdAt)}</td>
|
||||
<td><button className="button compact-button" type="button" disabled title="User edit and ban actions are not implemented yet.">Manage</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomsTable({
|
||||
rooms
|
||||
}: {
|
||||
rooms: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
visibility: string;
|
||||
updatedAt: Date;
|
||||
owner: { username: string; displayName: string | null } | null;
|
||||
_count: { members: number; mediaSources: number };
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<DataTable>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room</th>
|
||||
<th>Owner</th>
|
||||
<th>Access</th>
|
||||
<th>State</th>
|
||||
<th>Updated</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((room) => (
|
||||
<tr key={room.id}>
|
||||
<td>
|
||||
<div className="row-title">
|
||||
<strong>{room.name}</strong>
|
||||
<span>/{room.slug}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||
<td><StatusBadge>{room.visibility.toLowerCase().replace("_", " ")}</StatusBadge></td>
|
||||
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
|
||||
<td>{formatDate(room.updatedAt)}</td>
|
||||
<td><Link className="button compact-button" href={`/rooms/${encodeURIComponent(room.slug)}`}>Open</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesTable({
|
||||
roles
|
||||
}: {
|
||||
roles: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
scope: string;
|
||||
_count: { users: number; permissions: number };
|
||||
}>;
|
||||
}) {
|
||||
return roles.length === 0 ? (
|
||||
<EmptyState title="No roles defined" />
|
||||
) : (
|
||||
<div className="split-grid">
|
||||
<Panel title="Roles" eyebrow="Assignments">
|
||||
{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>
|
||||
))}
|
||||
</Panel>
|
||||
<Panel title="Permissions" eyebrow="Known keys">
|
||||
{SYSTEM_PERMISSIONS.map((permission) => (
|
||||
<div className="row" key={permission}>
|
||||
<div className="row-title">
|
||||
<strong>{permission}</strong>
|
||||
<span>Assignable to system roles</span>
|
||||
</div>
|
||||
<StatusBadge>Enabled</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
|
||||
return (
|
||||
<div className="split-grid">
|
||||
<Panel title="Room Invites" eyebrow="Pending">
|
||||
<EmptyState title="Room invite creation is not wired yet" description="The management surface is reserved so invite CRUD can be added without a layout change." />
|
||||
</Panel>
|
||||
<Panel title="Friend Requests" eyebrow="Instance">
|
||||
<div className="setting-row">
|
||||
<UsersRound size={16} />
|
||||
<span>
|
||||
<strong>{pendingRequests} pending friend requests</strong>
|
||||
<small>Handled by each receiving user in People.</small>
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) {
|
||||
return (
|
||||
<div className="split-grid">
|
||||
<Panel title="Runtime" eyebrow="Container">
|
||||
<div className="settings-list">
|
||||
<Setting icon={<Database size={16} />} label="Database" value="Postgres via DATABASE_URL" />
|
||||
<Setting icon={<Shield size={16} />} label="Sessions" value="Signed HTTP-only cookie" />
|
||||
<Setting icon={<UsersRound size={16} />} label="Accounts" value={`${userCount} users`} />
|
||||
<Setting icon={<Database size={16} />} label="Rooms" value={`${roomCount} rooms`} />
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Health" eyebrow="Status">
|
||||
<div className="settings-list">
|
||||
<StatusDot tone="good" label="Admin page rendered" />
|
||||
<StatusDot tone="good" label="Database query succeeded" />
|
||||
<StatusDot tone="info" label="Socket endpoint configured at /api/socket" />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityPanel() {
|
||||
return (
|
||||
<div className="split-grid">
|
||||
<Panel title="Security Controls" eyebrow="Policy">
|
||||
<div className="settings-list">
|
||||
<Setting icon={<LockKeyhole size={16} />} label="Password storage" value="bcrypt hash" />
|
||||
<Setting icon={<Shield size={16} />} label="Admin check" value="Server-side role lookup" />
|
||||
<Setting icon={<AlertTriangle size={16} />} label="Registration mode" value="Open registration" />
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Danger Zone" eyebrow="Destructive">
|
||||
<EmptyState title="Destructive actions are disabled" description="User banning, registration mode changes, and deletion need server actions before controls become active." />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Setting({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="setting-row">
|
||||
{icon}
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<small>{value}</small>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user