Add room invites, moderation, and sync acknowledgements #2
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
65
server.js
65
server.js
@@ -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,6 +161,16 @@ app.prepare().then(() => {
|
|||||||
await broadcastRoom(io, room.slug, room.id);
|
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", () => {
|
socket.on("disconnect", () => {
|
||||||
const roomSlug = socket.data.roomSlug;
|
const roomSlug = socket.data.roomSlug;
|
||||||
const user = socket.data.user;
|
const user = socket.data.user;
|
||||||
@@ -175,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) {
|
||||||
@@ -276,6 +294,11 @@ async function getRoomContext(slug, userId) {
|
|||||||
return { allowed, room };
|
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) {
|
async function buildRoomSnapshot(roomId) {
|
||||||
const room = await prisma.room.findUnique({
|
const room = await prisma.room.findUnique({
|
||||||
where: { id: roomId },
|
where: { id: roomId },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export default async function RoomPage({
|
|||||||
include: {
|
include: {
|
||||||
owner: true,
|
owner: true,
|
||||||
members: { include: { user: 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 }
|
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 isOwner = room.ownerId === user.id;
|
||||||
const explicitMember = room.members.some((member) => member.userId === 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
|
const isFriend = room.ownerId
|
||||||
? Boolean(
|
? Boolean(
|
||||||
await prisma.friendship.findFirst({
|
await prisma.friendship.findFirst({
|
||||||
@@ -97,6 +103,7 @@ export default async function RoomPage({
|
|||||||
roomVisibility={room.visibility}
|
roomVisibility={room.visibility}
|
||||||
isPersonal={room.isPersonal}
|
isPersonal={room.isPersonal}
|
||||||
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
|
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
|
||||||
|
canManageRoom={canManageRoom}
|
||||||
initialRail={rail}
|
initialRail={rail}
|
||||||
savedState={saved}
|
savedState={saved}
|
||||||
errorState={error}
|
errorState={error}
|
||||||
@@ -132,8 +139,16 @@ export default async function RoomPage({
|
|||||||
avatarUrl: member.user.avatarUrl,
|
avatarUrl: member.user.avatarUrl,
|
||||||
role: member.canManage ? "Manager" : "Member",
|
role: member.canManage ? "Manager" : "Member",
|
||||||
status: member.userId === user.id ? "Online" : "Allowed"
|
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"
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserPlus
|
UserPlus,
|
||||||
|
Ticket
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { io, type Socket } from "socket.io-client";
|
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 { Avatar } from "./avatar";
|
||||||
import { StatusBadge } from "./status-badge";
|
import { StatusBadge } from "./status-badge";
|
||||||
import { EmptyState, Panel, StatusDot } from "./ui";
|
import { EmptyState, Panel, StatusDot } from "./ui";
|
||||||
@@ -108,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;
|
||||||
@@ -116,6 +123,15 @@ type Participant = {
|
|||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RoomInvite = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
creator: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function RoomConsole({
|
export function RoomConsole({
|
||||||
roomId,
|
roomId,
|
||||||
roomSlug,
|
roomSlug,
|
||||||
@@ -123,12 +139,14 @@ export function RoomConsole({
|
|||||||
roomVisibility,
|
roomVisibility,
|
||||||
isPersonal,
|
isPersonal,
|
||||||
ownerName,
|
ownerName,
|
||||||
|
canManageRoom,
|
||||||
initialRail = "Activity",
|
initialRail = "Activity",
|
||||||
savedState,
|
savedState,
|
||||||
errorState,
|
errorState,
|
||||||
currentUser,
|
currentUser,
|
||||||
queue = [],
|
queue = [],
|
||||||
participants = []
|
participants = [],
|
||||||
|
invites = []
|
||||||
}: {
|
}: {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
roomSlug: string;
|
roomSlug: string;
|
||||||
@@ -136,12 +154,14 @@ export function RoomConsole({
|
|||||||
roomVisibility: string;
|
roomVisibility: string;
|
||||||
isPersonal: boolean;
|
isPersonal: boolean;
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
|
canManageRoom: boolean;
|
||||||
initialRail?: string;
|
initialRail?: string;
|
||||||
savedState?: string;
|
savedState?: string;
|
||||||
errorState?: string;
|
errorState?: string;
|
||||||
currentUser: string;
|
currentUser: string;
|
||||||
queue?: QueueItem[];
|
queue?: QueueItem[];
|
||||||
participants?: Participant[];
|
participants?: Participant[];
|
||||||
|
invites?: RoomInvite[];
|
||||||
}) {
|
}) {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
@@ -151,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("");
|
||||||
@@ -168,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(() => {
|
||||||
@@ -224,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;
|
||||||
@@ -303,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>
|
||||||
}
|
}
|
||||||
@@ -501,6 +548,11 @@ export function RoomConsole({
|
|||||||
<strong>{message.user}</strong>
|
<strong>{message.user}</strong>
|
||||||
<span>{message.body}</span>
|
<span>{message.body}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{canManageRoom ? (
|
||||||
|
<button className="icon-button compact danger animated-button" type="button" title="Delete message" onClick={() => emit("chat:delete", { messageId: message.id })}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -514,22 +566,65 @@ export function RoomConsole({
|
|||||||
|
|
||||||
{rail === "Invite" ? (
|
{rail === "Invite" ? (
|
||||||
<div className="settings-list" id="room-invite">
|
<div className="settings-list" id="room-invite">
|
||||||
{savedState ? <p className="form-success">Room member saved.</p> : null}
|
{savedState ? <p className="form-success">Room invite settings saved.</p> : null}
|
||||||
{errorState === "user" ? <p className="form-error">No matching user was found, or the user already owns the room.</p> : null}
|
{errorState === "user" ? <p className="form-error">No matching user was found, or the user already owns the room.</p> : null}
|
||||||
<form className="form compact-form" action={addRoomMember}>
|
{canManageRoom ? (
|
||||||
<input type="hidden" name="roomId" value={roomId} />
|
<>
|
||||||
<label>
|
<form className="form compact-form" action={createRoomInvite}>
|
||||||
Username
|
<input type="hidden" name="roomId" value={roomId} />
|
||||||
<input className="input" name="username" placeholder="username" autoComplete="off" required />
|
<label>
|
||||||
</label>
|
Invite expires after days
|
||||||
<label className="inline-check">
|
<input className="input" name="expiresDays" type="number" min="0" max="365" defaultValue="7" />
|
||||||
<input name="canManage" type="checkbox" />
|
</label>
|
||||||
Can manage room
|
<button className="button primary animated-button" type="submit">
|
||||||
</label>
|
<Ticket size={16} /> Create invite code
|
||||||
<button className="button primary animated-button" type="submit">
|
</button>
|
||||||
<UserPlus size={16} /> Add member
|
</form>
|
||||||
</button>
|
{invites.length > 0 ? (
|
||||||
</form>
|
<div className="settings-list">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<div className="setting-row" key={invite.id}>
|
||||||
|
<Ticket size={16} />
|
||||||
|
<span>
|
||||||
|
<strong>{invite.code}</strong>
|
||||||
|
<small>
|
||||||
|
{invite.status.toLowerCase()} by {invite.creator} - {invite.createdAt}
|
||||||
|
{invite.expiresAt ? ` - expires ${formatDateTime(invite.expiresAt)}` : ""}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<StatusBadge tone={invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? "good" : "warn"}>
|
||||||
|
{isExpired(invite.expiresAt) ? "expired" : invite.status.toLowerCase()}
|
||||||
|
</StatusBadge>
|
||||||
|
{invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? (
|
||||||
|
<form action={revokeRoomInvite}>
|
||||||
|
<input type="hidden" name="inviteId" value={invite.id} />
|
||||||
|
<button className="icon-button compact danger animated-button" type="submit" title="Revoke invite">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form className="form compact-form" action={addRoomMember}>
|
||||||
|
<input type="hidden" name="roomId" value={roomId} />
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input className="input" name="username" placeholder="username" autoComplete="off" required />
|
||||||
|
</label>
|
||||||
|
<label className="inline-check">
|
||||||
|
<input name="canManage" type="checkbox" />
|
||||||
|
Can manage room
|
||||||
|
</label>
|
||||||
|
<button className="button primary animated-button" type="submit">
|
||||||
|
<UserPlus size={16} /> Add member
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="disabled-note">Only room managers can create invite codes or change room members.</p>
|
||||||
|
)}
|
||||||
{participants.filter((participant) => participant.role !== "Owner").length > 0 ? (
|
{participants.filter((participant) => participant.role !== "Owner").length > 0 ? (
|
||||||
<div className="settings-list">
|
<div className="settings-list">
|
||||||
{participants
|
{participants
|
||||||
@@ -541,13 +636,15 @@ export function RoomConsole({
|
|||||||
<strong>{participant.name}</strong>
|
<strong>{participant.name}</strong>
|
||||||
<small>{participant.role}</small>
|
<small>{participant.role}</small>
|
||||||
</span>
|
</span>
|
||||||
<form action={removeRoomMember}>
|
{canManageRoom ? (
|
||||||
<input type="hidden" name="roomId" value={roomId} />
|
<form action={removeRoomMember}>
|
||||||
<input type="hidden" name="userId" value={participant.id} />
|
<input type="hidden" name="roomId" value={roomId} />
|
||||||
<button className="icon-button compact danger animated-button" type="submit" title="Remove member">
|
<input type="hidden" name="userId" value={participant.id} />
|
||||||
<Trash2 size={14} />
|
<button className="icon-button compact danger animated-button" type="submit" title="Remove member">
|
||||||
</button>
|
<Trash2 size={14} />
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -866,3 +963,27 @@ function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: stri
|
|||||||
function formatVisibility(value: string) {
|
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) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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") || "");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { Prisma, RoomVisibility } from "@prisma/client";
|
import { Prisma, RoomVisibility } from "@prisma/client";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -171,6 +172,74 @@ export async function addRoomMember(formData: FormData) {
|
|||||||
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
|
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) {
|
export async function removeRoomMember(formData: FormData) {
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
const roomId = String(formData.get("roomId") || "");
|
const roomId = String(formData.get("roomId") || "");
|
||||||
|
|||||||
Reference in New Issue
Block a user