diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 551024a..1f3064b 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -17,4 +17,8 @@ Initial implementation created from `codex-agent-repository-kit` guidance. - Gitea Actions can run `npm install`, tests, build, and Docker image publishing. - Local Docker is still unavailable in this Codex environment, but Gitea Actions can build the image. - Docker publish was fixed by updating `REGISTRY_TOKEN`; Gitea issue #1 is closed. -- UI redesign follow-ups that need real backend work: persisted invite records, editable room settings, queue reorder/remove server actions, chat persistence/moderation, user ban/remove-friend actions, a real audit/event table, and socket acknowledgements for sync health instead of client-local connection assumptions. +- 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. +- 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 136c3ba..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,6 +161,16 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); + 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 } }); + if (result.count === 0) return; + await audit("room.chat.delete", user.id, room.id, { messageId }); + await broadcastRoom(io, room.slug, room.id); + })); + socket.on("disconnect", () => { const roomSlug = socket.data.roomSlug; const user = socket.data.user; @@ -175,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) { @@ -276,6 +294,11 @@ async function getRoomContext(slug, userId) { return { allowed, room }; } +function canManageRoom(room, userId, user) { + const isAdmin = Boolean(user?.roles?.some((userRole) => userRole.role.name === "admin")); + return room.ownerId === userId || isAdmin || room.members.some((member) => member.userId === userId && member.canManage); +} + async function buildRoomSnapshot(roomId) { const room = await prisma.room.findUnique({ where: { id: 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({ User Roles Rooms + Friends Created Actions @@ -168,6 +172,7 @@ function UsersTable({ {account._count.ownedRooms + account._count.roomMembers} + {account._count.sentFriends + account._count.gotFriends} {account.disabledAt ? disabled : formatDate(account.createdAt)}
@@ -193,6 +198,18 @@ function UsersTable({ )} +
+ + +
+
+ + +
diff --git a/src/app/rooms/[slug]/page.tsx b/src/app/rooms/[slug]/page.tsx index 429dde9..7a6ef59 100644 --- a/src/app/rooms/[slug]/page.tsx +++ b/src/app/rooms/[slug]/page.tsx @@ -31,6 +31,11 @@ export default async function RoomPage({ include: { owner: true, members: { include: { user: true } }, + invites: { + include: { creator: true }, + orderBy: { createdAt: "desc" }, + take: 12 + }, mediaSources: { include: { submitter: true }, orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }], take: 40 } } }); @@ -41,6 +46,7 @@ export default async function RoomPage({ const isOwner = room.ownerId === user.id; const explicitMember = room.members.some((member) => member.userId === user.id); + const canManageRoom = isOwner || isAdmin || room.members.some((member) => member.userId === user.id && member.canManage); const isFriend = room.ownerId ? Boolean( await prisma.friendship.findFirst({ @@ -97,6 +103,7 @@ export default async function RoomPage({ roomVisibility={room.visibility} isPersonal={room.isPersonal} ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"} + canManageRoom={canManageRoom} initialRail={rail} savedState={saved} errorState={error} @@ -132,8 +139,16 @@ export default async function RoomPage({ avatarUrl: member.user.avatarUrl, role: member.canManage ? "Manager" : "Member", status: member.userId === user.id ? "Online" : "Allowed" - })) + })) ]} + invites={room.invites.map((invite) => ({ + id: invite.id, + code: invite.code, + status: invite.status, + expiresAt: invite.expiresAt?.toISOString() || null, + createdAt: formatDate(invite.createdAt), + creator: invite.creator?.displayName || invite.creator?.username || "Unknown" + }))} /> ); diff --git a/src/components/room-console.tsx b/src/components/room-console.tsx index bbbe7f2..1c46e0d 100644 --- a/src/components/room-console.tsx +++ b/src/components/room-console.tsx @@ -15,10 +15,11 @@ import { ShieldCheck, SkipForward, Trash2, - UserPlus + UserPlus, + Ticket } from "lucide-react"; import { io, type Socket } from "socket.io-client"; -import { addRoomMember, deleteRoom, removeRoomMember, resetPersonalRoom, updateRoomSettings } from "@/lib/room-actions"; +import { addRoomMember, createRoomInvite, deleteRoom, removeRoomMember, resetPersonalRoom, revokeRoomInvite, updateRoomSettings } from "@/lib/room-actions"; import { Avatar } from "./avatar"; import { StatusBadge } from "./status-badge"; import { EmptyState, Panel, StatusDot } from "./ui"; @@ -108,6 +109,12 @@ type RoomSnapshot = { messages: RoomMessage[]; }; +type RoomAck = { + ok: boolean; + at?: number; + message?: string; +}; + type Participant = { id: string; name: string; @@ -116,6 +123,15 @@ type Participant = { status: string; }; +type RoomInvite = { + id: string; + code: string; + status: string; + expiresAt: string | null; + createdAt: string; + creator: string; +}; + export function RoomConsole({ roomId, roomSlug, @@ -123,12 +139,14 @@ export function RoomConsole({ roomVisibility, isPersonal, ownerName, + canManageRoom, initialRail = "Activity", savedState, errorState, currentUser, queue = [], - participants = [] + participants = [], + invites = [] }: { roomId: string; roomSlug: string; @@ -136,12 +154,14 @@ export function RoomConsole({ roomVisibility: string; isPersonal: boolean; ownerName: string; + canManageRoom: boolean; initialRail?: string; savedState?: string; errorState?: string; currentUser: string; queue?: QueueItem[]; participants?: Participant[]; + invites?: RoomInvite[]; }) { const [connected, setConnected] = useState(false); const [source, setSource] = useState(""); @@ -151,6 +171,8 @@ export function RoomConsole({ const [presence, setPresence] = useState(participants); const [playback, setPlayback] = useState(queue[0] ? initialPlayback(queue[0]) : null); const [socketError, setSocketError] = useState(""); + const [syncHealth, setSyncHealth] = useState<"idle" | "pending" | "confirmed" | "failed">("idle"); + const [lastAckAt, setLastAckAt] = useState(null); const [syncBlocked, setSyncBlocked] = useState(false); const [playerReady, setPlayerReady] = useState(false); const [providerIssue, setProviderIssue] = useState(""); @@ -168,7 +190,16 @@ export function RoomConsole({ const emit = useCallback((event: string, payload: Record = {}) => { 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(() => { @@ -224,10 +255,25 @@ export function RoomConsole({ socketRef.current = socket; socket.on("connect", () => { 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("room:state", (snapshot: RoomSnapshot | null) => { if (!snapshot) return; @@ -303,6 +349,7 @@ export function RoomConsole({ actions={
+ {formatVisibility(roomVisibility)}
} @@ -501,6 +548,11 @@ export function RoomConsole({ {message.user} {message.body} + {canManageRoom ? ( + + ) : null} )) )} @@ -514,22 +566,65 @@ export function RoomConsole({ {rail === "Invite" ? (
- {savedState ?

Room member saved.

: null} + {savedState ?

Room invite settings saved.

: null} {errorState === "user" ?

No matching user was found, or the user already owns the room.

: null} -
- - - - -
+ {canManageRoom ? ( + <> +
+ + + +
+ {invites.length > 0 ? ( +
+ {invites.map((invite) => ( +
+ + + {invite.code} + + {invite.status.toLowerCase()} by {invite.creator} - {invite.createdAt} + {invite.expiresAt ? ` - expires ${formatDateTime(invite.expiresAt)}` : ""} + + + + {isExpired(invite.expiresAt) ? "expired" : invite.status.toLowerCase()} + + {invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? ( +
+ + +
+ ) : null} +
+ ))} +
+ ) : null} +
+ + + + +
+ + ) : ( +

Only room managers can create invite codes or change room members.

+ )} {participants.filter((participant) => participant.role !== "Owner").length > 0 ? (
{participants @@ -541,13 +636,15 @@ export function RoomConsole({ {participant.name} {participant.role} -
- - - -
+ {canManageRoom ? ( +
+ + + +
+ ) : null}
))}
@@ -866,3 +963,27 @@ function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: stri function formatVisibility(value: string) { 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) { + return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(new Date(value)); +} + +function isExpired(value: string | null) { + return Boolean(value && new Date(value).getTime() < Date.now()); +} diff --git a/src/lib/admin-actions.ts b/src/lib/admin-actions.ts index 339df4c..95fd806 100644 --- a/src/lib/admin-actions.ts +++ b/src/lib/admin-actions.ts @@ -31,6 +31,44 @@ export async function enableUser(formData: FormData) { 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) { const admin = await requireAdmin(); const userId = String(formData.get("userId") || ""); diff --git a/src/lib/room-actions.ts b/src/lib/room-actions.ts index e6b6ab8..bb8ce0f 100644 --- a/src/lib/room-actions.ts +++ b/src/lib/room-actions.ts @@ -1,5 +1,6 @@ "use server"; +import { randomBytes } from "node:crypto"; import { Prisma, RoomVisibility } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; @@ -171,6 +172,74 @@ export async function addRoomMember(formData: FormData) { redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`); } +export async function createRoomInvite(formData: FormData) { + const user = await requireCurrentUser(); + const roomId = String(formData.get("roomId") || ""); + const expiresDays = Number(formData.get("expiresDays") || 0); + if (!roomId) return; + + const room = await prisma.room.findUnique({ + where: { id: roomId }, + include: { members: { where: { userId: user.id }, select: { canManage: true } } } + }); + + if (!room || !canManageRoom(user, room)) return; + + const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null; + const invite = await prisma.invite.create({ + data: { + code: randomBytes(12).toString("base64url"), + creatorId: user.id, + roomId: room.id, + expiresAt + } + }); + + await prisma.auditEvent.create({ + data: { + actorId: user.id, + roomId: room.id, + action: "room.invite.create", + metadata: { inviteId: invite.id, expiresAt: expiresAt?.toISOString() || null } + } + }); + + revalidateRoom(room.slug); + redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`); +} + +export async function revokeRoomInvite(formData: FormData) { + const user = await requireCurrentUser(); + const inviteId = String(formData.get("inviteId") || ""); + if (!inviteId) return; + + const invite = await prisma.invite.findUnique({ + where: { id: inviteId }, + include: { + room: { + include: { members: { where: { userId: user.id }, select: { canManage: true } } } + } + } + }); + + if (!invite?.room || !canManageRoom(user, invite.room)) return; + + await prisma.$transaction([ + prisma.invite.update({ where: { id: invite.id }, data: { status: "REVOKED" } }), + prisma.auditEvent.create({ + data: { + actorId: user.id, + roomId: invite.room.id, + action: "room.invite.revoke", + metadata: { inviteId: invite.id } + } + }) + ]); + + revalidateRoom(invite.room.slug); + redirect(`/rooms/${encodeURIComponent(invite.room.slug)}?rail=Invite&saved=1`); +} + export async function removeRoomMember(formData: FormData) { const user = await requireCurrentUser(); const roomId = String(formData.get("roomId") || "");