feat: report page, take action on reports

This commit is contained in:
sam 2025-02-03 17:03:32 +01:00
parent a0ba712632
commit cacd3a30b7
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
14 changed files with 502 additions and 14 deletions

View file

@ -0,0 +1,90 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuditLogEntry, ClearableField } from "$api/models/moderation";
import log from "$lib/log";
import { type RequestEvent } from "@sveltejs/kit";
type ModactionResponse = { ok: boolean; resp: AuditLogEntry | null; error: RawApiError | null };
type ModactionFunction = (evt: RequestEvent) => Promise<ModactionResponse>;
export default function createModactionAction(
type: "ignore" | "warn" | "suspend",
requireReason: boolean,
): ModactionFunction {
return async function ({ request, fetch, cookies }) {
const body = await request.formData();
const userId = body.get("user") as string;
const memberId = body.get("member") as string | null;
const reportId = body.get("report") as string | null;
const reason = body.get("reason") as string | null;
if (!reportId && type === "ignore") {
return {
ok: false,
resp: null,
error: {
status: 400,
message: "Bad request",
code: ErrorCode.BadRequest,
errors: [
{ key: "report", errors: [{ message: "Ignoring a report requires a report ID" }] },
],
} satisfies RawApiError,
};
}
if (!reason && requireReason) {
return {
ok: false,
resp: null,
error: {
status: 400,
message: "Bad request",
code: ErrorCode.BadRequest,
errors: [{ key: "reason", errors: [{ message: "You must give a reason" }] }],
} satisfies RawApiError,
};
}
let clearFields: ClearableField[] | undefined = undefined;
if (type === "warn") {
clearFields = body.getAll("clear-fields") as ClearableField[];
}
let path: string;
if (type === "warn") path = `/moderation/warnings/${userId}`;
else if (type === "suspend") path = `/moderation/suspensions/${userId}`;
else path = `/moderation/reports/${reportId}/ignore`;
try {
const resp = await apiRequest<AuditLogEntry>("POST", path, {
fetch,
cookies,
body: {
reason: reason,
// These are ignored by POST /reports/{id}/ignore
member_id: memberId,
report_id: reportId,
// This is ignored by everything but POST /warnings/{id}
clear_fields: clearFields,
// This is ignored by everything but POST /suspensions/{id}
clear_profile: !!body.get("clear-profile"),
},
});
return { ok: true, resp, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj, resp: null };
log.error("could not take action on %s:", path, e);
throw e;
}
};
}
export function createModactions() {
return {
ignore: createModactionAction("ignore", false),
warn: createModactionAction("warn", true),
suspend: createModactionAction("suspend", true),
};
}

View file

@ -81,6 +81,7 @@ export async function apiRequest<TResponse, TRequest = unknown>(
if (resp.status < 200 || resp.status > 299) {
const err = await resp.json();
log.error("Received error for request to %s %s:", method, path, err);
if ("code" in err) throw new ApiError(err);
else throw new ApiError();
}

View file

@ -71,6 +71,12 @@ export type PartialReport = {
target_type: "USER" | "MEMBER";
};
export type ReportDetails = {
report: Report;
user: User;
member?: Member;
};
export type QueriedUser = {
user: User;
member_list_hidden: boolean;
@ -80,3 +86,28 @@ export type QueriedUser = {
deleted: boolean;
auth_methods?: AuthMethod[];
};
export type WarnUserRequest = {
reason: string;
clear_fields?: ClearableField[];
member_id?: string;
report_id?: string;
};
export type SuspendUserRequest = {
reason: string;
clear_profile: boolean;
report_id?: string;
};
export enum ClearableField {
DisplayName = "DISPLAY_NAME",
Avatar = "AVATAR",
Bio = "BIO",
Links = "LINKS",
Names = "NAMES",
Pronouns = "PRONOUNS",
Fields = "FIELDS",
Flags = "FLAGS",
CustomPreferences = "CUSTOM_PREFERENCES",
}

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { ClearableField } from "$api/models/moderation";
import FormStatusMarker, { type FormError } from "$components/editor/FormStatusMarker.svelte";
import { TabContent, TabPane } from "@sveltestrap/sveltestrap";
let {
userId,
reportId,
memberId,
form,
}: { userId: string; reportId?: string; memberId?: string; form: FormError } = $props();
let fields = $derived.by(() => {
const fields = [];
for (const value of Object.values(ClearableField)) {
fields.push({ value });
}
return fields;
});
</script>
<form method="POST">
<input type="hidden" name="user" value={userId} />
{#if memberId}
<input type="hidden" name="member" value={memberId} />
{/if}
{#if reportId}
<input type="hidden" name="report" value={reportId} />
{/if}
<FormStatusMarker {form} />
<textarea name="reason" class="form-control" style="height: 200px;"></textarea>
<TabContent>
{#if reportId}
<TabPane tabId="ignore" tab="Ignore">
<button type="submit" formaction="?/ignore" class="btn btn-secondary">Ignore report</button>
</TabPane>
{/if}
<TabPane tabId="warn" tab="Warn" active>
<div class="row row-cols-1 row-cols-lg-2">
{#each fields as field}
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="clear-fields"
value={field.value}
id="reason-{field.value}"
/>
<label class="form-check-label" for="reason-{field.value}">
<code>{field.value}</code>
</label>
</div>
{/each}
</div>
<div>
<button type="submit" formaction="?/warn" class="btn btn-danger">Warn user</button>
</div>
</TabPane>
<TabPane tabId="suspend" tab="Suspend">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="yes"
name="clear-profile"
id="clear-profile"
/>
<label class="form-check-label" for="clear-profile">Clear the user's profile?</label>
</div>
<button type="submit" formaction="?/suspend" class="btn btn-danger">Suspend user</button>
</TabPane>
</TabContent>
</form>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import type { PartialUser } from "$api/models";
import Avatar from "$components/Avatar.svelte";
import { idTimestamp } from "$lib";
import { t } from "$lib/i18n";
import { DateTime } from "luxon";
type Props = { user: PartialUser };
let { user }: Props = $props();
let createdAt = $derived(idTimestamp(user.id).toLocaleString(DateTime.DATETIME_SHORT));
</script>
<div class="text-center">
<a href="/@{user.username}">
<Avatar
name={user.username}
url={user.avatar_url}
lazyLoad
alt={$t("avatar-tooltip", { name: "@" + user.username })}
/>
</a>
<p class="m-2">
<a class="text-reset fs-5 text-break" href="/@{user.username}">
@{user.username}
</a>
</p>
<p>Created {createdAt}</p>
</div>

View file

@ -1,10 +1,14 @@
<script module lang="ts">
export type FormError = { error: RawApiError | null; ok: boolean } | null;
</script>
<script lang="ts">
import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n";
import type { RawApiError } from "$api/error";
import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string };
type Props = { form: FormError | null; successMessage?: string };
let { form, successMessage }: Props = $props();
</script>