4 Commits

Author SHA1 Message Date
ToxicCrzay270
d5ab61c0c2 Add stale invite expiry action
All checks were successful
Template Compliance / compliance (push) Successful in 5s
2026-06-11 22:54:14 +02:00
ToxicCrzay270
928a3815ed Improve invite handling
All checks were successful
Template Compliance / compliance (push) Successful in 5s
2026-06-11 22:42:35 +02:00
ToxicCrzay270
abee76c9b1 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
2026-06-11 21:00:57 +02:00
ToxicCrzay270
699232f5c6 Add room invites and chat moderation
All checks were successful
Template Compliance / compliance (push) Successful in 7s
2026-06-11 14:39:08 +02:00
11 changed files with 457 additions and 75 deletions

View File

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

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,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 },

View File

@@ -12,8 +12,9 @@ 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, expireStaleInvites, grantAdminRole, removeUserFriendships, revokeAdminRole, revokeInvite } from "@/lib/admin-actions";
import { deleteRoom } from "@/lib/room-actions"; import { deleteRoom } from "@/lib/room-actions";
import { isInviteExpired } from "@/lib/invites";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -34,7 +35,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 +133,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 +145,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 +173,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 +199,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>
@@ -322,6 +340,8 @@ function InvitesPanel({
}>; }>;
rooms: Array<{ id: string; name: string; slug: string }>; rooms: Array<{ id: string; name: string; slug: string }>;
}) { }) {
const expiredInviteCount = invites.filter((invite) => invite.status === "ACTIVE" && isInviteExpired(invite.expiresAt)).length;
return ( return (
<div className="split-grid"> <div className="split-grid">
<Panel title="Create Invite" eyebrow="Access"> <Panel title="Create Invite" eyebrow="Access">
@@ -343,25 +363,37 @@ function InvitesPanel({
</form> </form>
</Panel> </Panel>
<Panel title="Invites" eyebrow={`${invites.length} recent`}> <Panel title="Invites" eyebrow={`${invites.length} recent`}>
{expiredInviteCount > 0 ? (
<form action={expireStaleInvites} className="inline-form">
<button className="button compact-button" type="submit">Mark {expiredInviteCount} expired</button>
</form>
) : null}
{invites.length === 0 ? ( {invites.length === 0 ? (
<EmptyState title="No invites yet" description="Create an invite to allow registration or room access workflows." /> <EmptyState title="No invites yet" description="Create an invite to allow registration or room access workflows." />
) : ( ) : (
<div className="settings-list"> <div className="settings-list">
{invites.map((invite) => ( {invites.map((invite) => {
<div className="setting-row" key={invite.id}> const expired = isInviteExpired(invite.expiresAt);
<span> const label = expired && invite.status === "ACTIVE" ? "expired" : invite.status.toLowerCase();
<strong>{invite.code}</strong> return (
<small>{invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - {formatDate(invite.createdAt)}</small> <div className="setting-row" key={invite.id}>
</span> <span>
<StatusBadge tone={invite.status === "ACTIVE" ? "good" : "warn"}>{invite.status.toLowerCase()}</StatusBadge> <strong>{invite.code}</strong>
{invite.status === "ACTIVE" ? ( <small>
<form action={revokeInvite}> {invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - created {formatDate(invite.createdAt)}
<input type="hidden" name="inviteId" value={invite.id} /> {invite.expiresAt ? ` - expires ${formatDate(invite.expiresAt)}` : " - no expiry"}
<button className="button compact-button danger" type="submit">Revoke</button> </small>
</form> </span>
) : null} <StatusBadge tone={invite.status === "ACTIVE" && !expired ? "good" : "warn"}>{label}</StatusBadge>
</div> {invite.status === "ACTIVE" && !expired ? (
))} <form action={revokeInvite}>
<input type="hidden" name="inviteId" value={invite.id} />
<button className="button compact-button danger" type="submit">Revoke</button>
</form>
) : null}
</div>
);
})}
</div> </div>
)} )}
</Panel> </Panel>

View File

@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) { export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string; invite?: string }> }) {
if (!(await hasAdminUser())) { if (!(await hasAdminUser())) {
redirect("/setup"); redirect("/setup");
} }
@@ -14,7 +14,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
if (settings.registrationMode === "DISABLED") { if (settings.registrationMode === "DISABLED") {
redirect("/login?error=registration-closed"); redirect("/login?error=registration-closed");
} }
const { error } = await searchParams; const { error, invite = "" } = await searchParams;
return ( return (
<main className="auth-page"> <main className="auth-page">
@@ -44,7 +44,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
{settings.registrationMode === "INVITE_ONLY" ? ( {settings.registrationMode === "INVITE_ONLY" ? (
<label> <label>
Invite code Invite code
<input className="input" name="inviteCode" autoComplete="off" required /> <input className="input" name="inviteCode" autoComplete="off" defaultValue={invite} required />
</label> </label>
) : null} ) : null}
<button className="button primary" type="submit"> <button className="button primary" type="submit">

View File

@@ -41,6 +41,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({
@@ -69,6 +70,15 @@ export default async function RoomPage({
redirect("/dashboard"); redirect("/dashboard");
} }
const roomInvites = canManageRoom
? await prisma.invite.findMany({
where: { roomId: room.id },
include: { creator: true },
orderBy: { createdAt: "desc" },
take: 12
})
: [];
const shell = await getShellContext(user); const shell = await getShellContext(user);
return ( return (
@@ -97,6 +107,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}
@@ -134,6 +145,14 @@ export default async function RoomPage({
status: member.userId === user.id ? "Online" : "Allowed" status: member.userId === user.id ? "Online" : "Allowed"
})) }))
]} ]}
invites={roomInvites.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>
); );

View File

@@ -5,6 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Check,
Clipboard,
ExternalLink, ExternalLink,
MessageSquareText, MessageSquareText,
Pause, Pause,
@@ -15,10 +17,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 +111,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 +125,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 +141,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 +156,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 +173,9 @@ 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 [copiedInviteId, setCopiedInviteId] = useState("");
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 +193,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 +258,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;
@@ -293,6 +342,18 @@ export function RoomConsole({
form.reset(); form.reset();
} }
async function copyInvite(invite: RoomInvite) {
try {
const inviteUrl = new URL("/register", window.location.origin);
inviteUrl.searchParams.set("invite", invite.code);
await navigator.clipboard.writeText(inviteUrl.toString());
setCopiedInviteId(invite.id);
window.setTimeout(() => setCopiedInviteId((current) => (current === invite.id ? "" : current)), 2000);
} catch {
setSocketError("Invite link could not be copied.");
}
}
return ( return (
<section className="watch-console"> <section className="watch-console">
<div className="player-column"> <div className="player-column">
@@ -303,6 +364,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 +563,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 +581,68 @@ 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>
<button className="icon-button compact animated-button" type="button" title="Copy invite link" onClick={() => void copyInvite(invite)}>
{copiedInviteId === invite.id ? <Check size={14} /> : <Clipboard size={14} />}
</button>
{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 +654,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 +981,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());
}

View File

@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { requireCurrentUser, userIsAdmin } from "./session"; import { requireCurrentUser, userIsAdmin } from "./session";
import { inviteExpiresAt } from "./invites";
async function requireAdmin() { async function requireAdmin() {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
@@ -31,6 +32,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") || "");
@@ -63,8 +102,7 @@ export async function revokeAdminRole(formData: FormData) {
export async function createInstanceInvite(formData: FormData) { export async function createInstanceInvite(formData: FormData) {
const admin = await requireAdmin(); const admin = await requireAdmin();
const roomId = String(formData.get("roomId") || "") || null; const roomId = String(formData.get("roomId") || "") || null;
const expiresDays = Number(formData.get("expiresDays") || 0); const expiresAt = inviteExpiresAt(formData.get("expiresDays"));
const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null;
const invite = await prisma.invite.create({ const invite = await prisma.invite.create({
data: { data: {
code: randomBytes(12).toString("base64url"), code: randomBytes(12).toString("base64url"),
@@ -87,6 +125,19 @@ export async function revokeInvite(formData: FormData) {
revalidateAdmin(); revalidateAdmin();
} }
export async function expireStaleInvites() {
const admin = await requireAdmin();
const result = await prisma.invite.updateMany({
where: {
status: "ACTIVE",
expiresAt: { lt: new Date() }
},
data: { status: "EXPIRED" }
});
await audit(admin.id, "admin.invites.expire", { expiredInvites: result.count });
revalidateAdmin();
}
async function audit(actorId: string, action: string, metadata: Prisma.InputJsonObject) { async function audit(actorId: string, action: string, metadata: Prisma.InputJsonObject) {
await prisma.auditEvent.create({ data: { actorId, action, metadata } }); await prisma.auditEvent.create({ data: { actorId, action, metadata } });
} }

12
src/lib/invites.ts Normal file
View File

@@ -0,0 +1,12 @@
const maxInviteExpirationDays = 365;
export function inviteExpiresAt(input: FormDataEntryValue | null, now = Date.now()) {
const days = Math.trunc(Number(input || 0));
if (!Number.isFinite(days) || days <= 0) return null;
return new Date(now + Math.min(days, maxInviteExpirationDays) * 24 * 60 * 60 * 1000);
}
export function isInviteExpired(expiresAt: Date | string | null, now = Date.now()) {
if (!expiresAt) return false;
return new Date(expiresAt).getTime() < now;
}

View File

@@ -1,11 +1,13 @@
"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";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { requireCurrentUser, userIsAdmin } from "./session"; import { requireCurrentUser, userIsAdmin } from "./session";
import { getAppSettings } from "./settings"; import { getAppSettings } from "./settings";
import { inviteExpiresAt } from "./invites";
function normalizeSlug(value: string) { function normalizeSlug(value: string) {
return value return value
@@ -171,6 +173,73 @@ 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") || "");
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 = inviteExpiresAt(formData.get("expiresDays"));
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") || "");

View File

@@ -6,6 +6,7 @@ import { Prisma, type User } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { clearSession, setSession } from "./session"; import { clearSession, setSession } from "./session";
import { getAppSettings } from "./settings"; import { getAppSettings } from "./settings";
import { isInviteExpired } from "./invites";
function normalizeUsername(value: FormDataEntryValue | null) { function normalizeUsername(value: FormDataEntryValue | null) {
return String(value || "") return String(value || "")
@@ -32,11 +33,15 @@ export async function registerUser(formData: FormData) {
settings.registrationMode === "INVITE_ONLY" settings.registrationMode === "INVITE_ONLY"
? await prisma.invite.findUnique({ where: { code: inviteCode } }) ? await prisma.invite.findUnique({ where: { code: inviteCode } })
: null; : null;
const inviteExpired = isInviteExpired(invite?.expiresAt || null);
if ( if (
settings.registrationMode === "INVITE_ONLY" && settings.registrationMode === "INVITE_ONLY" &&
(!invite || invite.status !== "ACTIVE" || (invite.expiresAt && invite.expiresAt.getTime() < Date.now())) (!invite || invite.status !== "ACTIVE" || inviteExpired)
) { ) {
if (invite?.status === "ACTIVE" && inviteExpired) {
await prisma.invite.update({ where: { id: invite.id }, data: { status: "EXPIRED" } });
}
redirect("/register?error=invite"); redirect("/register?error=invite");
} }

28
tests/invites.test.ts Normal file
View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { inviteExpiresAt, isInviteExpired } from "../src/lib/invites";
const now = Date.UTC(2026, 0, 1);
const day = 24 * 60 * 60 * 1000;
describe("inviteExpiresAt", () => {
it("returns null for invalid or non-expiring values", () => {
expect(inviteExpiresAt(null, now)).toBeNull();
expect(inviteExpiresAt("0", now)).toBeNull();
expect(inviteExpiresAt("-3", now)).toBeNull();
expect(inviteExpiresAt("not-a-number", now)).toBeNull();
});
it("creates an expiration date for valid day counts", () => {
expect(inviteExpiresAt("7", now)?.getTime()).toBe(now + 7 * day);
});
it("caps expiration at one year", () => {
expect(inviteExpiresAt("9999", now)?.getTime()).toBe(now + 365 * day);
});
it("detects expired invite timestamps", () => {
expect(isInviteExpired(new Date(now - day), now)).toBe(true);
expect(isInviteExpired(new Date(now + day), now)).toBe(false);
expect(isInviteExpired(null, now)).toBe(false);
});
});