498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
/** @jsxRuntime automatic */
|
|
/** @jsxImportSource hono/jsx */
|
|
|
|
import type {Report} from '@fluxer/admin/src/api/Reports';
|
|
import {Layout} from '@fluxer/admin/src/components/Layout';
|
|
import {createMessageDeletionScriptBody, MessageList} from '@fluxer/admin/src/components/MessageList';
|
|
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
|
import {NavLink} from '@fluxer/admin/src/components/ui/NavLink';
|
|
import {ResourceLink} from '@fluxer/admin/src/components/ui/ResourceLink';
|
|
import {TextLink} from '@fluxer/admin/src/components/ui/TextLink';
|
|
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
|
import type {Session} from '@fluxer/admin/src/types/App';
|
|
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
|
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
|
|
import type {Flash} from '@fluxer/hono/src/Flash';
|
|
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
|
import {Button} from '@fluxer/ui/src/components/Button';
|
|
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
|
import {FlexRow} from '@fluxer/ui/src/components/Layout';
|
|
import type {FC} from 'hono/jsx';
|
|
|
|
interface MessageContext {
|
|
id: string;
|
|
channel_id: string;
|
|
guild_id: string | null;
|
|
content: string;
|
|
timestamp: string;
|
|
attachments: Array<{filename: string; url: string}>;
|
|
author_id: string;
|
|
author_username: string;
|
|
author_discriminator: string;
|
|
}
|
|
|
|
export interface ReportDetailPageProps {
|
|
config: Config;
|
|
session: Session;
|
|
currentAdmin: UserAdminResponse | undefined;
|
|
flash: Flash | undefined;
|
|
assetVersion: string;
|
|
report: Report;
|
|
csrfToken: string;
|
|
}
|
|
|
|
function formatTimestampLocal(timestamp: string): string {
|
|
return formatTimestamp(timestamp, 'en-US', {
|
|
year: 'numeric',
|
|
month: 'numeric',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatReportType(reportType: number): string {
|
|
switch (reportType) {
|
|
case 0:
|
|
return 'Message';
|
|
case 1:
|
|
return 'User';
|
|
case 2:
|
|
return 'Guild';
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
|
|
function buildMessageLookupHref(config: Config, channelId: string, messageId: string | null): string {
|
|
const params = new URLSearchParams();
|
|
params.set('channel_id', channelId);
|
|
params.set('context_limit', '50');
|
|
if (messageId) {
|
|
params.set('message_id', messageId);
|
|
}
|
|
return `${config.basePath}/messages?${params.toString()}`;
|
|
}
|
|
|
|
function formatReportedUserLabel(report: Report): string {
|
|
if (report.reported_user_tag) {
|
|
return report.reported_user_tag;
|
|
}
|
|
if (report.reported_user_username) {
|
|
const discriminator = report.reported_user_discriminator ?? '0000';
|
|
return `${report.reported_user_username}#${discriminator}`;
|
|
}
|
|
return `User ${report.reported_user_id ?? 'unknown'}`;
|
|
}
|
|
|
|
const InfoRow: FC<{label: string; value: string; mono?: boolean}> = ({label, value, mono}) => (
|
|
<VStack gap={1}>
|
|
<Caption>{label}</Caption>
|
|
<Text size="sm" class={mono ? 'font-mono' : ''}>
|
|
{value}
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
|
|
const InfoRowWithLink: FC<{
|
|
label: string;
|
|
value: string;
|
|
href: string;
|
|
mono?: boolean;
|
|
}> = ({label, value, href, mono}) => (
|
|
<VStack gap={1}>
|
|
<Caption>{label}</Caption>
|
|
<TextLink href={href} mono={mono} class="body-sm">
|
|
{value}
|
|
</TextLink>
|
|
</VStack>
|
|
);
|
|
|
|
const InfoRowOpt: FC<{label: string; value: string | null; mono?: boolean}> = ({label, value, mono}) => (
|
|
<VStack gap={1}>
|
|
<Caption>{label}</Caption>
|
|
<Text size="sm" class={mono ? 'font-mono' : ''}>
|
|
{value ?? '\u2014'}
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
|
|
const InfoRowOptWithLink: FC<{
|
|
config: Config;
|
|
label: string;
|
|
id: string | null;
|
|
name: string | null;
|
|
pathFn: (id: string) => string;
|
|
mono?: boolean;
|
|
}> = ({config, label, id, name, pathFn, mono}) => (
|
|
<VStack gap={1}>
|
|
<Caption>{label}</Caption>
|
|
{id ? (
|
|
<TextLink href={`${config.basePath}${pathFn(id)}`} mono={mono} class="body-sm">
|
|
{name ?? id}
|
|
</TextLink>
|
|
) : (
|
|
<Text size="sm" color="muted" class="italic">
|
|
{'\u2014'}
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
);
|
|
|
|
const BasicInfo: FC<{config: Config; report: Report}> = ({config, report}) => {
|
|
const reporterPrimary = report.reporter_tag ?? report.reporter_email ?? 'Anonymous';
|
|
|
|
return (
|
|
<div class="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 class="title-sm mb-4 text-neutral-900">Basic Information</h2>
|
|
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<InfoRow label="Report ID" value={report.report_id} mono />
|
|
<InfoRow label="Reported At" value={formatTimestampLocal(report.reported_at)} />
|
|
<InfoRow label="Type" value={formatReportType(report.report_type)} />
|
|
<InfoRow label="Category" value={report.category ?? ''} />
|
|
{report.reporter_id ? (
|
|
<InfoRowWithLink
|
|
label="Reporter"
|
|
value={reporterPrimary}
|
|
href={`${config.basePath}/users/${report.reporter_id}`}
|
|
mono
|
|
/>
|
|
) : (
|
|
<InfoRow label="Reporter" value={reporterPrimary} />
|
|
)}
|
|
<InfoRowOpt label="Reporter Email" value={report.reporter_email} />
|
|
<InfoRowOpt label="Full Legal Name" value={report.reporter_full_legal_name} />
|
|
<InfoRowOpt label="Country of Residence" value={report.reporter_country_of_residence} />
|
|
<InfoRow
|
|
label="Status"
|
|
value={report.status === 0 ? 'Pending' : report.status === 1 ? 'Resolved' : 'Unknown'}
|
|
/>
|
|
</dl>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ReportedEntity: FC<{config: Config; report: Report}> = ({config, report}) => {
|
|
const renderMessageReportEntity = () => (
|
|
<>
|
|
<InfoRowOptWithLink
|
|
config={config}
|
|
label="User"
|
|
id={report.reported_user_id}
|
|
name={formatReportedUserLabel(report)}
|
|
pathFn={(id) => `/users/${id}`}
|
|
mono
|
|
/>
|
|
{report.reported_message_id && report.reported_channel_id ? (
|
|
<InfoRowWithLink
|
|
label="Message ID"
|
|
value={report.reported_message_id}
|
|
href={buildMessageLookupHref(config, report.reported_channel_id, report.reported_message_id)}
|
|
mono
|
|
/>
|
|
) : (
|
|
<InfoRowOpt label="Message ID" value={report.reported_message_id} mono />
|
|
)}
|
|
{report.reported_channel_id ? (
|
|
<InfoRowWithLink
|
|
label="Channel ID"
|
|
value={report.reported_channel_id}
|
|
href={buildMessageLookupHref(config, report.reported_channel_id, report.reported_message_id)}
|
|
mono
|
|
/>
|
|
) : (
|
|
<InfoRowOpt label="Channel ID" value={report.reported_channel_id} mono />
|
|
)}
|
|
<InfoRowOpt label="Channel Name" value={report.reported_channel_name} />
|
|
<InfoRowOptWithLink
|
|
config={config}
|
|
label="Guild ID"
|
|
id={report.reported_guild_id}
|
|
name={report.reported_guild_id}
|
|
pathFn={(id) => `/guilds/${id}`}
|
|
mono
|
|
/>
|
|
<InfoRowOpt label="Guild Invite Code" value={report.reported_guild_invite_code} />
|
|
</>
|
|
);
|
|
|
|
const renderUserReportEntity = () => (
|
|
<>
|
|
<InfoRowOptWithLink
|
|
config={config}
|
|
label="User"
|
|
id={report.reported_user_id}
|
|
name={formatReportedUserLabel(report)}
|
|
pathFn={(id) => `/users/${id}`}
|
|
mono
|
|
/>
|
|
<InfoRowOpt label="Guild Name" value={report.reported_guild_name} />
|
|
<InfoRowOptWithLink
|
|
config={config}
|
|
label="Guild ID"
|
|
id={report.reported_guild_id}
|
|
name={report.reported_guild_id}
|
|
pathFn={(id) => `/guilds/${id}`}
|
|
mono
|
|
/>
|
|
<InfoRowOpt label="Guild Invite Code" value={report.reported_guild_invite_code} />
|
|
</>
|
|
);
|
|
|
|
const renderGuildReportEntity = () => (
|
|
<>
|
|
<InfoRowOptWithLink
|
|
config={config}
|
|
label="Guild"
|
|
id={report.reported_guild_id}
|
|
name={report.reported_guild_name}
|
|
pathFn={(id) => `/guilds/${id}`}
|
|
mono
|
|
/>
|
|
<InfoRowOpt label="Guild Invite Code" value={report.reported_guild_invite_code} />
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div class="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 class="title-sm mb-4 text-neutral-900">Reported Entity</h2>
|
|
<dl class="grid grid-cols-1 gap-4">
|
|
{report.report_type === 0 && renderMessageReportEntity()}
|
|
{report.report_type === 1 && renderUserReportEntity()}
|
|
{report.report_type === 2 && renderGuildReportEntity()}
|
|
</dl>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MessageContextList: FC<{config: Config; messages: Array<MessageContext>}> = ({config, messages}) => {
|
|
if (messages.length === 0) return null;
|
|
|
|
const mappedMessages = messages.map((msg) => ({
|
|
id: msg.id,
|
|
content: msg.content || '',
|
|
timestamp: formatTimestampLocal(msg.timestamp),
|
|
author_id: msg.author_id,
|
|
author_username: msg.author_username,
|
|
author_discriminator: msg.author_discriminator,
|
|
channel_id: msg.channel_id,
|
|
guild_id: msg.guild_id,
|
|
attachments: msg.attachments,
|
|
}));
|
|
|
|
return (
|
|
<div class="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 class="title-sm mb-4 text-neutral-900">Message Context</h2>
|
|
<MessageList basePath={config.basePath} messages={mappedMessages} includeDeleteButton />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AdditionalInfo: FC<{info: string}> = ({info}) => (
|
|
<div class="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 class="title-sm mb-4 text-neutral-900">Additional Information</h2>
|
|
<p class="whitespace-pre-wrap text-neutral-700">{info}</p>
|
|
</div>
|
|
);
|
|
|
|
const StatusCard: FC<{config: Config; report: Report}> = ({config, report}) => (
|
|
<div class="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 class="title-sm mb-4 text-neutral-900">Status</h2>
|
|
<div class="space-y-3">
|
|
<div class="text-center">
|
|
{report.status === 0 && (
|
|
<span class="subtitle rounded-lg bg-neutral-100 px-4 py-2 text-neutral-700">Pending</span>
|
|
)}
|
|
{report.status === 1 && <span class="subtitle rounded-lg bg-green-100 px-4 py-2 text-green-700">Resolved</span>}
|
|
{report.status !== 0 && report.status !== 1 && (
|
|
<span class="subtitle rounded-lg bg-neutral-100 px-4 py-2 text-neutral-700">Unknown</span>
|
|
)}
|
|
</div>
|
|
{report.resolved_at && (
|
|
<div class="body-sm text-neutral-600">
|
|
<span class="label">Resolved At: </span>
|
|
{formatTimestampLocal(report.resolved_at)}
|
|
</div>
|
|
)}
|
|
{report.resolved_by_admin_id && (
|
|
<div class="body-sm text-neutral-600">
|
|
<span class="label">Resolved By: </span>
|
|
<ResourceLink config={config} resourceType="user" resourceId={report.resolved_by_admin_id}>
|
|
{report.resolved_by_admin_id}
|
|
</ResourceLink>
|
|
</div>
|
|
)}
|
|
{report.public_comment && (
|
|
<div class="border-neutral-200 border-t pt-3">
|
|
<p class="body-sm mb-2 text-neutral-700">Public Comment:</p>
|
|
<p class="body-sm whitespace-pre-wrap text-neutral-600">{report.public_comment}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const ActionsCard: FC<{config: Config; report: Report; csrfToken: string}> = ({config, report, csrfToken}) => {
|
|
const renderResolveButton = () => {
|
|
if (report.status !== 0) return null;
|
|
|
|
return (
|
|
<form
|
|
method="post"
|
|
action={`${config.basePath}/reports/${report.report_id}/resolve`}
|
|
onsubmit="return confirm('Resolve this report?')"
|
|
>
|
|
<CsrfInput token={csrfToken} />
|
|
<input type="hidden" name="public_comment" value="" />
|
|
<Button type="submit" variant="primary">
|
|
Resolve Report
|
|
</Button>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const renderViewReportedEntityButton = () => {
|
|
if (report.report_type === 0 || report.report_type === 1) {
|
|
if (report.reported_user_id) {
|
|
return (
|
|
<NavLink href={`${config.basePath}/users/${report.reported_user_id}`} class="block w-full text-center">
|
|
View Reported User
|
|
</NavLink>
|
|
);
|
|
}
|
|
}
|
|
if (report.report_type === 2) {
|
|
if (report.reported_guild_id) {
|
|
return (
|
|
<NavLink href={`${config.basePath}/guilds/${report.reported_guild_id}`} class="block w-full text-center">
|
|
View Reported Guild
|
|
</NavLink>
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderViewReporterButton = () => {
|
|
if (!report.reporter_id) return null;
|
|
|
|
return (
|
|
<NavLink href={`${config.basePath}/users/${report.reporter_id}`} class="block w-full text-center">
|
|
View Reporter
|
|
</NavLink>
|
|
);
|
|
};
|
|
|
|
const renderViewMutualDmButton = () => {
|
|
if (report.report_type !== 1) {
|
|
return null;
|
|
}
|
|
if (!report.mutual_dm_channel_id) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<NavLink
|
|
href={buildMessageLookupHref(config, report.mutual_dm_channel_id, null)}
|
|
class="block w-full text-center"
|
|
>
|
|
View Mutual DM Channel
|
|
</NavLink>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div class="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 class="title-sm mb-4 text-neutral-900">Actions</h2>
|
|
<div class="space-y-3">
|
|
{renderResolveButton()}
|
|
{renderViewReportedEntityButton()}
|
|
{renderViewReporterButton()}
|
|
{renderViewMutualDmButton()}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export function ReportDetailPage({
|
|
config,
|
|
session,
|
|
currentAdmin,
|
|
flash,
|
|
assetVersion,
|
|
report,
|
|
csrfToken,
|
|
}: ReportDetailPageProps) {
|
|
const messageContext = report.message_context ?? [];
|
|
const hasMessageContext = report.report_type === 0 && messageContext.length > 0;
|
|
|
|
return (
|
|
<Layout
|
|
csrfToken={csrfToken}
|
|
title="Report Details"
|
|
activePage="reports"
|
|
config={config}
|
|
session={session}
|
|
currentAdmin={currentAdmin}
|
|
flash={flash}
|
|
assetVersion={assetVersion}
|
|
extraScripts={hasMessageContext ? createMessageDeletionScriptBody(csrfToken) : undefined}
|
|
>
|
|
<div class="mx-auto max-w-5xl">
|
|
<div class="mb-6">
|
|
<FlexRow gap="4">
|
|
<NavLink href={`${config.basePath}/reports`}>{'\u2190'} Back to Reports</NavLink>
|
|
<Heading level={1}>Report Details</Heading>
|
|
</FlexRow>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
<div class="space-y-6 lg:col-span-2">
|
|
<BasicInfo config={config} report={report} />
|
|
<ReportedEntity config={config} report={report} />
|
|
{hasMessageContext && <MessageContextList config={config} messages={messageContext} />}
|
|
{report.additional_info && <AdditionalInfo info={report.additional_info} />}
|
|
</div>
|
|
<div class="space-y-6">
|
|
<StatusCard config={config} report={report} />
|
|
<ActionsCard config={config} report={report} csrfToken={csrfToken} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
export function ReportDetailFragment({config, report}: {config: Config; report: Report}) {
|
|
const messageContext = report.message_context ?? [];
|
|
const hasMessageContext = report.report_type === 0 && messageContext.length > 0;
|
|
|
|
return (
|
|
<div data-report-fragment="" class="space-y-4">
|
|
<BasicInfo config={config} report={report} />
|
|
<ReportedEntity config={config} report={report} />
|
|
{hasMessageContext && <MessageContextList config={config} messages={messageContext} />}
|
|
{report.additional_info && <AdditionalInfo info={report.additional_info} />}
|
|
</div>
|
|
);
|
|
}
|