Complete WatchLink V1 realtime features
Some checks failed
Template Compliance / compliance (push) Successful in 7s
Release Dry Run / release-dry-run (push) Failing after 1m8s
Build / build (push) Failing after 1m15s

This commit is contained in:
MrSphay
2026-05-15 23:27:18 +02:00
parent 04d75c386f
commit c1ac6e4142
25 changed files with 1775 additions and 253 deletions

View File

@@ -12,6 +12,8 @@ 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 { createInstanceInvite, disableUser, enableUser, grantAdminRole, revokeAdminRole, revokeInvite } from "@/lib/admin-actions";
import { deleteRoom } from "@/lib/room-actions";
export const dynamic = "force-dynamic";
@@ -30,7 +32,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
const query = q.trim().toLowerCase();
const shell = await getShellContext(user);
const [users, rooms, roles, pendingRequests, appSettings] = await Promise.all([
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 } } },
orderBy: { createdAt: "asc" }
@@ -44,7 +46,9 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
orderBy: { name: "asc" }
}),
prisma.friendship.count({ where: { status: "PENDING" } }),
getAppSettings()
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) =>
@@ -74,8 +78,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
}
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>
<Link className="button" href="/admin?tab=Invites"><Plus size={16} /> Invite</Link>
<Link className="button" href="/admin?tab=Security"><Shield size={16} /> Audit</Link>
</>
}
/>
@@ -102,29 +106,32 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
>
<Tabs active={activeTab} items={adminTabs.map((item) => ({ label: item, href: `/admin?tab=${encodeURIComponent(item)}` }))} />
{activeTab === "Users" ? <UsersTable users={filteredUsers} /> : null}
{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} /> : 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} /> : null}
{activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} auditEvents={auditEvents} /> : null}
</Panel>
</AppShell>
);
}
function UsersTable({
users
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 };
}>;
currentUserId: string;
}) {
return (
<DataTable>
@@ -161,8 +168,33 @@ function UsersTable({
</div>
</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>
<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>
)}
</div>
</td>
</tr>
))}
</tbody>
@@ -179,6 +211,7 @@ function RoomsTable({
slug: string;
name: string;
visibility: string;
isPersonal: boolean;
updatedAt: Date;
owner: { username: string; displayName: string | null } | null;
_count: { members: number; mediaSources: number };
@@ -210,7 +243,19 @@ function RoomsTable({
<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>
<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>
@@ -260,11 +305,65 @@ function RolesTable({
);
}
function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
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 }>;
}) {
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 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`}>
{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) => (
<div className="setting-row" key={invite.id}>
<span>
<strong>{invite.code}</strong>
<small>{invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - {formatDate(invite.createdAt)}</small>
</span>
<StatusBadge tone={invite.status === "ACTIVE" ? "good" : "warn"}>{invite.status.toLowerCase()}</StatusBadge>
{invite.status === "ACTIVE" ? (
<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">
@@ -337,7 +436,21 @@ function InstancePanel({ userCount, roomCount, settings, saved }: { userCount: n
);
}
function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: string }) {
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">
@@ -360,7 +473,27 @@ function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: str
</form>
</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." />
<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>
);