2026-05-15 20:13:29 +02:00
import Link from "next/link" ;
import { AlertTriangle , Database , LockKeyhole , Plus , Search , Shield , UsersRound } from "lucide-react" ;
import { redirect } from "next/navigation" ;
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 20:13:29 +02:00
import { getShellContext } from "@/lib/shell" ;
2026-05-15 17:32:26 +02:00
import { requireCurrentUser , userIsAdmin } from "@/lib/session" ;
import { requireInitialSetup } from "@/lib/setup" ;
2026-05-15 20:13:29 +02:00
import { DataTable , EmptyState , MetricTile , PageHeader , Panel , StatusDot , Tabs } from "@/components/ui" ;
2026-05-15 21:36:22 +02:00
import { getAppSettings , type AppSettings } from "@/lib/settings" ;
import { updateInstanceSettings , updateSecuritySettings } from "@/lib/settings-actions" ;
2026-05-15 17:32:26 +02:00
2026-05-15 18:20:01 +02:00
export const dynamic = "force-dynamic" ;
2026-05-15 20:13:29 +02:00
const adminTabs = [ "Users" , "Rooms" , "Roles" , "Invites" , "Instance" , "Security" ] ;
2026-05-15 21:36:22 +02:00
export default async function AdminPage ( { searchParams } : { searchParams : Promise < { tab? : string ; q? : string ; saved? : string } > } ) {
2026-05-15 17:32:26 +02:00
await requireInitialSetup ( ) ;
const user = await requireCurrentUser ( ) ;
if ( ! userIsAdmin ( user ) ) {
redirect ( "/dashboard" ) ;
}
2026-05-15 03:11:41 +02:00
2026-05-15 21:36:22 +02:00
const { tab = "Users" , q = "" , saved } = await searchParams ;
2026-05-15 20:13:29 +02:00
const activeTab = adminTabs . includes ( tab ) ? tab : "Users" ;
const query = q . trim ( ) . toLowerCase ( ) ;
const shell = await getShellContext ( user ) ;
2026-05-15 21:36:22 +02:00
const [ users , rooms , roles , pendingRequests , appSettings ] = await Promise . all ( [
2026-05-15 17:47:42 +02:00
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 20:13:29 +02:00
} ) ,
2026-05-15 21:36:22 +02:00
prisma . friendship . count ( { where : { status : "PENDING" } } ) ,
getAppSettings ( )
2026-05-15 17:47:42 +02:00
] ) ;
2026-05-15 20:13:29 +02:00
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 ;
2026-05-15 17:47:42 +02:00
2026-05-15 03:11:41 +02:00
return (
2026-05-15 20:13:29 +02:00
< 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 }
2026-05-15 21:36:22 +02:00
{ activeTab === "Instance" ? < InstancePanel userCount = { users . length } roomCount = { rooms . length } settings = { appSettings } saved = { saved } / > : null }
{ activeTab === "Security" ? < SecurityPanel settings = { appSettings } saved = { saved } / > : null }
2026-05-15 20:13:29 +02:00
< / Panel >
< / AppShell >
) ;
}
function UsersTable ( {
users
} : {
users : Array < {
id : string ;
username : string ;
displayName : string | null ;
2026-05-15 21:36:22 +02:00
avatarUrl : string | null ;
2026-05-15 20:13:29 +02:00
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 >
2026-05-15 17:47:42 +02:00
< div className = "row-main" >
2026-05-15 21:36:22 +02:00
< Avatar name = { account . displayName || account . username } src = { account . avatarUrl } size = { 30 } / >
2026-05-15 17:47:42 +02:00
< div className = "row-title" >
< strong > { account . displayName || account . username } < / strong >
2026-05-15 20:13:29 +02:00
< span > @ { account . username } < / span >
2026-05-15 17:47:42 +02:00
< / div >
< / div >
2026-05-15 20:13:29 +02:00
< / td >
< td >
2026-05-15 17:47:42 +02:00
< div className = "status-row" >
2026-05-15 20:13:29 +02:00
{ account . roles . length === 0 ? < StatusBadge > user < / StatusBadge > : null }
2026-05-15 17:47:42 +02:00
{ account . roles . map ( ( userRole ) = > (
< StatusBadge key = { userRole . roleId } tone = { userRole . role . name === "admin" ? "good" : undefined } >
{ userRole . role . name }
< / StatusBadge >
) ) }
< / div >
2026-05-15 20:13:29 +02:00
< / 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 >
2026-05-15 17:47:42 +02:00
< div className = "row-title" >
2026-05-15 20:13:29 +02:00
< strong > { room . name } < / strong >
< span > / { room . slug } < / span >
2026-05-15 17:47:42 +02:00
< / div >
2026-05-15 20:13:29 +02:00
< / 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 >
2026-05-15 03:11:41 +02:00
< / div >
2026-05-15 20:13:29 +02:00
) ) }
< / 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 >
2026-05-15 03:11:41 +02:00
< / div >
2026-05-15 20:13:29 +02:00
) ) }
< / Panel >
< / div >
2026-05-15 03:11:41 +02:00
) ;
}
2026-05-15 20:13:29 +02:00
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 >
) ;
}
2026-05-15 21:36:22 +02:00
function InstancePanel ( { userCount , roomCount , settings , saved } : { userCount : number ; roomCount : number ; settings : AppSettings ; saved? : string } ) {
2026-05-15 20:13:29 +02:00
return (
< div className = "split-grid" >
2026-05-15 21:36:22 +02:00
< 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 >
2026-05-15 20:13:29 +02:00
< 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 >
) ;
}
2026-05-15 21:36:22 +02:00
function SecurityPanel ( { settings , saved } : { settings : AppSettings ; saved? : string } ) {
2026-05-15 20:13:29 +02:00
return (
< div className = "split-grid" >
< Panel title = "Security Controls" eyebrow = "Policy" >
2026-05-15 21:36:22 +02:00
{ 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 >
2026-05-15 20:13:29 +02:00
< / 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 ) ;
}