feat(admin): show deleted message content on audit logs

This commit is contained in:
2026-04-07 16:07:58 +02:00
parent 2b270db2ed
commit 4a708ac92f
8 changed files with 68 additions and 21 deletions

View File

@@ -280,23 +280,32 @@ async function logModerationEvent(payload: {
});
}
async function deleteMessageFromHistory(targetUsername: string, msgId: string): Promise<boolean> {
async function deleteMessageFromHistory(
targetUsername: string,
msgId: string
): Promise<{ deleted: boolean; messageContent?: string }> {
const channelKey = `chat:history:${targetUsername}`;
const history = await redis.zrange(channelKey, 0, -1);
for (const entry of history) {
try {
const parsed = JSON.parse(entry) as { msgId?: string };
const parsed = JSON.parse(entry) as { msgId?: string; message?: string };
if (parsed.msgId === msgId) {
await redis.zrem(channelKey, entry);
return true;
return {
deleted: true,
messageContent:
typeof parsed.message === 'string' && parsed.message.length > 0
? parsed.message
: undefined,
};
}
} catch {
continue;
}
}
return false;
return { deleted: false };
}
const app = new Hono();

View File

@@ -26,7 +26,10 @@ type ModerationContext = {
};
type DeleteMessageDeps = {
deleteMessageFromHistory: (targetUsername: string, msgId: string) => Promise<boolean>;
deleteMessageFromHistory: (
targetUsername: string,
msgId: string
) => Promise<{ deleted: boolean; messageContent?: string }>;
logModerationEvent: (payload: {
action: ChatModerationAction;
channelId: string;
@@ -258,8 +261,8 @@ export async function handleDeleteMessageCommand(
return;
}
const deleted = await deps.deleteMessageFromHistory(context.targetUsername, msgId);
if (!deleted) {
const deletedMessage = await deps.deleteMessageFromHistory(context.targetUsername, msgId);
if (!deletedMessage.deleted) {
sendModerationError(socket, 'NOT_FOUND', 'Message not found.');
return;
}
@@ -269,7 +272,10 @@ export async function handleDeleteMessageCommand(
channelId: context.channelId,
moderatorId: context.chatUser.moderatorUserId,
reason: 'Message deleted by moderator',
details: { msgId },
details: {
msgId,
messageContent: deletedMessage.messageContent,
},
});
recordChatModerationAction('message_deleted');

View File

@@ -1019,6 +1019,20 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
{log.reason}
</p>
)}
{log.deletedMessageContent && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Deleted message
</p>
<p className="mt-1 break-words text-xs leading-relaxed text-foreground/90">
{log.deletedMessageContent}
</p>
</div>
</div>
)}
</div>
</div>
);
@@ -1184,10 +1198,10 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</div>
)}
</div>
)}
{activeTab === 'settings' && (
<div>
)}
{activeTab === 'settings' && (
<div>
<SectionHeader
icon={<Settings className="h-4 w-4" />}
title="Platform Settings"
@@ -1205,7 +1219,8 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
ID Verification Bypass
</h3>
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
Allow existing users to bypass HCA verification and let them access the platform.
Allow existing users to bypass HCA verification and let them access the
platform.
</p>
</div>
</div>
@@ -1231,16 +1246,15 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
Start searching to manage users
</p>
<p className="text-xs text-muted-foreground">
Type an email or username above to find users and toggle their verification bypass
Type an email or username above to find users and toggle their
verification bypass
</p>
</div>
) : users.length === 0 ? (
<div className="rounded-lg border-2 border-dashed border-border bg-muted/30 p-6 text-center">
<XCircle className="mx-auto h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm font-medium text-foreground mb-1">No users found</p>
<p className="text-xs text-muted-foreground">
Try a different search term
</p>
<p className="text-xs text-muted-foreground">Try a different search term</p>
</div>
) : (
<>
@@ -1292,7 +1306,8 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
onClick={() => handleToggleBypassVerification(user.id)}
className={cn(
'h-7 text-xs gap-1 shrink-0 font-semibold transition-all duration-200',
user.bypassVerification && 'border-primary/30 hover:bg-primary/10 hover:border-primary/50'
user.bypassVerification &&
'border-primary/30 hover:bg-primary/10 hover:border-primary/50'
)}
>
{user.bypassVerification ? (
@@ -1327,7 +1342,8 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
<div className="flex-1">
<h3 className="text-base font-bold tracking-tight">Session Management</h3>
<p className="mt-0.5 text-xs text-muted-foreground mb-3 leading-relaxed">
Force logout all other sessions except your current one. Useful for security maintenance.
Force logout all other sessions except your current one. Useful for
security maintenance.
</p>
<Button
variant="outline"
@@ -1536,6 +1552,7 @@ interface AuditLog {
reason: string | null;
details?: unknown;
channelName?: string;
deletedMessageContent?: string | null;
}
interface ChatReport {

View File

@@ -2,6 +2,15 @@ import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
function getDeletedMessageContent(details: unknown): string | null {
if (!details || typeof details !== 'object' || Array.isArray(details)) {
return null;
}
const messageContent = (details as { messageContent?: unknown }).messageContent;
return typeof messageContent === 'string' && messageContent.length > 0 ? messageContent : null;
}
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
@@ -136,6 +145,7 @@ export async function GET(request: NextRequest) {
(log.targetUserId ? (targetUserMap.get(log.targetUserId) ?? log.targetUserId) : null),
reason: log.reason,
details: log.details,
deletedMessageContent: getDeletedMessageContent(log.details),
actorMeta: {
isPlatformAdmin: log.actor.isAdmin,
isChannelModerator: channelModSet.has(log.actorId),
@@ -157,6 +167,7 @@ export async function GET(request: NextRequest) {
target: log.targetUser?.personalChannel?.name ?? log.channel.name,
reason: log.reason,
details: log.details,
deletedMessageContent: getDeletedMessageContent(log.details),
channelName: log.channel.name,
actorMeta: {
isPlatformAdmin: log.moderator.isAdmin,

View File

@@ -292,6 +292,7 @@ export async function POST(request: NextRequest) {
details: {
reportId,
msgId: report.reportedMessageId,
messageContent: report.reportedMessage,
} as any,
},
});
@@ -307,6 +308,7 @@ export async function POST(request: NextRequest) {
reportId,
enforcement: 'DELETE_REPORTED_MESSAGE',
msgId: report.reportedMessageId,
messageContent: report.reportedMessage,
} as any,
},
});

View File

@@ -200,7 +200,7 @@ export default function StreamPlayer() {
{(process.env.NODE_ENV === 'development' || userInfo?.isLive) && (
<MediaChromeButton onClick={() => triggerRecovery('manual_reload')}>
<span className="flex h-4 w-4 items-center justify-center">
<RefreshCw className="h-5 w-5 shrink-0" strokeWidth={2.5} />
<RefreshCw className={cn("h-5 w-5 shrink-0", isRecovering && "animate-spin")} strokeWidth={2.5} />
</span>
<span slot="tooltip-content">Retry stream</span>
</MediaChromeButton>

View File

@@ -15,6 +15,7 @@
"docker:chat": "dotenvx run -f .env.docker -- docker buildx build --platform linux/amd64 -f apps/chat/Dockerfile . --secret id=TURBO_TOKEN,env=TURBO_TOKEN --secret id=TURBO_TEAM,env=TURBO_TEAM --no-cache",
"act": "act --secret-file .env.ci",
"db:migrate": "pnpm --filter=@hctv/db db:migrate",
"db:studio": "pnpm --filter=@hctv/db db:studio",
"ui:add": "pnpm --filter=@hctv/web ui:add",
"prisma": "pnpm --filter=@hctv/db prisma",
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0",

View File

@@ -19,6 +19,7 @@
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:populate-verification": "tsx src/populateHackClubVerification.ts",
"db:studio": "prisma studio",
"build": "prisma generate && tsc --build",
"dev": "tsc --watch --preserveWatchOutput"
},
@@ -27,4 +28,4 @@
"tsx": "^4.7.1",
"typescript": "^5.8.2"
}
}
}