Add player controls and configurable profiles
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 1m34s
Build / build (push) Successful in 11m47s
Template Compliance / compliance (push) Successful in 5s

This commit is contained in:
MrSphay
2026-05-15 21:36:22 +02:00
parent 9fbd79c7ef
commit 7a5cc2f64b
27 changed files with 592 additions and 56 deletions

View File

@@ -10,12 +10,14 @@ 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";
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 }> }) {
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string; saved?: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
@@ -23,12 +25,12 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
redirect("/dashboard");
}
const { tab = "Users", q = "" } = await searchParams;
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] = await Promise.all([
const [users, rooms, roles, pendingRequests, appSettings] = await Promise.all([
prisma.user.findMany({
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
orderBy: { createdAt: "asc" }
@@ -41,7 +43,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
include: { _count: { select: { users: true, permissions: true } } },
orderBy: { name: "asc" }
}),
prisma.friendship.count({ where: { status: "PENDING" } })
prisma.friendship.count({ where: { status: "PENDING" } }),
getAppSettings()
]);
const filteredUsers = query
? users.filter((account) =>
@@ -103,8 +106,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
{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}
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} settings={appSettings} saved={saved} /> : null}
{activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} /> : null}
</Panel>
</AppShell>
);
@@ -117,6 +120,7 @@ function UsersTable({
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>;
_count: { ownedRooms: number; roomMembers: number };
@@ -139,7 +143,7 @@ function UsersTable({
<tr key={account.id}>
<td>
<div className="row-main">
<Avatar name={account.displayName || account.username} size={30} />
<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>
@@ -275,9 +279,45 @@ function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
);
}
function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) {
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" />
@@ -297,15 +337,27 @@ function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount:
);
}
function SecurityPanel() {
function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: string }) {
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>
{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">
<EmptyState title="Destructive actions are disabled" description="User banning, registration mode changes, and deletion need server actions before controls become active." />