Add room invites and chat moderation
All checks were successful
Template Compliance / compliance (push) Successful in 7s
All checks were successful
Template Compliance / compliance (push) Successful in 7s
This commit is contained in:
@@ -17,4 +17,6 @@ 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.
|
||||||
|
- Remaining UI redesign follow-ups that need real backend work: user ban/remove-friend admin actions and socket acknowledgements for sync health instead of client-local connection assumptions.
|
||||||
|
|||||||
15
server.js
15
server.js
@@ -160,6 +160,16 @@ app.prepare().then(() => {
|
|||||||
await broadcastRoom(io, room.slug, room.id);
|
await broadcastRoom(io, room.slug, room.id);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
socket.on("chat:delete", (payload) => safeRoomAction(socket, async ({ room, user }) => {
|
||||||
|
if (!canManageRoom(room, user.id, user)) return reject(socket, "Only room managers can moderate chat.");
|
||||||
|
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;
|
||||||
@@ -276,6 +286,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 },
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -116,6 +117,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 +133,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 +148,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("");
|
||||||
@@ -501,6 +515,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 +533,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 +603,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 +930,11 @@ 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 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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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