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