Add admin moderation and sync acknowledgements
All checks were successful
Template Compliance / compliance (push) Successful in 6s
Template Compliance / compliance (pull_request) Successful in 6s

This commit is contained in:
ToxicCrzay270
2026-06-11 21:00:57 +02:00
parent 699232f5c6
commit abee76c9b1
5 changed files with 145 additions and 31 deletions

View File

@@ -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. - 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-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. - 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.

View File

@@ -25,12 +25,12 @@ app.prepare().then(() => {
}); });
io.on("connection", (socket) => { io.on("connection", (socket) => {
socket.on("room:join", async ({ roomSlug } = {}) => { socket.on("room:join", async ({ roomSlug } = {}, acknowledge) => {
await safeSocket(socket, async () => { await safeSocket(socket, acknowledge, async () => {
const session = await getSocketSession(socket); 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); 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.user = session.user;
socket.data.roomSlug = context.room.slug; socket.data.roomSlug = context.room.slug;
@@ -40,15 +40,16 @@ app.prepare().then(() => {
socket.emit("room:state", await buildRoomSnapshot(context.room.id)); socket.emit("room:state", await buildRoomSnapshot(context.room.id));
io.to(context.room.slug).emit("presence:list", getPresence(context.room.slug)); 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(); const sourceUrl = String(payload?.sourceUrl || "").trim();
if (!sourceUrl) return; if (!sourceUrl) return;
const settings = await getAppSettings(); const settings = await getAppSettings();
const media = normalizeMediaUrl(sourceUrl); 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 nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
const created = await prisma.mediaSource.create({ const created = await prisma.mediaSource.create({
@@ -77,7 +78,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id); 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 mediaSourceId = String(payload?.mediaSourceId || "");
const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } }); const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } });
if (!media) return; if (!media) return;
@@ -86,7 +87,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id); 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 mediaSourceId = String(payload?.mediaSourceId || "");
const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } }); const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } });
if (!media) return; if (!media) return;
@@ -113,7 +114,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id); 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 mediaSourceId = String(payload?.mediaSourceId || "");
const direction = payload?.direction === "down" ? 1 : -1; const direction = payload?.direction === "down" ? 1 : -1;
await moveMedia(room.id, mediaSourceId, direction); await moveMedia(room.id, mediaSourceId, direction);
@@ -121,7 +122,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id); 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, { await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""), mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""),
status: "PLAYING", status: "PLAYING",
@@ -131,7 +132,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id); 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, { await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""), mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""),
status: "PAUSED", status: "PAUSED",
@@ -141,7 +142,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id); 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); const previous = parseState(room.currentState);
await persistPlaybackState(room.id, user, { await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || previous?.mediaSourceId || ""), mediaSourceId: String(payload?.mediaSourceId || previous?.mediaSourceId || ""),
@@ -152,7 +153,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id); 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); const body = String(payload?.body || "").trim().slice(0, 1000);
if (!body) return; if (!body) return;
await prisma.roomMessage.create({ data: { roomId: room.id, userId: user.id, body } }); 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); await broadcastRoom(io, room.slug, room.id);
})); }));
socket.on("chat:delete", (payload) => safeRoomAction(socket, async ({ room, user }) => { 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."); if (!canManageRoom(room, user.id, user)) return reject(socket, "Only room managers can moderate chat.", acknowledge);
const messageId = String(payload?.messageId || ""); const messageId = String(payload?.messageId || "");
if (!messageId) return; if (!messageId) return;
const result = await prisma.roomMessage.deleteMany({ where: { id: messageId, roomId: room.id } }); 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 { try {
await action(); await action();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
reject(socket, "Realtime action failed."); reject(socket, "Realtime action failed.", acknowledge);
} }
} }
async function safeRoomAction(socket, action) { async function safeRoomAction(socket, acknowledge, action) {
await safeSocket(socket, async () => { await safeSocket(socket, acknowledge, async () => {
const user = socket.data.user || (await getSocketSession(socket))?.user; const user = socket.data.user || (await getSocketSession(socket))?.user;
const roomSlug = socket.data.roomSlug; 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); const context = await getRoomContext(roomSlug, 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);
await action({ room: context.room, user }); 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 }); socket.emit("room:error", { message });
if (typeof callback === "function") callback({ ok: false, message, at: Date.now() });
return false;
} }
async function broadcastRoom(io, roomSlug, roomId) { async function broadcastRoom(io, roomSlug, roomId) {

View File

@@ -12,7 +12,7 @@ import { requireInitialSetup } from "@/lib/setup";
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui"; import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { getAppSettings, type AppSettings } from "@/lib/settings"; import { getAppSettings, type AppSettings } from "@/lib/settings";
import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions"; 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"; import { deleteRoom } from "@/lib/room-actions";
export const dynamic = "force-dynamic"; 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([ const [users, rooms, roles, pendingRequests, appSettings, invites, auditEvents] = await Promise.all([
prisma.user.findMany({ 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" } orderBy: { createdAt: "asc" }
}), }),
prisma.room.findMany({ prisma.room.findMany({
@@ -129,7 +132,7 @@ function UsersTable({
disabledAt: Date | null; disabledAt: Date | null;
createdAt: Date; createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>; roles: Array<{ roleId: string; role: { name: string } }>;
_count: { ownedRooms: number; roomMembers: number }; _count: { ownedRooms: number; roomMembers: number; sentFriends: number; gotFriends: number };
}>; }>;
currentUserId: string; currentUserId: string;
}) { }) {
@@ -141,6 +144,7 @@ function UsersTable({
<th>User</th> <th>User</th>
<th>Roles</th> <th>Roles</th>
<th>Rooms</th> <th>Rooms</th>
<th>Friends</th>
<th>Created</th> <th>Created</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -168,6 +172,7 @@ function UsersTable({
</div> </div>
</td> </td>
<td>{account._count.ownedRooms + account._count.roomMembers}</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>{account.disabledAt ? <StatusBadge tone="danger">disabled</StatusBadge> : formatDate(account.createdAt)}</td>
<td> <td>
<div className="row-actions"> <div className="row-actions">
@@ -193,6 +198,18 @@ function UsersTable({
<button className="button compact-button danger" type="submit" disabled={account.id === currentUserId}>Disable</button> <button className="button compact-button danger" type="submit" disabled={account.id === currentUserId}>Disable</button>
</form> </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> </div>
</td> </td>
</tr> </tr>

View File

@@ -109,6 +109,12 @@ type RoomSnapshot = {
messages: RoomMessage[]; messages: RoomMessage[];
}; };
type RoomAck = {
ok: boolean;
at?: number;
message?: string;
};
type Participant = { type Participant = {
id: string; id: string;
name: string; name: string;
@@ -165,6 +171,8 @@ export function RoomConsole({
const [presence, setPresence] = useState<Participant[]>(participants); const [presence, setPresence] = useState<Participant[]>(participants);
const [playback, setPlayback] = useState<PlaybackState | null>(queue[0] ? initialPlayback(queue[0]) : null); const [playback, setPlayback] = useState<PlaybackState | null>(queue[0] ? initialPlayback(queue[0]) : null);
const [socketError, setSocketError] = useState(""); const [socketError, setSocketError] = useState("");
const [syncHealth, setSyncHealth] = useState<"idle" | "pending" | "confirmed" | "failed">("idle");
const [lastAckAt, setLastAckAt] = useState<number | null>(null);
const [syncBlocked, setSyncBlocked] = useState(false); const [syncBlocked, setSyncBlocked] = useState(false);
const [playerReady, setPlayerReady] = useState(false); const [playerReady, setPlayerReady] = useState(false);
const [providerIssue, setProviderIssue] = useState(""); const [providerIssue, setProviderIssue] = useState("");
@@ -182,7 +190,16 @@ export function RoomConsole({
const emit = useCallback((event: string, payload: Record<string, unknown> = {}) => { const emit = useCallback((event: string, payload: Record<string, unknown> = {}) => {
setSocketError(""); setSocketError("");
socketRef.current?.emit(event, payload); setSyncHealth("pending");
socketRef.current?.timeout(5000).emit(event, payload, (error: Error | null, response?: RoomAck) => {
if (error || !response?.ok) {
setSyncHealth("failed");
setSocketError(response?.message || "Realtime action was not acknowledged.");
return;
}
setLastAckAt(response.at || Date.now());
setSyncHealth("confirmed");
});
}, []); }, []);
const currentPosition = useCallback(() => { const currentPosition = useCallback(() => {
@@ -238,10 +255,25 @@ export function RoomConsole({
socketRef.current = socket; socketRef.current = socket;
socket.on("connect", () => { socket.on("connect", () => {
setConnected(true); setConnected(true);
socket.emit("room:join", { roomSlug }); setSyncHealth("pending");
socket.timeout(5000).emit("room:join", { roomSlug }, (error: Error | null, response?: RoomAck) => {
if (error || !response?.ok) {
setSyncHealth("failed");
setSocketError(response?.message || "Realtime join was not acknowledged.");
return;
}
setLastAckAt(response.at || Date.now());
setSyncHealth("confirmed");
});
});
socket.on("disconnect", () => {
setConnected(false);
setSyncHealth("idle");
});
socket.on("room:error", ({ message }: { message: string }) => {
setSocketError(message);
setSyncHealth("failed");
}); });
socket.on("disconnect", () => setConnected(false));
socket.on("room:error", ({ message }: { message: string }) => setSocketError(message));
socket.on("presence:list", (rows: Participant[]) => setPresence(rows)); socket.on("presence:list", (rows: Participant[]) => setPresence(rows));
socket.on("room:state", (snapshot: RoomSnapshot | null) => { socket.on("room:state", (snapshot: RoomSnapshot | null) => {
if (!snapshot) return; if (!snapshot) return;
@@ -317,6 +349,7 @@ export function RoomConsole({
actions={ actions={
<div className="status-row"> <div className="status-row">
<StatusDot tone={connected ? "good" : "warn"} label={connected ? "connected" : "connecting"} /> <StatusDot tone={connected ? "good" : "warn"} label={connected ? "connected" : "connecting"} />
<StatusDot tone={syncStatusTone(syncHealth)} label={syncStatusLabel(syncHealth, lastAckAt)} />
<StatusBadge>{formatVisibility(roomVisibility)}</StatusBadge> <StatusBadge>{formatVisibility(roomVisibility)}</StatusBadge>
</div> </div>
} }
@@ -931,6 +964,22 @@ function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " "); return value.toLowerCase().replace("_", " ");
} }
function syncStatusTone(value: "idle" | "pending" | "confirmed" | "failed") {
if (value === "confirmed") return "good";
if (value === "pending") return "info";
if (value === "failed") return "danger";
return "neutral";
}
function syncStatusLabel(value: "idle" | "pending" | "confirmed" | "failed", lastAckAt: number | null) {
if (value === "confirmed" && lastAckAt) {
return `sync ack ${new Intl.DateTimeFormat("en", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(lastAckAt))}`;
}
if (value === "pending") return "sync pending";
if (value === "failed") return "sync unconfirmed";
return "sync idle";
}
function formatDateTime(value: string) { function formatDateTime(value: string) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(new Date(value)); return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(new Date(value));
} }

View File

@@ -31,6 +31,44 @@ export async function enableUser(formData: FormData) {
revalidateAdmin(); revalidateAdmin();
} }
export async function banUser(formData: FormData) {
const admin = await requireAdmin();
const userId = String(formData.get("userId") || "");
if (!userId || userId === admin.id) return;
const [friendships, memberships] = await prisma.$transaction([
prisma.friendship.deleteMany({
where: {
OR: [{ requesterId: userId }, { receiverId: userId }]
}
}),
prisma.roomMember.deleteMany({ where: { userId } }),
prisma.user.update({ where: { id: userId }, data: { disabledAt: new Date() } })
]);
await audit(admin.id, "admin.user.ban", {
userId,
removedFriendships: friendships.count,
removedRoomMemberships: memberships.count
});
revalidateAdmin();
}
export async function removeUserFriendships(formData: FormData) {
const admin = await requireAdmin();
const userId = String(formData.get("userId") || "");
if (!userId || userId === admin.id) return;
const result = await prisma.friendship.deleteMany({
where: {
OR: [{ requesterId: userId }, { receiverId: userId }]
}
});
await audit(admin.id, "admin.user.friendships.remove", { userId, removedFriendships: result.count });
revalidateAdmin();
}
export async function grantAdminRole(formData: FormData) { export async function grantAdminRole(formData: FormData) {
const admin = await requireAdmin(); const admin = await requireAdmin();
const userId = String(formData.get("userId") || ""); const userId = String(formData.get("userId") || "");