diff --git a/apps/chat/src/index.ts b/apps/chat/src/index.ts index 9700ade..70727f2 100644 --- a/apps/chat/src/index.ts +++ b/apps/chat/src/index.ts @@ -290,6 +290,9 @@ app.get( pfpUrl: botAccount.botAccount.pfpUrl, displayName: botAccount.botAccount.displayName, isBot: true, + moderatorUserId: botAccount.botAccount.ownerId, + isPlatformAdmin: false, + channelRole: null, }; personalChannel = { @@ -309,6 +312,9 @@ app.get( username: userChannel.name, pfpUrl: session.user.pfpUrl, isBot: false, + moderatorUserId: session.user.id, + isPlatformAdmin: Boolean(session.user.isAdmin), + channelRole: null, }; personalChannel = userChannel; } @@ -343,6 +349,16 @@ app.get( id: true, }, }, + chatModerators: { + select: { + id: true, + }, + }, + chatModeratorBots: { + select: { + id: true, + }, + }, }, }); @@ -351,11 +367,41 @@ app.get( return; } + let channelRole: ChatUser['channelRole'] = null; + const activeChatUser = chatUser; + if (activeChatUser) { + if (activeChatUser.isBot) { + if (channel.chatModeratorBots.some((bot) => bot.id === activeChatUser.id)) { + channelRole = 'botModerator'; + } + } else if (channel.ownerId === activeChatUser.id) { + channelRole = 'owner'; + } else if (channel.managers.some((manager) => manager.id === activeChatUser.id)) { + channelRole = 'manager'; + } else if (channel.chatModerators.some((moderator) => moderator.id === activeChatUser.id)) { + channelRole = 'chatModerator'; + } + } + + if (chatUser) { + const moderatorUser = await prisma.user.findUnique({ + where: { id: chatUser.moderatorUserId }, + select: { isAdmin: true }, + }); + + chatUser = { + ...chatUser, + isPlatformAdmin: Boolean(moderatorUser?.isAdmin), + channelRole, + }; + } + const isModerator = Boolean( chatUser && - !chatUser.isBot && - (channel.ownerId === chatUser.id || - channel.managers.some((manager) => manager.id === chatUser.id)) + (chatUser.channelRole === 'owner' || + chatUser.channelRole === 'manager' || + chatUser.channelRole === 'chatModerator' || + chatUser.channelRole === 'botModerator') ); const moderationSettings = await getCachedModerationSettings(channel.id); @@ -485,7 +531,7 @@ app.get( await logModerationEvent({ action: ChatModerationAction.MESSAGE_DELETED, channelId: socketState.channelId, - moderatorId: socketState.chatUser.id, + moderatorId: socketState.chatUser.moderatorUserId, reason: 'Message deleted by moderator', details: { msgId }, }); @@ -510,9 +556,11 @@ app.get( return; } + const actingModeratorUserId = socketState.chatUser.moderatorUserId; + const targetUserId = typeof msg.targetUserId === 'string' ? msg.targetUserId : ''; - if (!targetUserId || targetUserId === socketState.chatUser.id) { + if (!targetUserId || targetUserId === actingModeratorUserId) { socket.send( JSON.stringify({ type: 'moderationError', @@ -542,10 +590,14 @@ app.get( } const actingUserRecord = await prisma.user.findUnique({ - where: { id: socketState.chatUser.id }, + where: { id: actingModeratorUserId }, select: { isAdmin: true }, }); - if (targetUserRecord.isAdmin && !actingUserRecord?.isAdmin) { + if ( + process.env.NODE_ENV === 'production' && + targetUserRecord.isAdmin && + !actingUserRecord?.isAdmin + ) { socket.send( JSON.stringify({ type: 'moderationError', @@ -569,7 +621,7 @@ app.get( await logModerationEvent({ action: ChatModerationAction.USER_UNBANNED, channelId: socketState.channelId, - moderatorId: socketState.chatUser.id, + moderatorId: actingModeratorUserId, targetUserId, reason: 'User unbanned in chat', }); @@ -610,12 +662,12 @@ app.get( create: { channelId: socketState.channelId, userId: targetUserId, - bannedById: socketState.chatUser.id, + bannedById: actingModeratorUserId, reason, expiresAt, }, update: { - bannedById: socketState.chatUser.id, + bannedById: actingModeratorUserId, reason, expiresAt, }, @@ -627,7 +679,7 @@ app.get( ? ChatModerationAction.USER_TIMEOUT : ChatModerationAction.USER_BANNED, channelId: socketState.channelId, - moderatorId: socketState.chatUser.id, + moderatorId: actingModeratorUserId, targetUserId, reason, details: durationSeconds ? { durationSeconds } : undefined, @@ -770,6 +822,8 @@ app.get( pfpUrl: chatUser.pfpUrl, displayName: chatUser.displayName, isBot: chatUser.isBot || false, + isPlatformAdmin: chatUser.isPlatformAdmin, + channelRole: chatUser.channelRole, }, message, msgId, @@ -878,6 +932,9 @@ interface ChatUser { pfpUrl: string; displayName?: string; isBot: boolean; + moderatorUserId: string; + isPlatformAdmin: boolean; + channelRole: 'owner' | 'manager' | 'chatModerator' | 'botModerator' | null; } interface ChatModerationSettingsShape { diff --git a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx index 88811e4..a429d32 100644 --- a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react'; import { format, formatDistanceToNow } from 'date-fns'; -import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -33,7 +32,9 @@ import { Shield, Activity, Hash, + ShieldAlert, } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Dialog, DialogContent, @@ -899,11 +900,53 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) by{' '} {log.actor} + {log.actorMeta?.isChannelModerator && ( + + + + + + Channel Mod + + + )} + {log.actorMeta?.isPlatformAdmin && ( + + + + + + Platform Admin + + + )} {log.target && ( <> {log.target} + {log.targetMeta?.isChannelModerator && ( + + + + + + Channel Mod + + + )} + {log.targetMeta?.isPlatformAdmin && ( + + + + + + + Platform Admin + + + + )} )} @@ -1266,7 +1309,15 @@ interface AuditLog { action: string; createdAt: string; actor: string; + actorMeta?: { + isPlatformAdmin: boolean; + isChannelModerator: boolean; + }; target: string | null; + targetMeta?: { + isPlatformAdmin: boolean; + isChannelModerator: boolean; + } | null; reason: string | null; details?: unknown; channelName?: string; diff --git a/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts b/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts index d213904..cdcee08 100644 --- a/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts +++ b/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts @@ -17,7 +17,10 @@ export async function GET(request: NextRequest) { take, include: { actor: { - include: { + select: { + id: true, + isAdmin: true, + slack_id: true, personalChannel: { select: { name: true, @@ -33,11 +36,15 @@ export async function GET(request: NextRequest) { include: { channel: { select: { + id: true, name: true, }, }, moderator: { - include: { + select: { + id: true, + isAdmin: true, + slack_id: true, personalChannel: { select: { name: true, @@ -46,7 +53,10 @@ export async function GET(request: NextRequest) { }, }, targetUser: { - include: { + select: { + id: true, + isAdmin: true, + slack_id: true, personalChannel: { select: { name: true, @@ -84,6 +94,36 @@ export async function GET(request: NextRequest) { targetUser.personalChannel?.name ?? targetUser.slack_id, ]) ); + const targetUserAdminMap = new Map( + targetUsers.map((targetUser) => [targetUser.id, targetUser.isAdmin]) + ); + + const actorIds = [ + ...new Set([ + ...adminLogs.map((log) => log.actorId), + ...chatLogs.map((log) => log.moderatorId), + ...chatLogs.map((log) => log.targetUserId).filter(Boolean), + ...targetUserIds, + ]), + ] as string[]; + + const modRoleUsers = + actorIds.length > 0 + ? await prisma.user.findMany({ + where: { + id: { in: actorIds }, + OR: [ + { ownedChannels: { some: {} } }, + { managedChannels: { some: {} } }, + { chatModeratedChannels: { some: {} } }, + ], + }, + select: { + id: true, + }, + }) + : []; + const channelModSet = new Set(modRoleUsers.map((user) => user.id)); const normalizedAdminLogs = adminLogs.map((log) => ({ id: log.id, @@ -96,6 +136,16 @@ export async function GET(request: NextRequest) { (log.targetUserId ? (targetUserMap.get(log.targetUserId) ?? log.targetUserId) : null), reason: log.reason, details: log.details, + actorMeta: { + isPlatformAdmin: log.actor.isAdmin, + isChannelModerator: channelModSet.has(log.actorId), + }, + targetMeta: log.targetUserId + ? { + isPlatformAdmin: Boolean(targetUserAdminMap.get(log.targetUserId)), + isChannelModerator: channelModSet.has(log.targetUserId), + } + : null, })); const normalizedChatLogs = chatLogs.map((log) => ({ @@ -108,6 +158,16 @@ export async function GET(request: NextRequest) { reason: log.reason, details: log.details, channelName: log.channel.name, + actorMeta: { + isPlatformAdmin: log.moderator.isAdmin, + isChannelModerator: true, + }, + targetMeta: log.targetUser + ? { + isPlatformAdmin: log.targetUser.isAdmin, + isChannelModerator: channelModSet.has(log.targetUser.id), + } + : null, })); const logs = [...normalizedAdminLogs, ...normalizedChatLogs] diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx index 04697ce..4c845ac 100644 --- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx @@ -22,12 +22,18 @@ import { Eye, EyeOff, MessageSquareWarning, + Bot, } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm'; import { updateChannelSettings, addChannelManager, removeChannelManager, + addChatModerator, + removeChatModerator, + addChatBotModerator, + removeChatBotModerator, deleteChannel, toggleGlobalChannelNotifs, editStreamInfo, @@ -43,6 +49,7 @@ import type { StreamKey, Follow, ChatModerationSettings, + BotAccount, } from '@hctv/db'; import { Dialog, @@ -79,6 +86,10 @@ interface ChannelSettingsClientProps { ownerPersonalChannel: Channel | null; managers: User[]; managerPersonalChannels: (Channel | null)[]; + chatModerators: User[]; + chatModeratorPersonalChannels: (Channel | null)[]; + chatModeratorBots: BotAccount[]; + teamBotAccounts: BotAccount[]; streamInfo: StreamInfo[]; streamKey: StreamKey | null; chatSettings: ChatModerationSettings | null; @@ -671,7 +682,6 @@ export default function ChannelSettingsClient({
- {/* Owner */}
@@ -685,13 +695,9 @@ export default function ChannelSettingsClient({

Channel Owner

- - - Owner - +
- {/* Managers */} {channel.managers.map((manager) => { const personalChannel = channel.managerPersonalChannels.find( (c) => c?.ownerId === manager.id @@ -711,26 +717,34 @@ export default function ChannelSettingsClient({

Manager

- {isOwner && ( - - )} +
+ + {isOwner && ( + + )} +
); })} @@ -828,7 +842,162 @@ export default function ChannelSettingsClient({ chat. - + +
+
+

Moderators

+
+ manager.id), + ...channel.chatModerators.map((moderator) => moderator.id), + ]} + /> + bot.id)} + /> +
+
+ +
+
+
+ + + {channel.owner.slack_id[0]?.toUpperCase()} + +
+

{channel.ownerPersonalChannel?.name}

+

Owner

+
+
+ +
+ + {channel.managers.map((manager) => { + const personalChannel = channel.managerPersonalChannels.find( + (candidate) => candidate?.ownerId === manager.id + ); + return ( +
+
+ + + {personalChannel?.name} + +
+

{personalChannel?.name}

+

Manager

+
+
+ +
+ ); + })} + + {channel.chatModerators.map((moderator) => { + const personalChannel = channel.chatModeratorPersonalChannels.find( + (candidate) => candidate?.ownerId === moderator.id + ); + return ( +
+
+ + + {personalChannel?.name} + +
+

{personalChannel?.name}

+

Chat moderator

+
+
+
+ + +
+
+ ); + })} + + {channel.chatModeratorBots.map((botAccount) => ( +
+
+ + + {botAccount.slug[0]?.toUpperCase()} + +
+

{botAccount.displayName}

+

@{botAccount.slug}

+
+
+
+ + +
+
+ ))} + + {channel.chatModerators.length === 0 && + channel.chatModeratorBots.length === 0 && ( +

+ No extra chat moderators yet. +

+ )} +
+
+ + + + + {label} + + ); +} + +type ChannelRoleBadgeKey = 'owner' | 'manager' | 'chatModerator' | 'botModerator' | 'platformAdmin'; + +const ROLE_BADGE_META: Record< + ChannelRoleBadgeKey, + { icon: LucideIcon; label: string; className: string } +> = { + owner: { + icon: Shield, + label: 'Owner', + className: 'border-primary/30 bg-primary/10 text-primary', + }, + manager: { + icon: Wrench, + label: 'Manager', + className: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300', + }, + chatModerator: { + icon: MessageSquareWarning, + label: 'Chat Mod', + className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + }, + botModerator: { + icon: Bot, + label: 'Bot Mod', + className: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300', + }, + platformAdmin: { + icon: Shield, + label: 'Platform Admin', + className: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300', + }, +}; + +const withPlatformAdmin = ( + roles: readonly ChannelRoleBadgeKey[], + isPlatformAdmin?: boolean +): ChannelRoleBadgeKey[] => (isPlatformAdmin ? [...roles, 'platformAdmin'] : [...roles]); + +function ChannelRoleBadges({ roles }: { roles: ChannelRoleBadgeKey[] }) { + return ( +
+ {[...new Set(roles)].map((role) => { + const meta = ROLE_BADGE_META[role]; + return ( + + ); + })} +
+ ); +} + function AddManagerDialog({ channelId, existingManagers, @@ -966,3 +1203,121 @@ function AddManagerDialog({ ); } + +function AddChatModeratorDialog({ + channelId, + existingModerators, +}: { + channelId: string; + existingModerators: string[]; +}) { + const [open, setOpen] = useState(false); + const [selectedChannel, setSelectedChannel] = useState(''); + + return ( + + + + + + + Add chat moderator + + Choose a user who should be able to moderate this channel's chat. + + + { + setSelectedChannel(value); + }} + filter={existingModerators} + value={selectedChannel} + modal + /> + + + + + + ); +} + +function AddChatBotModeratorDialog({ + channelId, + teamBots, + existingBotModerators, +}: { + channelId: string; + teamBots: BotAccount[]; + existingBotModerators: string[]; +}) { + const [open, setOpen] = useState(false); + const [selectedBotId, setSelectedBotId] = useState(''); + + const availableBots = teamBots.filter( + (botAccount) => !existingBotModerators.includes(botAccount.id) + ); + + return ( + + + + + + + Add bot moderator + + Bots can delete messages, timeout users, and ban users in chat. + + + + + + + + + ); +} diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx index 0c0c4ff..678168c 100644 --- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx +++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx @@ -22,6 +22,8 @@ export default async function ChannelSettingsPage({ include: { owner: true, managers: true, + chatModerators: true, + chatModeratorBots: true, streamInfo: true, streamKey: true, chatSettings: true, @@ -53,17 +55,38 @@ export default async function ChannelSettingsPage({ const managerPersonalChannels = await Promise.all( channel.managers.map((manager) => resolvePersonalChannel(manager.id)) ); + const managerIds = new Set(channel.managers.map((manager) => manager.id)); + const extraChatModerators = channel.chatModerators.filter( + (moderator) => moderator.id !== channel.ownerId && !managerIds.has(moderator.id) + ); + const chatModeratorPersonalChannels = await Promise.all( + extraChatModerators.map((moderator) => resolvePersonalChannel(moderator.id)) + ); const followerPersonalChannels = await Promise.all( channel.followers.map((follower) => resolvePersonalChannel(follower.user.id)) ); + const teamMemberIds = [channel.ownerId, ...channel.managers.map((manager) => manager.id)]; + const teamBotAccounts = await prisma.botAccount.findMany({ + where: { + ownerId: { + in: teamMemberIds, + }, + }, + orderBy: { + slug: 'asc', + }, + }); return ( ; + +const ROLE_META: Record = { + owner: { label: 'Owner', icon: Crown, className: 'text-amber-500' }, + manager: { label: 'Manager', icon: Wrench, className: 'text-violet-500' }, + chatModerator: { label: 'Chat Mod', icon: Shield, className: 'text-emerald-500' }, + botModerator: { label: 'Bot Mod', icon: ShieldCheck, className: 'text-cyan-500' }, +}; + +function TooltipIcon({ + icon: Icon, + label, + className, +}: { + icon: LucideIcon; + label: string; + className?: string; +}) { + return ( + + + + + {label} + + ); +} + +function UsernameRow({ user, displayName }: { user?: User; displayName?: string }) { + const role = user?.channelRole ? ROLE_META[user.channelRole] : null; + + return ( + + + {user?.isBot && } + {role && } + {user?.isPlatformAdmin && ( + + )} + {displayName} + : + + + ); +} + +function ReportDialog({ + open, + onOpenChange, + displayName, + message, + reportReason, + onReasonChange, + onSubmit, + isSubmitting, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + displayName?: string; + message: string; + reportReason: string; + onReasonChange: (value: string) => void; + onSubmit: () => void; + isSubmitting: boolean; +}) { + return ( + + + + Report message + + Message against Hack Club's Code of Conduct? Let us know! + + +
+
+

Reported user

+

{displayName}

+

{message}

+
+
+ +