From abee76c9b13964ee47a1df57fb8acaf8ef466363 Mon Sep 17 00:00:00 2001 From: ToxicCrzay270 <185776014+ToxicCrzay270@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:00:57 +0200 Subject: [PATCH] Add admin moderation and sync acknowledgements --- docs/agent-handoff.md | 4 ++- server.js | 54 ++++++++++++++++++------------- src/app/admin/page.tsx | 23 +++++++++++-- src/components/room-console.tsx | 57 ++++++++++++++++++++++++++++++--- src/lib/admin-actions.ts | 38 ++++++++++++++++++++++ 5 files changed, 145 insertions(+), 31 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 174cbdc..1f3064b 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -19,4 +19,6 @@ Initial implementation created from `codex-agent-repository-kit` guidance. - Docker publish was fixed by updating `REGISTRY_TOKEN`; Gitea issue #1 is closed. - Room-scoped persisted invite codes were added to the room Invite rail. Room owners, admins, and room managers can create expiring invite codes and revoke active room invites. - Room chat moderation was added through Socket.IO. Room owners, admins, and room managers can delete room chat messages; deletions are audited and rebroadcast to the room. -- Remaining UI redesign follow-ups that need real backend work: user ban/remove-friend admin actions and socket acknowledgements for sync health instead of client-local connection assumptions. +- Admin user ban and remove-friendship actions were added. Admins can disable an account while clearing its friendships and explicit room memberships, or remove all friendships for a user without disabling the account. +- Socket acknowledgements were added for room join and realtime room actions. The room header now shows pending, confirmed, or failed sync acknowledgement state from the server. +- Remaining UI redesign follow-ups that need real backend work: none currently documented in this handoff. diff --git a/server.js b/server.js index 907d41f..5039175 100644 --- a/server.js +++ b/server.js @@ -25,12 +25,12 @@ app.prepare().then(() => { }); io.on("connection", (socket) => { - socket.on("room:join", async ({ roomSlug } = {}) => { - await safeSocket(socket, async () => { + socket.on("room:join", async ({ roomSlug } = {}, acknowledge) => { + await safeSocket(socket, acknowledge, async () => { const session = await getSocketSession(socket); - if (!session || !roomSlug) return reject(socket, "Sign in to join this room."); + if (!session || !roomSlug) return reject(socket, "Sign in to join this room.", acknowledge); const context = await getRoomContext(roomSlug, session.user.id); - if (!context.allowed) return reject(socket, "You do not have access to this room."); + if (!context.allowed) return reject(socket, "You do not have access to this room.", acknowledge); socket.data.user = session.user; socket.data.roomSlug = context.room.slug; @@ -40,15 +40,16 @@ app.prepare().then(() => { socket.emit("room:state", await buildRoomSnapshot(context.room.id)); io.to(context.room.slug).emit("presence:list", getPresence(context.room.slug)); + ok(acknowledge); }); }); - socket.on("queue:add", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("queue:add", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { const sourceUrl = String(payload?.sourceUrl || "").trim(); if (!sourceUrl) return; const settings = await getAppSettings(); const media = normalizeMediaUrl(sourceUrl); - if (!settings.allowedProviders.includes(media.provider)) return reject(socket, "This media provider is disabled."); + if (!settings.allowedProviders.includes(media.provider)) return reject(socket, "This media provider is disabled.", acknowledge); const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } }); const created = await prisma.mediaSource.create({ @@ -77,7 +78,7 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("queue:play", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("queue:play", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { const mediaSourceId = String(payload?.mediaSourceId || ""); const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } }); if (!media) return; @@ -86,7 +87,7 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("queue:remove", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("queue:remove", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { const mediaSourceId = String(payload?.mediaSourceId || ""); const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } }); if (!media) return; @@ -113,7 +114,7 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("queue:move", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("queue:move", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { const mediaSourceId = String(payload?.mediaSourceId || ""); const direction = payload?.direction === "down" ? 1 : -1; await moveMedia(room.id, mediaSourceId, direction); @@ -121,7 +122,7 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("playback:play", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("playback:play", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { await persistPlaybackState(room.id, user, { mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""), status: "PLAYING", @@ -131,7 +132,7 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("playback:pause", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("playback:pause", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { await persistPlaybackState(room.id, user, { mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""), status: "PAUSED", @@ -141,7 +142,7 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("playback:seek", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("playback:seek", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { const previous = parseState(room.currentState); await persistPlaybackState(room.id, user, { mediaSourceId: String(payload?.mediaSourceId || previous?.mediaSourceId || ""), @@ -152,7 +153,7 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("chat:message", (payload) => safeRoomAction(socket, async ({ room, user }) => { + socket.on("chat:message", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { const body = String(payload?.body || "").trim().slice(0, 1000); if (!body) return; await prisma.roomMessage.create({ data: { roomId: room.id, userId: user.id, body } }); @@ -160,8 +161,8 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); - socket.on("chat:delete", (payload) => safeRoomAction(socket, async ({ room, user }) => { - if (!canManageRoom(room, user.id, user)) return reject(socket, "Only room managers can moderate chat."); + socket.on("chat:delete", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => { + if (!canManageRoom(room, user.id, user)) return reject(socket, "Only room managers can moderate chat.", acknowledge); const messageId = String(payload?.messageId || ""); if (!messageId) return; const result = await prisma.roomMessage.deleteMany({ where: { id: messageId, roomId: room.id } }); @@ -185,28 +186,35 @@ app.prepare().then(() => { }); }); -async function safeSocket(socket, action) { +async function safeSocket(socket, acknowledge, action) { try { await action(); } catch (error) { console.error(error); - reject(socket, "Realtime action failed."); + reject(socket, "Realtime action failed.", acknowledge); } } -async function safeRoomAction(socket, action) { - await safeSocket(socket, async () => { +async function safeRoomAction(socket, acknowledge, action) { + await safeSocket(socket, acknowledge, async () => { const user = socket.data.user || (await getSocketSession(socket))?.user; const roomSlug = socket.data.roomSlug; - if (!user || !roomSlug) return reject(socket, "Join a room before sending actions."); + if (!user || !roomSlug) return reject(socket, "Join a room before sending actions.", acknowledge); const context = await getRoomContext(roomSlug, user.id); - if (!context.allowed) return reject(socket, "You do not have access to this room."); - await action({ room: context.room, user }); + if (!context.allowed) return reject(socket, "You do not have access to this room.", acknowledge); + const result = await action({ room: context.room, user }); + if (result !== false) ok(acknowledge); }); } -function reject(socket, message) { +function ok(callback) { + if (typeof callback === "function") callback({ ok: true, at: Date.now() }); +} + +function reject(socket, message, callback) { socket.emit("room:error", { message }); + if (typeof callback === "function") callback({ ok: false, message, at: Date.now() }); + return false; } async function broadcastRoom(io, roomSlug, roomId) { diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1aa0954..1aefcd9 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12,7 +12,7 @@ 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 { banUser, createInstanceInvite, disableUser, enableUser, grantAdminRole, removeUserFriendships, revokeAdminRole, revokeInvite } from "@/lib/admin-actions"; import { deleteRoom } from "@/lib/room-actions"; export const dynamic = "force-dynamic"; @@ -34,7 +34,10 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis 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 } } }, + include: { + roles: { include: { role: true } }, + _count: { select: { ownedRooms: true, roomMembers: true, sentFriends: true, gotFriends: true } } + }, orderBy: { createdAt: "asc" } }), prisma.room.findMany({ @@ -129,7 +132,7 @@ function UsersTable({ disabledAt: Date | null; createdAt: Date; roles: Array<{ roleId: string; role: { name: string } }>; - _count: { ownedRooms: number; roomMembers: number }; + _count: { ownedRooms: number; roomMembers: number; sentFriends: number; gotFriends: number }; }>; currentUserId: string; }) { @@ -141,6 +144,7 @@ function UsersTable({