Files
WatchLink/src/app/admin/page.tsx
ToxicCrzay270 d5ab61c0c2
All checks were successful
Template Compliance / compliance (push) Successful in 5s
Add stale invite expiry action
2026-06-11 22:54:14 +02:00

549 lines
22 KiB
TypeScript

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 { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { getAppSettings, type AppSettings } from "@/lib/settings";
import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions";
import { banUser, createInstanceInvite, disableUser, enableUser, expireStaleInvites, grantAdminRole, removeUserFriendships, revokeAdminRole, revokeInvite } from "@/lib/admin-actions";
import { deleteRoom } from "@/lib/room-actions";
import { isInviteExpired } from "@/lib/invites";
export const dynamic = "force-dynamic";
const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"];
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string; saved?: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
if (!userIsAdmin(user)) {
redirect("/dashboard");
}
const { tab = "Users", q = "", saved } = await searchParams;
const activeTab = adminTabs.includes(tab) ? tab : "Users";
const query = q.trim().toLowerCase();
const shell = await getShellContext(user);
const [users, rooms, roles, pendingRequests, appSettings, invites, auditEvents] = await Promise.all([
prisma.user.findMany({
include: {
roles: { include: { role: true } },
_count: { select: { ownedRooms: true, roomMembers: true, sentFriends: true, gotFriends: 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" }
}),
prisma.friendship.count({ where: { status: "PENDING" } }),
getAppSettings(),
prisma.invite.findMany({ include: { room: true, creator: true }, orderBy: { createdAt: "desc" }, take: 50 }),
prisma.auditEvent.findMany({ include: { actor: true, room: true }, orderBy: { createdAt: "desc" }, take: 20 })
]);
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" {...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={
<>
<Link className="button" href="/admin?tab=Invites"><Plus size={16} /> Invite</Link>
<Link className="button" href="/admin?tab=Security"><Shield size={16} /> Audit</Link>
</>
}
/>
<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} currentUserId={user.id} /> : null}
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} invites={invites} rooms={rooms} /> : null}
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} settings={appSettings} saved={saved} /> : null}
{activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} auditEvents={auditEvents} /> : null}
</Panel>
</AppShell>
);
}
function UsersTable({
users,
currentUserId
}: {
users: Array<{
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
disabledAt: Date | null;
createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>;
_count: { ownedRooms: number; roomMembers: number; sentFriends: number; gotFriends: number };
}>;
currentUserId: string;
}) {
return (
<DataTable>
<table className="table">
<thead>
<tr>
<th>User</th>
<th>Roles</th>
<th>Rooms</th>
<th>Friends</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} src={account.avatarUrl} size={30} />
<div className="row-title">
<strong>{account.displayName || account.username}</strong>
<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>
))}
</div>
</td>
<td>{account._count.ownedRooms + account._count.roomMembers}</td>
<td>{account._count.sentFriends + account._count.gotFriends}</td>
<td>{account.disabledAt ? <StatusBadge tone="danger">disabled</StatusBadge> : formatDate(account.createdAt)}</td>
<td>
<div className="row-actions">
{account.roles.some((role) => role.role.name === "admin") ? (
<form action={revokeAdminRole}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button" type="submit" disabled={account.id === currentUserId}>Revoke admin</button>
</form>
) : (
<form action={grantAdminRole}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button" type="submit">Make admin</button>
</form>
)}
{account.disabledAt ? (
<form action={enableUser}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button" type="submit">Enable</button>
</form>
) : (
<form action={disableUser}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button danger" type="submit" disabled={account.id === currentUserId}>Disable</button>
</form>
)}
<form action={removeUserFriendships}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button danger" type="submit" disabled={account.id === currentUserId || account._count.sentFriends + account._count.gotFriends === 0}>
Remove friends
</button>
</form>
<form action={banUser}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button danger" type="submit" disabled={account.id === currentUserId || Boolean(account.disabledAt)}>
Ban
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</DataTable>
);
}
function RoomsTable({
rooms
}: {
rooms: Array<{
id: string;
slug: string;
name: string;
visibility: string;
isPersonal: boolean;
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>
<div className="row-actions">
<Link className="button compact-button" href={`/rooms/${encodeURIComponent(room.slug)}`}>Open</Link>
{!room.isPersonal ? (
<form action={deleteRoom}>
<input type="hidden" name="roomId" value={room.id} />
<button className="button compact-button danger" type="submit">Delete</button>
</form>
) : (
<StatusBadge>personal</StatusBadge>
)}
</div>
</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,
invites,
rooms
}: {
pendingRequests: number;
invites: Array<{
id: string;
code: string;
status: string;
expiresAt: Date | null;
createdAt: Date;
room: { id: string; name: string; slug: string } | null;
creator: { username: string; displayName: string | null } | null;
}>;
rooms: Array<{ id: string; name: string; slug: string }>;
}) {
const expiredInviteCount = invites.filter((invite) => invite.status === "ACTIVE" && isInviteExpired(invite.expiresAt)).length;
return (
<div className="split-grid">
<Panel title="Create Invite" eyebrow="Access">
<form className="form compact-form" action={createInstanceInvite}>
<label>
Room access
<select className="input" name="roomId" defaultValue="">
<option value="">Instance registration only</option>
{rooms.map((room) => (
<option value={room.id} key={room.id}>{room.name} /{room.slug}</option>
))}
</select>
</label>
<label>
Expires after days
<input className="input" name="expiresDays" type="number" min="0" max="365" defaultValue="7" />
</label>
<button className="button primary" type="submit">Create invite</button>
</form>
</Panel>
<Panel title="Invites" eyebrow={`${invites.length} recent`}>
{expiredInviteCount > 0 ? (
<form action={expireStaleInvites} className="inline-form">
<button className="button compact-button" type="submit">Mark {expiredInviteCount} expired</button>
</form>
) : null}
{invites.length === 0 ? (
<EmptyState title="No invites yet" description="Create an invite to allow registration or room access workflows." />
) : (
<div className="settings-list">
{invites.map((invite) => {
const expired = isInviteExpired(invite.expiresAt);
const label = expired && invite.status === "ACTIVE" ? "expired" : invite.status.toLowerCase();
return (
<div className="setting-row" key={invite.id}>
<span>
<strong>{invite.code}</strong>
<small>
{invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - created {formatDate(invite.createdAt)}
{invite.expiresAt ? ` - expires ${formatDate(invite.expiresAt)}` : " - no expiry"}
</small>
</span>
<StatusBadge tone={invite.status === "ACTIVE" && !expired ? "good" : "warn"}>{label}</StatusBadge>
{invite.status === "ACTIVE" && !expired ? (
<form action={revokeInvite}>
<input type="hidden" name="inviteId" value={invite.id} />
<button className="button compact-button danger" type="submit">Revoke</button>
</form>
) : null}
</div>
);
})}
</div>
)}
</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, settings, saved }: { userCount: number; roomCount: number; settings: AppSettings; saved?: string }) {
return (
<div className="split-grid">
<Panel title="Instance Settings" eyebrow="Site-wide">
{saved ? <p className="form-success">Instance settings saved.</p> : null}
<form className="form compact-form" action={updateInstanceSettings}>
<label>
Instance name
<input className="input" name="instanceName" defaultValue={settings.instanceName} maxLength={80} />
</label>
<label>
Description
<textarea className="input textarea" name="instanceDescription" defaultValue={settings.instanceDescription} maxLength={240} />
</label>
<label>
Default room visibility
<select className="input" name="defaultRoomVisibility" defaultValue={settings.defaultRoomVisibility}>
<option value="FRIENDS">Friends-only</option>
<option value="PUBLIC">Public</option>
<option value="EXPLICIT">Explicit members</option>
<option value="ROLE_RESTRICTED">Role restricted</option>
</select>
</label>
<label>
Avatar upload limit in MB
<input className="input" name="maxAvatarUploadMb" type="number" min="1" max="10" defaultValue={settings.maxAvatarUploadMb} />
</label>
<fieldset className="checkbox-grid">
<legend>Allowed media providers</legend>
{["YOUTUBE", "TWITCH", "DIRECT"].map((provider) => (
<label key={provider}>
<input name="allowedProviders" type="checkbox" value={provider} defaultChecked={settings.allowedProviders.includes(provider)} />
{provider}
</label>
))}
</fieldset>
<button className="button primary" type="submit">Save instance settings</button>
</form>
</Panel>
<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({
settings,
saved,
auditEvents
}: {
settings: AppSettings;
saved?: string;
auditEvents: Array<{
id: string;
action: string;
createdAt: Date;
actor: { username: string; displayName: string | null } | null;
room: { slug: string; name: string } | null;
}>;
}) {
return (
<div className="split-grid">
<Panel title="Security Controls" eyebrow="Policy">
{saved ? <p className="form-success">Security settings saved.</p> : null}
<form className="form compact-form" action={updateSecuritySettings}>
<label>
Registration mode
<select className="input" name="registrationMode" defaultValue={settings.registrationMode}>
<option value="OPEN">Open registration</option>
<option value="INVITE_ONLY">Invite only</option>
<option value="DISABLED">Disabled</option>
</select>
</label>
<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={settings.registrationMode.toLowerCase().replace("_", " ")} />
</div>
<button className="button primary" type="submit">Save security settings</button>
</form>
</Panel>
<Panel title="Danger Zone" eyebrow="Destructive">
<div className="settings-list">
<StatusDot tone="good" label="User disable actions enabled" />
<StatusDot tone="good" label="Non-personal room deletion enabled" />
<StatusDot tone="good" label="Registration mode controls enabled" />
</div>
</Panel>
<Panel title="Audit Events" eyebrow="Recent">
{auditEvents.length === 0 ? (
<EmptyState title="No audit events yet" description="Admin, room, queue, and chat actions will appear here." />
) : (
<div className="settings-list">
{auditEvents.map((event) => (
<div className="setting-row" key={event.id}>
<span>
<strong>{event.action}</strong>
<small>{event.actor?.displayName || event.actor?.username || "System"} - {event.room?.slug || "instance"} - {formatDate(event.createdAt)}</small>
</span>
</div>
))}
</div>
)}
</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);
}