feat: report page, take action on reports
This commit is contained in:
parent
a0ba712632
commit
cacd3a30b7
14 changed files with 502 additions and 14 deletions
90
Foxnouns.Frontend/src/lib/actions/modaction.ts
Normal file
90
Foxnouns.Frontend/src/lib/actions/modaction.ts
Normal 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),
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
73
Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte
Normal file
73
Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue