diff options
Diffstat (limited to 'web/source/settings')
31 files changed, 1999 insertions, 206 deletions
diff --git a/web/source/settings/components/error.tsx b/web/source/settings/components/error.tsx index 977cf06c8..3ca5eb416 100644 --- a/web/source/settings/components/error.tsx +++ b/web/source/settings/components/error.tsx @@ -107,7 +107,11 @@ function Error({ error, reset }: ErrorProps) {  			{ reset &&   				<span   					className="dismiss" -					onClick={reset} +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						reset(); +					}}  					role="button"  					tabIndex={0}  				> diff --git a/web/source/settings/components/username.tsx b/web/source/settings/components/username-lozenge.tsx index 56ba67c4f..9f955cf22 100644 --- a/web/source/settings/components/username.tsx +++ b/web/source/settings/components/username-lozenge.tsx @@ -17,18 +17,107 @@  	along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -import React from "react"; +import React, { useEffect } from "react";  import { useLocation } from "wouter";  import { AdminAccount } from "../lib/types/account"; +import { useLazyGetAccountQuery } from "../lib/query/admin"; +import Loading from "./loading"; +import { Error as ErrorC } from "./error"; -interface UsernameProps { +interface UsernameLozengeProps { +	/** +	 * Either an account ID (for fetching) or an account. +	 */ +	account?: string | AdminAccount; +	/** +	 * Make the lozenge clickable and link to this location. +	 */ +	linkTo?: string; +	/** +	 * Location to set as backLocation after linking to linkTo. +	 */ +	backLocation?: string; +	/** +	 * Additional classnames to add to the lozenge. +	 */ +	classNames?: string[]; +} + +export default function UsernameLozenge({ account, linkTo, backLocation, classNames }: UsernameLozengeProps) { +	if (account === undefined) { +		return <>[unknown]</>; +	} else if (typeof account === "string") { +		return ( +			<FetchUsernameLozenge +				accountID={account} +				linkTo={linkTo} +				backLocation={backLocation} +				classNames={classNames} +			/> +		); +	} else { +		return ( +			<ReadyUsernameLozenge +				account={account} +				linkTo={linkTo} +				backLocation={backLocation} +				classNames={classNames} +			/> +		); +	} + +} + +interface FetchUsernameLozengeProps { +	accountID: string; +	linkTo?: string; +	backLocation?: string; +	classNames?: string[]; +} + +function FetchUsernameLozenge({ accountID, linkTo, backLocation, classNames }: FetchUsernameLozengeProps) { +	const [ trigger, result ] = useLazyGetAccountQuery(); +	 +	// Call to get the account +	// using the provided ID. +	useEffect(() => { +		trigger(accountID, true); +	}, [trigger, accountID]); + +	const { +		data: account, +		isLoading, +		isFetching, +		isError, +		error, +	} = result; + +	// Wait for the account +	// model to be returned. +	if (isError) { +		return <ErrorC error={error} />; +	} else if (isLoading || isFetching || account === undefined) { +		return <Loading />; +	} + +	return ( +		<ReadyUsernameLozenge +			account={account} +			linkTo={linkTo} +			backLocation={backLocation} +			classNames={classNames} +		/> +	); +} + +interface ReadyUsernameLozengeProps {  	account: AdminAccount;  	linkTo?: string;  	backLocation?: string;  	classNames?: string[];  } -export default function Username({ account, linkTo, backLocation, classNames }: UsernameProps) { +function ReadyUsernameLozenge({ account, linkTo, backLocation, classNames }: ReadyUsernameLozengeProps) {  	const [ _location, setLocation ] = useLocation();  	let className = "username-lozenge"; diff --git a/web/source/settings/lib/navigation/menu.tsx b/web/source/settings/lib/navigation/menu.tsx index 514e3ea2f..2bd07a055 100644 --- a/web/source/settings/lib/navigation/menu.tsx +++ b/web/source/settings/lib/navigation/menu.tsx @@ -110,12 +110,19 @@ export function MenuItem(props: PropsWithChildren<MenuItemProps>) {  	if (topLevel) {  		classNames.push("category", "top-level");  	} else { -		if (thisLevel === 1 && hasChildren) { -			classNames.push("category", "expanding"); -		} else if (thisLevel === 1 && !hasChildren) { -			classNames.push("view", "expanding"); -		} else if (thisLevel === 2) { -			classNames.push("view", "nested"); +		switch (true) { +			case thisLevel === 1 && hasChildren: +				classNames.push("category", "expanding"); +				break; +			case thisLevel === 1 && !hasChildren: +				classNames.push("view", "expanding"); +				break; +			case thisLevel >= 2 && hasChildren: +				classNames.push("nested", "category"); +				break; +			case thisLevel >= 2 && !hasChildren: +				classNames.push("nested", "view"); +				break;  		}  	} diff --git a/web/source/settings/lib/query/admin/domain-permissions/drafts.ts b/web/source/settings/lib/query/admin/domain-permissions/drafts.ts new file mode 100644 index 000000000..1a85f9dde --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/drafts.ts @@ -0,0 +1,173 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import { gtsApi } from "../../gts-api"; + +import type { +	DomainPerm, +	DomainPermDraftCreateParams, +	DomainPermDraftSearchParams, +	DomainPermDraftSearchResp, +} from "../../../types/domain-permission"; +import parse from "parse-link-header"; +import { PermType } from "../../../types/perm"; + +const extended = gtsApi.injectEndpoints({ +	endpoints: (build) => ({ +		searchDomainPermissionDrafts: build.query<DomainPermDraftSearchResp, DomainPermDraftSearchParams>({ +			query: (form) => { +				const params = new(URLSearchParams); +				Object.entries(form).forEach(([k, v]) => { +					if (v !== undefined) { +						params.append(k, v); +					} +				}); + +				let query = ""; +				if (params.size !== 0) { +					query = `?${params.toString()}`; +				} + +				return { +					url: `/api/v1/admin/domain_permission_drafts${query}` +				}; +			}, +			// Headers required for paging. +			transformResponse: (apiResp: DomainPerm[], meta) => { +				const drafts = apiResp; +				const linksStr = meta?.response?.headers.get("Link"); +				const links = parse(linksStr); +				return { drafts, links }; +			}, +			// Only provide TRANSFORMED tag id since this model is not the same +			// as getDomainPermissionDraft model (due to transformResponse). +			providesTags: [{ type: "DomainPermissionDraft", id: "TRANSFORMED" }] +		}), + +		getDomainPermissionDraft: build.query<DomainPerm, string>({ +			query: (id) => ({ +				url: `/api/v1/admin/domain_permission_drafts/${id}` +			}), +			providesTags: (_result, _error, id) => [ +				{ type: 'DomainPermissionDraft', id } +			], +		}), + +		createDomainPermissionDraft: build.mutation<DomainPerm, DomainPermDraftCreateParams>({ +			query: (formData) => ({ +				method: "POST", +				url: `/api/v1/admin/domain_permission_drafts`, +				asForm: true, +				body: formData, +				discardEmpty: true +			}), +			invalidatesTags: [{ type: "DomainPermissionDraft", id: "TRANSFORMED" }], +		}), + +		acceptDomainPermissionDraft: build.mutation<DomainPerm, { id: string, overwrite?: boolean, permType: PermType }>({ +			query: ({ id, overwrite }) => ({ +				method: "POST", +				url: `/api/v1/admin/domain_permission_drafts/${id}/accept`, +				asForm: true, +				body: { +					overwrite: overwrite, +				}, +				discardEmpty: true +			}), +			invalidatesTags: (res, _error, { id, permType }) => { +				const invalidated: any[] = []; +				 +				// If error, nothing to invalidate. +				if (!res) { +					return invalidated; +				} +				 +				// Invalidate this draft by ID, and +				// the transformed list of all drafts. +				invalidated.push( +					{ type: 'DomainPermissionDraft', id: id }, +					{ type: "DomainPermissionDraft", id: "TRANSFORMED" }, +				); + +				// Invalidate cached blocks/allows depending +				// on the permType of the accepted draft. +				if (permType === "allow") { +					invalidated.push("domainAllows"); +				} else { +					invalidated.push("domainBlocks"); +				} + +				return invalidated; +			} +		}), + +		removeDomainPermissionDraft: build.mutation<DomainPerm, { id: string, exclude_target?: boolean }>({ +			query: ({ id, exclude_target }) => ({ +				method: "POST", +				url: `/api/v1/admin/domain_permission_drafts/${id}/remove`, +				asForm: true, +				body: { +					exclude_target: exclude_target, +				}, +				discardEmpty: true +			}), +			invalidatesTags: (res, _error, { id }) => +				res +					? [ +						{ type: "DomainPermissionDraft", id }, +						{ type: "DomainPermissionDraft", id: "TRANSFORMED" }, +					] +					: [], +		}) + +	}), +}); + +/** + * View domain permission drafts. + */ +const useLazySearchDomainPermissionDraftsQuery = extended.useLazySearchDomainPermissionDraftsQuery; + +/** + * Get domain permission draft with the given ID. + */ +const useGetDomainPermissionDraftQuery = extended.useGetDomainPermissionDraftQuery; + +/** + * Create a domain permission draft with the given parameters. + */ +const useCreateDomainPermissionDraftMutation = extended.useCreateDomainPermissionDraftMutation; + +/** + * Accept a domain permission draft, turning it into an enforced domain permission. + */ +const useAcceptDomainPermissionDraftMutation = extended.useAcceptDomainPermissionDraftMutation; + +/** + * Remove a domain permission draft, optionally ignoring all future drafts targeting the given domain. + */ +const useRemoveDomainPermissionDraftMutation = extended.useRemoveDomainPermissionDraftMutation; + +export { +	useLazySearchDomainPermissionDraftsQuery, +	useGetDomainPermissionDraftQuery, +	useCreateDomainPermissionDraftMutation, +	useAcceptDomainPermissionDraftMutation, +	useRemoveDomainPermissionDraftMutation, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/excludes.ts b/web/source/settings/lib/query/admin/domain-permissions/excludes.ts new file mode 100644 index 000000000..6b8f16cad --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/excludes.ts @@ -0,0 +1,124 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import { gtsApi } from "../../gts-api"; + +import type { +	DomainPerm, +	DomainPermExcludeCreateParams, +	DomainPermExcludeSearchParams, +	DomainPermExcludeSearchResp, +} from "../../../types/domain-permission"; +import parse from "parse-link-header"; + +const extended = gtsApi.injectEndpoints({ +	endpoints: (build) => ({ +		searchDomainPermissionExcludes: build.query<DomainPermExcludeSearchResp, DomainPermExcludeSearchParams>({ +			query: (form) => { +				const params = new(URLSearchParams); +				Object.entries(form).forEach(([k, v]) => { +					if (v !== undefined) { +						params.append(k, v); +					} +				}); + +				let query = ""; +				if (params.size !== 0) { +					query = `?${params.toString()}`; +				} + +				return { +					url: `/api/v1/admin/domain_permission_excludes${query}` +				}; +			}, +			// Headers required for paging. +			transformResponse: (apiResp: DomainPerm[], meta) => { +				const excludes = apiResp; +				const linksStr = meta?.response?.headers.get("Link"); +				const links = parse(linksStr); +				return { excludes, links }; +			}, +			// Only provide TRANSFORMED tag id since this model is not the same +			// as getDomainPermissionExclude model (due to transformResponse). +			providesTags: [{ type: "DomainPermissionExclude", id: "TRANSFORMED" }] +		}), + +		getDomainPermissionExclude: build.query<DomainPerm, string>({ +			query: (id) => ({ +				url: `/api/v1/admin/domain_permission_excludes/${id}` +			}), +			providesTags: (_result, _error, id) => [ +				{ type: 'DomainPermissionExclude', id } +			], +		}), + +		createDomainPermissionExclude: build.mutation<DomainPerm, DomainPermExcludeCreateParams>({ +			query: (formData) => ({ +				method: "POST", +				url: `/api/v1/admin/domain_permission_excludes`, +				asForm: true, +				body: formData, +				discardEmpty: true +			}), +			invalidatesTags: [{ type: "DomainPermissionExclude", id: "TRANSFORMED" }], +		}), + +		deleteDomainPermissionExclude: build.mutation<DomainPerm, string>({ +			query: (id) => ({ +				method: "DELETE", +				url: `/api/v1/admin/domain_permission_excludes/${id}`, +			}), +			invalidatesTags: (res, _error, id) => +				res +					? [ +						{ type: "DomainPermissionExclude", id }, +						{ type: "DomainPermissionExclude", id: "TRANSFORMED" }, +					] +					: [], +		}) + +	}), +}); + +/** + * View domain permission excludes. + */ +const useLazySearchDomainPermissionExcludesQuery = extended.useLazySearchDomainPermissionExcludesQuery; + +/** + * Get domain permission exclude with the given ID. + */ +const useGetDomainPermissionExcludeQuery = extended.useGetDomainPermissionExcludeQuery; + +/** + * Create a domain permission exclude with the given parameters. + */ +const useCreateDomainPermissionExcludeMutation = extended.useCreateDomainPermissionExcludeMutation; + +/** + * Delete a domain permission exclude. + */ +const useDeleteDomainPermissionExcludeMutation = extended.useDeleteDomainPermissionExcludeMutation; + +export { +	useLazySearchDomainPermissionExcludesQuery, +	useGetDomainPermissionExcludeQuery, +	useCreateDomainPermissionExcludeMutation, +	useDeleteDomainPermissionExcludeMutation, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/get.ts b/web/source/settings/lib/query/admin/domain-permissions/get.ts index 3e27742d4..ae7ac7960 100644 --- a/web/source/settings/lib/query/admin/domain-permissions/get.ts +++ b/web/source/settings/lib/query/admin/domain-permissions/get.ts @@ -37,6 +37,12 @@ const extended = gtsApi.injectEndpoints({  			}),  			transformResponse: listToKeyedObject<DomainPerm>("domain"),  		}), + +		domainPermissionDrafts: build.query<any, void>({ +			query: () => ({ +				url: `/api/v1/admin/domain_permission_drafts` +			}), +		}),  	}),  }); diff --git a/web/source/settings/lib/query/admin/domain-permissions/import.ts b/web/source/settings/lib/query/admin/domain-permissions/import.ts index dde488625..cbcf44964 100644 --- a/web/source/settings/lib/query/admin/domain-permissions/import.ts +++ b/web/source/settings/lib/query/admin/domain-permissions/import.ts @@ -24,7 +24,7 @@ import {  	type DomainPerm,  	type ImportDomainPermsParams,  	type MappedDomainPerms, -	isDomainPermInternalKey, +	stripOnImport,  } from "../../../types/domain-permission";  import { listToKeyedObject } from "../../transforms"; @@ -83,7 +83,7 @@ function importEntriesProcessor(formData: ImportDomainPermsParams): (_entry: Dom  		// Unset all internal processing keys  		// and any undefined keys on this entry.  		Object.entries(entry).forEach(([key, val]: [keyof DomainPerm, any]) => {			 -			if (val == undefined || isDomainPermInternalKey(key)) { +			if (val == undefined || stripOnImport(key)) {  				delete entry[key];  			}  		}); diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 911ea58c7..9543819a9 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -169,6 +169,8 @@ export const gtsApi = createApi({  		"HTTPHeaderBlocks",  		"DefaultInteractionPolicies",  		"InteractionRequest", +		"DomainPermissionDraft", +		"DomainPermissionExclude"  	],  	endpoints: (build) => ({  		instanceV1: build.query<InstanceV1, void>({ diff --git a/web/source/settings/lib/types/domain-permission.ts b/web/source/settings/lib/types/domain-permission.ts index ccf7c9c57..1a0a9bd0b 100644 --- a/web/source/settings/lib/types/domain-permission.ts +++ b/web/source/settings/lib/types/domain-permission.ts @@ -19,11 +19,12 @@  import typia from "typia";  import { PermType } from "./perm"; +import { Links } from "parse-link-header";  export const validateDomainPerms = typia.createValidate<DomainPerm[]>();  /** - * A single domain permission entry (block or allow). + * A single domain permission entry (block, allow, draft, ignore).   */  export interface DomainPerm {  	id?: string; @@ -32,11 +33,14 @@ export interface DomainPerm {  	private_comment?: string;  	public_comment?: string;  	created_at?: string; +	created_by?: string; +	subscription_id?: string; -	// Internal processing keys; remove -	// before serdes of domain perm. +	// Keys that should be stripped before +	// sending the domain permission (if imported). + +	permission_type?: PermType;  	key?: string; -	permType?: PermType;  	suggest?: string;  	valid?: boolean;  	checked?: boolean; @@ -53,9 +57,9 @@ export interface MappedDomainPerms {  	[key: string]: DomainPerm;  } -const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([ +const domainPermStripOnImport: Set<keyof DomainPerm> = new Set([  	"key", -	"permType", +	"permission_type",  	"suggest",  	"valid",  	"checked", @@ -65,15 +69,14 @@ const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([  ]);  /** - * Returns true if provided DomainPerm Object key is - * "internal"; ie., it's just for our use, and it shouldn't - * be serialized to or deserialized from the GtS API. + * Returns true if provided DomainPerm Object key is one + * that should be stripped when importing a domain permission.   *    * @param key    * @returns    */ -export function isDomainPermInternalKey(key: keyof DomainPerm) { -	return domainPermInternalKeys.has(key); +export function stripOnImport(key: keyof DomainPerm) { +	return domainPermStripOnImport.has(key);  }  export interface ImportDomainPermsParams { @@ -94,3 +97,119 @@ export interface ExportDomainPermsParams {  	action: "export" | "export-file";  	exportType: "json" | "csv" | "plain";  } + +/** + * Parameters for GET to /api/v1/admin/domain_permission_drafts. + */ +export interface DomainPermDraftSearchParams { +	/** +	 * Show only drafts created by the given subscription ID. +	 */ +	subscription_id?: string; +	/** +	 * Return only drafts that target the given domain. +	 */ +	domain?: string; +	/** +	 * Filter on "block" or "allow" type drafts. +	 */ +	permission_type?: PermType; +	/** +	 * Return only items *OLDER* than the given max ID (for paging downwards). +	 * The item with the specified ID will not be included in the response. +	 */ +	max_id?: string; +	/** +	 * Return only items *NEWER* than the given since ID. +	 * The item with the specified ID will not be included in the response. +	 */ +	since_id?: string; +	/** +	 * Return only items immediately *NEWER* than the given min ID (for paging upwards). +	 * The item with the specified ID will not be included in the response. +	 */ +	min_id?: string; +	/** +	 * Number of items to return. +	 */ +	limit?: number; +} + +export interface DomainPermDraftSearchResp { +	drafts: DomainPerm[]; +	links: Links | null; +} + +export interface DomainPermDraftCreateParams { +	/** +	 * Domain to create the permission draft for. +	 */ +	domain: string; +	/** +	 * Create a draft "allow" or a draft "block". +	 */ +	permission_type: PermType; +	/** +	 * Obfuscate the name of the domain when serving it publicly. +	 * Eg., `example.org` becomes something like `ex***e.org`. +	 */ +	obfuscate?: boolean; +	/** +	 * Public comment about this domain permission. This will be displayed +	 * alongside the domain permission if you choose to share permissions. +	 */ +	public_comment?: string; +	/** +	 * Private comment about this domain permission. +	 * Will only be shown to other admins, so this is a useful way of +	 * internally keeping track of why a certain domain ended up permissioned. +	 */ +	private_comment?: string; +} + +/** + * Parameters for GET to /api/v1/admin/domain_permission_excludes. + */ +export interface DomainPermExcludeSearchParams { +	/** +	 * Return only excludes that target the given domain. +	 */ +	domain?: string; +	/** +	 * Return only items *OLDER* than the given max ID (for paging downwards). +	 * The item with the specified ID will not be included in the response. +	 */ +	max_id?: string; +	/** +	 * Return only items *NEWER* than the given since ID. +	 * The item with the specified ID will not be included in the response. +	 */ +	since_id?: string; +	/** +	 * Return only items immediately *NEWER* than the given min ID (for paging upwards). +	 * The item with the specified ID will not be included in the response. +	 */ +	min_id?: string; +	/** +	 * Number of items to return. +	 */ +	limit?: number; +} + +export interface DomainPermExcludeSearchResp { +	excludes: DomainPerm[]; +	links: Links | null; +} + +export interface DomainPermExcludeCreateParams { +	/** +	 * Domain to create the permission exclude for. +	 */ +	domain: string; +	/** +	 * Private comment about this domain permission. +	 * Will only be shown to other admins, so this is a useful way of +	 * internally keeping track of why a certain domain ended up permissioned. +	 */ +	private_comment?: string; +} diff --git a/web/source/settings/lib/util/formvalidators.ts b/web/source/settings/lib/util/formvalidators.ts new file mode 100644 index 000000000..c509cf59d --- /dev/null +++ b/web/source/settings/lib/util/formvalidators.ts @@ -0,0 +1,48 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import isValidDomain from "is-valid-domain"; + +/** + * Validate the "domain" field of a form. + * @param domain  + * @returns  + */ +export function formDomainValidator(domain: string): string { +	if (domain.length === 0) { +		return ""; +	} + +	if (domain[domain.length-1] === ".") { +		return "invalid domain"; +	} + +	const valid = isValidDomain(domain, { +		subdomain: true, +		wildcard: false, +		allowUnicode: true, +		topLevel: false, +	}); + +	if (valid) { +		return ""; +	} + +	return "invalid domain"; +} diff --git a/web/source/settings/lib/util/index.ts b/web/source/settings/lib/util/index.ts index d016f3398..4c8a90626 100644 --- a/web/source/settings/lib/util/index.ts +++ b/web/source/settings/lib/util/index.ts @@ -41,3 +41,16 @@ export function UseOurInstanceAccount(account: AdminAccount): boolean {  	return !account.domain && account.username == ourDomain;  } + +/** + * Uppercase first letter of given string. + */ +export function useCapitalize(i?: string): string { +	return useMemo(() => { +		if (i === undefined) { +			return ""; +		} +		 +		return i.charAt(0).toUpperCase() + i.slice(1);  +	}, [i]); +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index ecfe5910a..740c30059 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -194,7 +194,8 @@ nav.menu-tree {  		}  	} -	li.nested { /* any deeper nesting, just has indent */ +	/* Deeper nesting. */ +	li.nested {  		a.title {  			padding-left: 1rem;  			font-weight: normal; @@ -210,11 +211,35 @@ nav.menu-tree {  				background: $settings-nav-bg-hover;  			}  		} +		 +		&.active > a.title { +			color: $fg-accent; +			font-weight: bold; +		} -		&.active { -			a.title { -				color: $fg-accent; -				font-weight: bold; +		&.category { +			& > a.title { +				&::after { +					content: "â–¶"; +					left: 0.8rem; +					bottom: 0.1rem; +					position: relative; +				} +			} + +			&.active { +				& > a.title { +					&::after { +						content: "â–¼"; +						bottom: 0; +					} + +					border-bottom: 0.15rem dotted $gray1; +				} +			} + +			li.nested > a.title { +				padding-left: 2rem;  			}  		}  	} @@ -1334,6 +1359,66 @@ button.tab-button {  	}  } +.domain-permission-drafts-view, +.domain-permission-excludes-view { +	.domain-permission-draft, +	.domain-permission-exclude { +		display: flex; +		flex-direction: column; +		flex-wrap: nowrap; +		gap: 0.5rem; +		 +		&.block { +			border-left: 0.3rem solid $error3; +		} + +		&.allow { +			border-left: 0.3rem solid $green1; +		} + +		&:hover { +			border-color: $fg-accent; +		} + +		.info-list { +			border: none; + +			.info-list-entry { +				background: none; +				padding: 0; +			} +		} + +		.action-buttons { +			display: flex; +			gap: 0.5rem; +			align-items: center; + +			> .mutation-button +			> button { +				font-size: 1rem; +				line-height: 1rem; +			} +		} +	} +} + +.domain-permission-draft-details, +.domain-permission-exclude-details { +	.info-list { +		margin-top: 1rem; +	} +} + +.domain-permission-drafts-view, +.domain-permission-draft-details { +	dd.permission-type { +		display: flex; +		gap: 0.35rem; +		align-items: center; +	} +} +  .instance-rules {  	list-style-position: inside;  	margin: 0; diff --git a/web/source/settings/views/admin/actions/keys/expireremote.tsx b/web/source/settings/views/admin/actions/keys/expireremote.tsx index 1d62f9439..082f1fdff 100644 --- a/web/source/settings/views/admin/actions/keys/expireremote.tsx +++ b/web/source/settings/views/admin/actions/keys/expireremote.tsx @@ -22,32 +22,11 @@ import { TextInput } from "../../../../components/form/inputs";  import MutationButton from "../../../../components/form/mutation-button";  import { useTextInput } from "../../../../lib/form";  import { useInstanceKeysExpireMutation } from "../../../../lib/query/admin/actions"; -import isValidDomain from "is-valid-domain"; +import { formDomainValidator } from "../../../../lib/util/formvalidators";  export default function ExpireRemote({}) {  	const domainField = useTextInput("domain", { -		validator: (v: string) => { -			if (v.length === 0) { -				return ""; -			} - -			if (v[v.length-1] === ".") { -				return "invalid domain"; -			} - -			const valid = isValidDomain(v, { -				subdomain: true, -				wildcard: false, -				allowUnicode: true, -				topLevel: false, -			}); - -			if (valid) { -				return ""; -			} - -			return "invalid domain"; -		} +		validator: formDomainValidator,  	});  	const [expire, expireResult] = useInstanceKeysExpireMutation(); diff --git a/web/source/settings/views/admin/http-header-permissions/detail.tsx b/web/source/settings/views/admin/http-header-permissions/detail.tsx index 522f2dba2..e0d49ffd2 100644 --- a/web/source/settings/views/admin/http-header-permissions/detail.tsx +++ b/web/source/settings/views/admin/http-header-permissions/detail.tsx @@ -17,7 +17,7 @@  	along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -import React, { useEffect, useMemo } from "react"; +import React, { useMemo } from "react";  import { useLocation, useParams } from "wouter";  import { PermType } from "../../../lib/types/perm";  import { useDeleteHeaderAllowMutation, useDeleteHeaderBlockMutation, useGetHeaderAllowQuery, useGetHeaderBlockQuery } from "../../../lib/query/admin/http-header-permissions"; @@ -26,8 +26,7 @@ import { FetchBaseQueryError } from "@reduxjs/toolkit/query";  import { SerializedError } from "@reduxjs/toolkit";  import Loading from "../../../components/loading";  import { Error } from "../../../components/error"; -import { useLazyGetAccountQuery } from "../../../lib/query/admin"; -import Username from "../../../components/username"; +import UsernameLozenge from "../../../components/username-lozenge";  import { useBaseUrl } from "../../../lib/navigation/util";  import BackButton from "../../../components/back-button";  import MutationButton from "../../../components/form/mutation-button"; @@ -92,58 +91,19 @@ interface PermDeetsProps {  function PermDeets({  	permType,  	data: perm, -	isLoading: isLoadingPerm, -	isFetching: isFetchingPerm, -	isError: isErrorPerm, -	error: errorPerm, +	isLoading, +	isFetching, +	isError, +	error,  }: PermDeetsProps) {  	const [ location ] = useLocation();  	const baseUrl = useBaseUrl(); -	 -	// Once we've loaded the perm, trigger -	// getting the account that created it. -	const [ getAccount, getAccountRes ] = useLazyGetAccountQuery(); -	useEffect(() => { -		if (!perm) { -			return; -		} -		getAccount(perm.created_by, true); -	}, [getAccount, perm]); - -	// Load the createdByAccount if possible, -	// returning a username lozenge with -	// a link to the account. -	const createdByAccount = useMemo(() => { -		const { -			data: account, -			isLoading: isLoadingAccount, -			isFetching: isFetchingAccount, -			isError: isErrorAccount, -		} = getAccountRes; -		 -		// Wait for query to finish, returning -		// loading spinner in the meantime. -		if (isLoadingAccount || isFetchingAccount || !perm) { -			return <Loading />; -		} else if (isErrorAccount || account === undefined) { -			// Fall back to account ID. -			return perm?.created_by; -		} - -		return ( -			<Username -				account={account} -				linkTo={`~/settings/moderation/accounts/${account.id}`} -				backLocation={`~${baseUrl}${location}`} -			/> -		); -	}, [getAccountRes, perm, baseUrl, location]); - -	// Now wait til the perm itself is loaded. -	if (isLoadingPerm || isFetchingPerm) { + +	// Wait til the perm itself is loaded. +	if (isLoading || isFetching) {  		return <Loading />; -	} else if (isErrorPerm) { -		return <Error error={errorPerm} />; +	} else if (isError) { +		return <Error error={error} />;  	} else if (perm === undefined) {  		throw "perm undefined";  	} @@ -172,7 +132,13 @@ function PermDeets({  				</div>  				<div className="info-list-entry">  					<dt>Created By</dt> -					<dd>{createdByAccount}</dd> +					<dd> +						<UsernameLozenge +							account={perm.created_by} +							linkTo={`~/settings/moderation/accounts/${perm.created_by}`} +							backLocation={`~${baseUrl}${location}`} +						/> +					</dd>  				</div>  				<div className="info-list-entry">  					<dt>Header Name</dt> diff --git a/web/source/settings/views/admin/http-header-permissions/overview.tsx b/web/source/settings/views/admin/http-header-permissions/overview.tsx index 54b58b642..b2d8b7372 100644 --- a/web/source/settings/views/admin/http-header-permissions/overview.tsx +++ b/web/source/settings/views/admin/http-header-permissions/overview.tsx @@ -27,6 +27,7 @@ import { PermType } from "../../../lib/types/perm";  import { FetchBaseQueryError } from "@reduxjs/toolkit/query";  import { SerializedError } from "@reduxjs/toolkit";  import HeaderPermCreateForm from "./create"; +import { useCapitalize } from "../../../lib/util";  export default function HeaderPermsOverview() {  	const [ location, setLocation ] = useLocation(); @@ -41,9 +42,7 @@ export default function HeaderPermsOverview() {  	}, [params]);  	// Uppercase first letter of given permType. -	const permTypeUpper = useMemo(() => { -		return permType.charAt(0).toUpperCase() + permType.slice(1);  -	}, [permType]); +	const permTypeUpper = useCapitalize(permType);  	// Fetch desired perms, skipping  	// the ones we don't want. diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx index f03c4800c..10f7d726a 100644 --- a/web/source/settings/views/moderation/accounts/pending/index.tsx +++ b/web/source/settings/views/moderation/accounts/pending/index.tsx @@ -21,7 +21,7 @@ import React, { ReactNode } from "react";  import { useSearchAccountsQuery } from "../../../../lib/query/admin";  import { PageableList } from "../../../../components/pageable-list";  import { useLocation } from "wouter"; -import Username from "../../../../components/username"; +import UsernameLozenge from "../../../../components/username-lozenge";  import { AdminAccount } from "../../../../lib/types/account";  export default function AccountsPending() { @@ -32,7 +32,7 @@ export default function AccountsPending() {  	function itemToEntry(account: AdminAccount): ReactNode {  		const acc = account.account;  		return ( -			<Username +			<UsernameLozenge  				key={acc.acct}  				account={account}  				linkTo={`/${account.id}`} diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx index 504746adc..3b9e53ba2 100644 --- a/web/source/settings/views/moderation/accounts/search/index.tsx +++ b/web/source/settings/views/moderation/accounts/search/index.tsx @@ -26,8 +26,8 @@ import { Select, TextInput } from "../../../../components/form/inputs";  import MutationButton from "../../../../components/form/mutation-button";  import { useLocation, useSearch } from "wouter";  import { AdminAccount } from "../../../../lib/types/account"; -import Username from "../../../../components/username"; -import isValidDomain from "is-valid-domain"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import { formDomainValidator } from "../../../../lib/util/formvalidators";  export function AccountSearchForm() {  	const [ location, setLocation ] = useLocation(); @@ -45,28 +45,7 @@ export function AccountSearchForm() {  		display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}),  		by_domain: useTextInput("by_domain", {  			defaultValue: urlQueryParams.get("by_domain") ?? "", -			validator: (v: string) => { -				if (v.length === 0) { -					return ""; -				} - -				if (v[v.length-1] === ".") { -					return "invalid domain"; -				} - -				const valid = isValidDomain(v, { -					subdomain: true, -					wildcard: false, -					allowUnicode: true, -					topLevel: false, -				}); - -				if (valid) { -					return ""; -				} - -				return "invalid domain"; -			} +			validator: formDomainValidator,  		}),  		email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}),  		ip: useTextInput("ip", { defaultValue: urlQueryParams.get("ip") ?? ""}), @@ -114,7 +93,7 @@ export function AccountSearchForm() {  	function itemToEntry(account: AdminAccount): ReactNode {  		const acc = account.account;  		return ( -			<Username +			<UsernameLozenge  				key={acc.acct}  				account={account}  				linkTo={`/${account.id}`} diff --git a/web/source/settings/views/moderation/domain-permissions/detail.tsx b/web/source/settings/views/moderation/domain-permissions/detail.tsx index 2b27b534d..0105d9615 100644 --- a/web/source/settings/views/moderation/domain-permissions/detail.tsx +++ b/web/source/settings/views/moderation/domain-permissions/detail.tsx @@ -39,37 +39,47 @@ import { NoArg } from "../../../lib/types/query";  import { Error } from "../../../components/error";  import { useBaseUrl } from "../../../lib/navigation/util";  import { PermType } from "../../../lib/types/perm"; -import isValidDomain from "is-valid-domain"; +import { useCapitalize } from "../../../lib/util"; +import { formDomainValidator } from "../../../lib/util/formvalidators";  export default function DomainPermDetail() {  	const baseUrl = useBaseUrl(); -	 -	// Parse perm type from routing params. -	let params = useParams(); -	if (params.permType !== "blocks" && params.permType !== "allows") { +	const search = useSearch(); + +	// Parse perm type from routing params, converting +	// "blocks" => "block" and "allows" => "allow". +	const params = useParams(); +	const permTypeRaw = params.permType; +	if (permTypeRaw !== "blocks" && permTypeRaw !== "allows") {  		throw "unrecognized perm type " + params.permType;  	} -	const permType = params.permType.slice(0, -1) as PermType; +	const permType = useMemo(() => { +		return permTypeRaw.slice(0, -1) as PermType; +	}, [permTypeRaw]); -	const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); -	const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" }); - -	let isLoading; -	switch (permType) { -		case "block": -			isLoading = isLoadingDomainBlocks; -			break; -		case "allow": -			isLoading = isLoadingDomainAllows; -			break; -		default: -			throw "perm type unknown"; +	// Conditionally fetch either domain blocks or domain +	// allows depending on which perm type we're looking at. +	const { +		data: blocks = {}, +		isLoading: loadingBlocks, +		isFetching: fetchingBlocks, +	} = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); +	const { +		data: allows = {}, +		isLoading: loadingAllows, +		isFetching: fetchingAllows, +	} = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" }); + +	// Wait until we're done loading. +	const loading = permType === "block" +		? loadingBlocks || fetchingBlocks +		: loadingAllows || fetchingAllows; +	if (loading) { +		return <Loading />;  	}  	// Parse domain from routing params.  	let domain = params.domain ?? "unknown"; - -	const search = useSearch();  	if (domain === "view") {  		// Retrieve domain from form field submission.  		const searchParams = new URLSearchParams(search); @@ -81,36 +91,41 @@ export default function DomainPermDetail() {  		domain = searchDomain;  	} -	// Normalize / decode domain (it may be URL-encoded). +	// Normalize / decode domain +	// (it may be URL-encoded).  	domain = decodeURIComponent(domain); -	// Check if we already have a perm of the desired type for this domain. -	const existingPerm: DomainPerm | undefined = useMemo(() => { -		if (permType == "block") { -			return domainBlocks[domain]; -		} else { -			return domainAllows[domain]; -		} -	}, [domainBlocks, domainAllows, domain, permType]); - +	// Check if we already have a perm +	// of the desired type for this domain. +	const existingPerm = permType === "block" +		? blocks[domain] +		: allows[domain]; +	 +	// Render different into content depending on +	// if we have a perm already for this domain.  	let infoContent: React.JSX.Element; - -	if (isLoading) { -		infoContent = <Loading />; -	} else if (existingPerm == undefined) { -		infoContent = <span>No stored {permType} yet, you can add one below:</span>; +	if (existingPerm === undefined) { +		infoContent = ( +			<span> +				No stored {permType} yet, you can add one below: +			</span> +		);  	} else {  		infoContent = (  			<div className="info">  				<i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i> -				<b>Editing domain permissions isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b> +				<b>Editing existing domain {permTypeRaw} isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>  			</div>  		);  	}  	return (  		<div> -			<h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1> +			<h1 className="text-cutoff"> +				<BackButton to={`~${baseUrl}/${permTypeRaw}`} /> +				{" "} +				Domain {permType} for {domain} +			</h1>  			{infoContent}  			<DomainPermForm  				defaultDomain={domain} @@ -143,28 +158,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)  		domain: useTextInput("domain", {  			source: perm,  			defaultValue: defaultDomain, -			validator: (v: string) => { -				if (v.length === 0) { -					return ""; -				} - -				if (v[v.length-1] === ".") { -					return "invalid domain"; -				} - -				const valid = isValidDomain(v, { -					subdomain: true, -					wildcard: false, -					allowUnicode: true, -					topLevel: false, -				}); - -				if (valid) { -					return ""; -				} - -				return "invalid domain"; -			} +			validator: formDomainValidator,  		}),  		obfuscate: useBoolInput("obfuscate", { source: perm }),  		commentPrivate: useTextInput("private_comment", { source: perm }), @@ -209,9 +203,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)  	const [submitForm, submitFormResult] = useFormSubmit(form, [addTrigger, addResult], { changedOnly: false });  	// Uppercase first letter of given permType. -	const permTypeUpper = useMemo(() => { -		return permType.charAt(0).toUpperCase() + permType.slice(1);  -	}, [permType]); +	const permTypeUpper = useCapitalize(permType);  	const [location, setLocation] = useLocation(); diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/common.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/common.tsx new file mode 100644 index 000000000..af919dc57 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/common.tsx @@ -0,0 +1,43 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; + +export function DomainPermissionDraftHelpText() { +	return ( +		<> +			Domain permission drafts are domain block or domain allow entries that are not yet in force. +			<br/> +			You can choose to accept or remove a draft. +		</> +	); +} + +export function DomainPermissionDraftDocsLink() { +	return ( +		<a +			href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-drafts" +			target="_blank" +			className="docslink" +			rel="noreferrer" +		> +			Learn more about domain permission drafts (opens in a new tab) +		</a> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/detail.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/detail.tsx new file mode 100644 index 000000000..a5ba325f0 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/detail.tsx @@ -0,0 +1,210 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import { useLocation, useParams } from "wouter"; +import Loading from "../../../../components/loading"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { +	useAcceptDomainPermissionDraftMutation, +	useGetDomainPermissionDraftQuery, +	useRemoveDomainPermissionDraftMutation +} from "../../../../lib/query/admin/domain-permissions/drafts"; +import { Error as ErrorC } from "../../../../components/error"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useBoolInput, useTextInput } from "../../../../lib/form"; +import { Checkbox, Select } from "../../../../components/form/inputs"; +import { PermType } from "../../../../lib/types/perm"; + +export default function DomainPermissionDraftDetail() { +	const baseUrl = useBaseUrl(); +	const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`; +	const params = useParams(); + +	let id = params.permDraftId as string | undefined; +	if (!id) { +		throw "no perm ID"; +	} + +	const { +		data: permDraft, +		isLoading, +		isFetching, +		isError, +		error, +	} = useGetDomainPermissionDraftQuery(id); + +	if (isLoading || isFetching) { +		return <Loading />; +	} else if (isError) { +		return <ErrorC error={error} />; +	} else if (permDraft === undefined) { +		return <ErrorC error={new Error("permission draft was undefined")} />; +	} + +	const created = permDraft.created_at ? new Date(permDraft.created_at).toDateString(): "unknown"; +	const domain = permDraft.domain; +	const permType = permDraft.permission_type; +	if (!permType) { +		return <ErrorC error={new Error("permission_type was undefined")} />; +	} +	const publicComment = permDraft.public_comment ?? "[none]"; +	const privateComment = permDraft.private_comment ?? "[none]"; +	const subscriptionID = permDraft.subscription_id ?? "[none]"; + +	return ( +		<div className="domain-permission-draft-details"> +			<h1><BackButton to={backLocation} /> Domain Permission Draft Detail</h1> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Created</dt> +					<dd><time dateTime={permDraft.created_at}>{created}</time></dd> +				</div> +				<div className="info-list-entry"> +					<dt>Created By</dt> +					<dd> +						<UsernameLozenge +							account={permDraft.created_by} +							linkTo={`~/settings/moderation/accounts/${permDraft.created_by}`} +							backLocation={`~${location}`} +						/> +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Domain</dt> +					<dd>{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Permission type</dt> +					<dd className={`permission-type ${permType}`}> +						<i +							aria-hidden={true} +							className={`fa fa-${permType === "allow" ? "check" : "close"}`} +						></i> +						{permType} +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment</dt> +					<dd>{privateComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Public comment</dt> +					<dd>{publicComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Subscription ID</dt> +					<dd>{subscriptionID}</dd> +				</div> +			</dl> +			<HandleDraft +				id={id} +				permType={permType} +				backLocation={backLocation} +			/>  +		</div> +	); +} + +function HandleDraft({ id, permType, backLocation }: { id: string, permType: PermType, backLocation: string }) { +	const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation(); +	const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation(); +	const [_location, setLocation] = useLocation(); +	const form = { +		acceptOrRemove: useTextInput("accept_or_remove", { defaultValue: "accept" }), +		overwrite: useBoolInput("overwrite"), +		exclude_target: useBoolInput("exclude_target"), +	}; + +	const onClick = (e) => { +		e.preventDefault(); +		if (form.acceptOrRemove.value === "accept") { +			const overwrite = form.overwrite.value; +			accept({id, overwrite, permType}).then(res => { +				if ("data" in res) { +					setLocation(backLocation); +				} +			}); +		} else { +			const exclude_target = form.exclude_target.value; +			remove({id, exclude_target}).then(res => { +				if ("data" in res) { +					setLocation(backLocation); +				} +			});	 +		} +	}; + +	return ( +		<form> +			<Select +				field={form.acceptOrRemove} +				label="Accept or remove draft" +				options={ +					<> +						<option value="accept">Accept</option> +						<option value="remove">Remove</option> +					</> +				} +			></Select> +			 +			{ form.acceptOrRemove.value === "accept" && +				<> +					<Checkbox +						field={form.overwrite} +						label={`Overwrite any existing ${permType} for this domain`} +					/> +				</> +			} + +			{ form.acceptOrRemove.value === "remove" && +				<> +					<Checkbox +						field={form.exclude_target} +						label={`Add a domain permission exclude for this domain`} +					/> +				</> +			} + +			<MutationButton +				label={ +					form.acceptOrRemove.value === "accept" +						? `Accept ${permType}` +						: "Remove draft" +				} +				type="button" +				className={ +					form.acceptOrRemove.value === "accept" +						? "button" +						: "button danger" +				} +				onClick={onClick} +				disabled={false} +				showError={true} +				result={ +					form.acceptOrRemove.value === "accept" +						? acceptResult +						: removeResult +				} +			/> +		</form> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx new file mode 100644 index 000000000..19dbe0d88 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx @@ -0,0 +1,293 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React, { ReactNode, useEffect, useMemo } from "react"; + +import { useTextInput } from "../../../../lib/form"; +import { PageableList } from "../../../../components/pageable-list"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { useAcceptDomainPermissionDraftMutation, useLazySearchDomainPermissionDraftsQuery, useRemoveDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts"; +import { DomainPerm } from "../../../../lib/types/domain-permission"; +import { Error as ErrorC } from "../../../../components/error"; +import { Select, TextInput } from "../../../../components/form/inputs"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import { useCapitalize } from "../../../../lib/util"; +import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common"; + +export default function DomainPermissionDraftsSearch() { +	return ( +		<div className="domain-permission-drafts-view"> +			<div className="form-section-docs"> +				<h1>Domain Permission Drafts</h1> +				<p> +					You can use the form below to search through domain permission drafts. +					<br/> +					<DomainPermissionDraftHelpText /> +				</p> +				<DomainPermissionDraftDocsLink /> +			</div> +			<DomainPermissionDraftsSearchForm /> +		</div> +	); +} + +function DomainPermissionDraftsSearchForm() { +	const [ location, setLocation ] = useLocation(); +	const search = useSearch(); +	const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); +	const hasParams = urlQueryParams.size != 0; +	const [ searchDrafts, searchRes ] = useLazySearchDomainPermissionDraftsQuery(); + +	const form = { +		subscription_id: useTextInput("subscription_id", { defaultValue: urlQueryParams.get("subscription_id") ?? "" }), +		domain: useTextInput("domain", { +			defaultValue: urlQueryParams.get("domain") ?? "", +			validator: formDomainValidator, +		}), +		permission_type: useTextInput("permission_type", { defaultValue: urlQueryParams.get("permission_type") ?? "" }), +		limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" }) +	}; + +	// On mount, if urlQueryParams were provided, +	// trigger the search. For example, if page +	// was accessed at /search?origin=local&limit=20, +	// then run a search with origin=local and +	// limit=20 and immediately render the results. +	// +	// If no urlQueryParams set, trigger default +	// search (first page, no filtering). +	useEffect(() => { +		if (hasParams) { +			searchDrafts(Object.fromEntries(urlQueryParams)); +		} else { +			setLocation(location + "?limit=20"); +		} +	}, [ +		urlQueryParams, +		hasParams, +		searchDrafts, +		location, +		setLocation, +	]); + +	// Rather than triggering the search directly, +	// the "submit" button changes the location +	// based on form field params, and lets the +	// useEffect hook above actually do the search. +	function submitQuery(e) { +		e.preventDefault(); +		 +		// Parse query parameters. +		const entries = Object.entries(form).map(([k, v]) => { +			// Take only defined form fields. +			if (v.value === undefined || v.value.length === 0 || v.value === "any") { +				return null; +			} +			return [[k, v.value]]; +		}).flatMap(kv => { +			// Remove any nulls. +			return kv || []; +		}); + +		const searchParams = new URLSearchParams(entries); +		setLocation(location + "?" + searchParams.toString()); +	} + +	// Location to return to when user clicks "back" on the detail view. +	const backLocation = location + (hasParams ? `?${urlQueryParams}` : ""); +	 +	// Function to map an item to a list entry. +	function itemToEntry(draft: DomainPerm): ReactNode { +		return ( +			<DraftListEntry +				key={draft.id}	 +				permDraft={draft} +				linkTo={`/drafts/${draft.id}`} +				backLocation={backLocation} +			/> +		); +	} + +	return ( +		<> +			<form +				onSubmit={submitQuery} +				// Prevent password managers +				// trying to fill in fields. +				autoComplete="off" +			> +				<Select +					field={form.permission_type} +					label="Permission type" +					options={ +						<> +							<option value="">Any</option> +							<option value="block">Block</option> +							<option value="allow">Allow</option> +						</> +					} +				></Select> +				<TextInput +					field={form.domain} +					label={`Domain (without "https://" prefix)`} +					placeholder="example.org" +					autoCapitalize="none" +					spellCheck="false" +				/> +				<Select +					field={form.limit} +					label="Items per page" +					options={ +						<> +							<option value="20">20</option> +							<option value="50">50</option> +							<option value="100">100</option> +						</> +					} +				></Select> +				<MutationButton +					disabled={false} +					label={"Search"} +					result={searchRes} +				/> +			</form> +			<PageableList +				isLoading={searchRes.isLoading} +				isFetching={searchRes.isFetching} +				isSuccess={searchRes.isSuccess} +				items={searchRes.data?.drafts} +				itemToEntry={itemToEntry} +				isError={searchRes.isError} +				error={searchRes.error} +				emptyMessage={<b>No drafts found that match your query.</b>} +				prevNextLinks={searchRes.data?.links} +			/> +		</> +	); +} + +interface DraftEntryProps { +	permDraft: DomainPerm; +	linkTo: string; +	backLocation: string; +} + +function DraftListEntry({ permDraft, linkTo, backLocation }: DraftEntryProps) { +	const [ _location, setLocation ] = useLocation(); +	const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation(); +	const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation(); + +	const domain = permDraft.domain; +	const permType = permDraft.permission_type; +	const permTypeUpper = useCapitalize(permType); +	if (!permType) { +		return <ErrorC error={new Error("permission_type was undefined")} />; +	} + +	const publicComment = permDraft.public_comment ?? "[none]"; +	const privateComment = permDraft.private_comment ?? "[none]"; +	const subscriptionID = permDraft.subscription_id ?? "[none]"; +	const id = permDraft.id; +	if (!id) { +		return <ErrorC error={new Error("id was undefined")} />; +	} + +	const title = `${permTypeUpper} ${domain}`; + +	return ( +		<span +			className={`pseudolink domain-permission-draft entry ${permType}`} +			aria-label={title} +			title={title} +			onClick={() => { +				// When clicking on a draft, direct +				// to the detail view for that draft. +				setLocation(linkTo, { +					// Store the back location in history so +					// the detail view can use it to return to +					// this page (including query parameters). +					state: { backLocation: backLocation } +				}); +			}} +			role="link" +			tabIndex={0} +		> +			<h3>{title}</h3> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Domain:</dt> +					<dd className="text-cutoff">{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Permission type:</dt> +					<dd className={`permission-type ${permType}`}> +						<i +							aria-hidden={true} +							className={`fa fa-${permType === "allow" ? "check" : "close"}`} +						></i> +						{permType} +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment:</dt> +					<dd className="text-cutoff">{privateComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Public comment:</dt> +					<dd>{publicComment}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Subscription:</dt> +					<dd className="text-cutoff">{subscriptionID}</dd> +				</div> +			</dl> +			<div className="action-buttons"> +				<MutationButton +					label={`Accept ${permType}`} +					title={`Accept ${permType}`} +					type="button" +					className="button" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						accept({ id, permType }); +					}} +					disabled={false} +					showError={true} +					result={acceptResult} +				/> +				<MutationButton +					label={`Remove draft`} +					title={`Remove draft`} +					type="button" +					className="button danger" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						remove({ id }); +					}} +					disabled={false} +					showError={true} +					result={removeResult} +				/> +			</div> +		</span> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx new file mode 100644 index 000000000..c78f8192a --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx @@ -0,0 +1,119 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import useFormSubmit from "../../../../lib/form/submit"; +import { useCreateDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts"; +import { useBoolInput, useRadioInput, useTextInput } from "../../../../lib/form"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import MutationButton from "../../../../components/form/mutation-button"; +import { Checkbox, RadioGroup, TextArea, TextInput } from "../../../../components/form/inputs"; +import { useLocation } from "wouter"; +import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common"; + +export default function DomainPermissionDraftNew() { +	const [ _location, setLocation ] = useLocation(); +	 +	const form = { +		domain: useTextInput("domain", { +			validator: formDomainValidator, +		}), +		permission_type: useRadioInput("permission_type", {  +			options: { +				block: "Block domain", +				allow: "Allow domain", +			} +		}), +		obfuscate: useBoolInput("obfuscate"), +		public_comment: useTextInput("public_comment"), +		private_comment: useTextInput("private_comment"), +	}; +		 +	const [formSubmit, result] = useFormSubmit( +		form, +		useCreateDomainPermissionDraftMutation(), +		{ +			changedOnly: false, +			onFinish: (res) => { +				if (res.data) { +					// Creation successful, +					// redirect to drafts overview. +					setLocation(`/drafts/search`); +				} +			}, +		}); + +	return ( +		<form +			onSubmit={formSubmit} +			// Prevent password managers +			// trying to fill in fields. +			autoComplete="off" +		> +			<div className="form-section-docs"> +				<h2>New Domain Permission Draft</h2> +				<p><DomainPermissionDraftHelpText /></p> +				<DomainPermissionDraftDocsLink /> +			</div> + +			<RadioGroup +				field={form.permission_type} +			/> + +			<TextInput +				field={form.domain} +				label={`Domain (without "https://" prefix)`} +				placeholder="example.org" +				autoCapitalize="none" +				spellCheck="false" +			/> + +			<TextArea +				field={form.private_comment} +				label={"Private comment"} +				placeholder="This domain is like unto a clown car full of clowns, I suggest we block it forthwith." +				autoCapitalize="sentences" +				rows={3} +			/> + +			<TextArea +				field={form.public_comment} +				label={"Public comment"} +				placeholder="Bad posters" +				autoCapitalize="sentences" +				rows={3} +			/> + +			<Checkbox +				field={form.obfuscate} +				label="Obfuscate domain in public lists" +			/> + +			<MutationButton +				label="Save" +				result={result} +				disabled={ +					!form.domain.value || +					!form.domain.valid || +					!form.permission_type.value +				} +			/> +		</form> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/common.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/common.tsx new file mode 100644 index 000000000..f88f0af68 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/common.tsx @@ -0,0 +1,54 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; + +export function DomainPermissionExcludeHelpText() { +	return ( +		<> +			Domain permission excludes prevent permissions for a domain (and all +			subdomains) from being auomatically managed by domain permission subscriptions. +			<br/> +			For example, if you create an exclude entry for <code>example.org</code>, then +			a blocklist or allowlist subscription will <em>exclude</em> entries for <code>example.org</code> +			and any of its subdomains (<code>sub.example.org</code>, <code>another.sub.example.org</code> etc.) +			when creating domain permission drafts and domain blocks/allows. +			<br/> +			This functionality allows you to manually manage permissions for excluded domains, +			in cases where you know you definitely do or don't want to federate with a given domain, +			no matter what entries are contained in a domain permission subscription. +			<br/> +			Note that by itself, creation of an exclude entry for a given domain does not affect +			federation with that domain at all, it is only useful in combination with permission subscriptions. +		</> +	); +} + +export function DomainPermissionExcludeDocsLink() { +	return ( +		<a +			href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-excludes" +			target="_blank" +			className="docslink" +			rel="noreferrer" +		> +			Learn more about domain permission excludes (opens in a new tab) +		</a> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/detail.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/detail.tsx new file mode 100644 index 000000000..4e14ec3ad --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/detail.tsx @@ -0,0 +1,119 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import { useLocation, useParams } from "wouter"; +import Loading from "../../../../components/loading"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { Error as ErrorC } from "../../../../components/error"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import { useDeleteDomainPermissionExcludeMutation, useGetDomainPermissionExcludeQuery } from "../../../../lib/query/admin/domain-permissions/excludes"; +import MutationButton from "../../../../components/form/mutation-button"; + +export default function DomainPermissionExcludeDetail() { +	const baseUrl = useBaseUrl(); +	const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`; + +	const params = useParams(); +	let id = params.excludeId as string | undefined; +	if (!id) { +		throw "no perm ID"; +	} + +	const { +		data: permExclude, +		isLoading, +		isFetching, +		isError, +		error, +	} = useGetDomainPermissionExcludeQuery(id); + +	if (isLoading || isFetching) { +		return <Loading />; +	} else if (isError) { +		return <ErrorC error={error} />; +	} else if (permExclude === undefined) { +		return <ErrorC error={new Error("permission exclude was undefined")} />; +	} + +	const created = permExclude.created_at ? new Date(permExclude.created_at).toDateString(): "unknown"; +	const domain = permExclude.domain; +	const privateComment = permExclude.private_comment ?? "[none]"; + +	return ( +		<div className="domain-permission-exclude-details"> +			<h1><BackButton to={backLocation} /> Domain Permission Exclude Detail</h1> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Created</dt> +					<dd><time dateTime={permExclude.created_at}>{created}</time></dd> +				</div> +				<div className="info-list-entry"> +					<dt>Created By</dt> +					<dd> +						<UsernameLozenge +							account={permExclude.created_by} +							linkTo={`~/settings/moderation/accounts/${permExclude.created_by}`} +							backLocation={`~${location}`} +						/> +					</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Domain</dt> +					<dd>{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment</dt> +					<dd>{privateComment}</dd> +				</div> +			</dl> +			<HandleExclude +				id={id} +				backLocation={backLocation} +			/> +		</div> +	); +} + +function HandleExclude({ id, backLocation}: {id: string, backLocation: string}) { +	const [_location, setLocation] = useLocation(); +	const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation(); +	 +	return ( +		<MutationButton +			label={`Delete exclude`} +			title={`Delete exclude`} +			type="button" +			className="button danger" +			onClick={(e) => { +				e.preventDefault(); +				e.stopPropagation(); +				deleteExclude(id).then(res => { +					if ("data" in res) { +						setLocation(backLocation); +					} +				}); +			}} +			disabled={false} +			showError={true} +			result={deleteResult} +		/> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/index.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/index.tsx new file mode 100644 index 000000000..915d6f5cc --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/index.tsx @@ -0,0 +1,235 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React, { ReactNode, useEffect, useMemo } from "react"; + +import { useTextInput } from "../../../../lib/form"; +import { PageableList } from "../../../../components/pageable-list"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { useDeleteDomainPermissionExcludeMutation, useLazySearchDomainPermissionExcludesQuery } from "../../../../lib/query/admin/domain-permissions/excludes"; +import { DomainPerm } from "../../../../lib/types/domain-permission"; +import { Error as ErrorC } from "../../../../components/error"; +import { Select, TextInput } from "../../../../components/form/inputs"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common"; + +export default function DomainPermissionExcludesSearch() { +	return ( +		<div className="domain-permission-excludes-view"> +			<div className="form-section-docs"> +				<h1>Domain Permission Excludes</h1> +				<p> +					You can use the form below to search through domain permission excludes. +					<br/> +					<DomainPermissionExcludeHelpText /> +				</p> +				<DomainPermissionExcludeDocsLink /> +			</div> +			<DomainPermissionExcludesSearchForm /> +		</div> +	); +} + +function DomainPermissionExcludesSearchForm() { +	const [ location, setLocation ] = useLocation(); +	const search = useSearch(); +	const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); +	const hasParams = urlQueryParams.size != 0; +	const [ searchExcludes, searchRes ] = useLazySearchDomainPermissionExcludesQuery(); + +	const form = { +		domain: useTextInput("domain", { +			defaultValue: urlQueryParams.get("domain") ?? "", +			validator: formDomainValidator, +		}), +		limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" }) +	}; + +	// On mount, if urlQueryParams were provided, +	// trigger the search. For example, if page +	// was accessed at /search?origin=local&limit=20, +	// then run a search with origin=local and +	// limit=20 and immediately render the results. +	// +	// If no urlQueryParams set, trigger default +	// search (first page, no filtering). +	useEffect(() => { +		if (hasParams) { +			searchExcludes(Object.fromEntries(urlQueryParams)); +		} else { +			setLocation(location + "?limit=20"); +		} +	}, [ +		urlQueryParams, +		hasParams, +		searchExcludes, +		location, +		setLocation, +	]); + +	// Rather than triggering the search directly, +	// the "submit" button changes the location +	// based on form field params, and lets the +	// useEffect hook above actually do the search. +	function submitQuery(e) { +		e.preventDefault(); +		 +		// Parse query parameters. +		const entries = Object.entries(form).map(([k, v]) => { +			// Take only defined form fields. +			if (v.value === undefined || v.value.length === 0 || v.value === "any") { +				return null; +			} +			return [[k, v.value]]; +		}).flatMap(kv => { +			// Remove any nulls. +			return kv || []; +		}); + +		const searchParams = new URLSearchParams(entries); +		setLocation(location + "?" + searchParams.toString()); +	} + +	// Location to return to when user clicks "back" on the detail view. +	const backLocation = location + (hasParams ? `?${urlQueryParams}` : ""); +	 +	// Function to map an item to a list entry. +	function itemToEntry(exclude: DomainPerm): ReactNode { +		return ( +			<ExcludeListEntry +				key={exclude.id}	 +				permExclude={exclude} +				linkTo={`/excludes/${exclude.id}`} +				backLocation={backLocation} +			/> +		); +	} + +	return ( +		<> +			<form +				onSubmit={submitQuery} +				// Prevent password managers +				// trying to fill in fields. +				autoComplete="off" +			> +				<TextInput +					field={form.domain} +					label={`Domain (without "https://" prefix)`} +					placeholder="example.org" +					autoCapitalize="none" +					spellCheck="false" +				/> +				<Select +					field={form.limit} +					label="Items per page" +					options={ +						<> +							<option value="20">20</option> +							<option value="50">50</option> +							<option value="100">100</option> +						</> +					} +				></Select> +				<MutationButton +					disabled={false} +					label={"Search"} +					result={searchRes} +				/> +			</form> +			<PageableList +				isLoading={searchRes.isLoading} +				isFetching={searchRes.isFetching} +				isSuccess={searchRes.isSuccess} +				items={searchRes.data?.excludes} +				itemToEntry={itemToEntry} +				isError={searchRes.isError} +				error={searchRes.error} +				emptyMessage={<b>No excludes found that match your query.</b>} +				prevNextLinks={searchRes.data?.links} +			/> +		</> +	); +} + +interface ExcludeEntryProps { +	permExclude: DomainPerm; +	linkTo: string; +	backLocation: string; +} + +function ExcludeListEntry({ permExclude, linkTo, backLocation }: ExcludeEntryProps) { +	const [ _location, setLocation ] = useLocation(); +	const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation(); + +	const domain = permExclude.domain; +	const privateComment = permExclude.private_comment ?? "[none]"; +	const id = permExclude.id; +	if (!id) { +		return <ErrorC error={new Error("id was undefined")} />; +	} + +	return ( +		<span +			className={`pseudolink domain-permission-exclude entry`} +			aria-label={`Exclude ${domain}`} +			title={`Exclude ${domain}`} +			onClick={() => { +				// When clicking on a exclude, direct +				// to the detail view for that exclude. +				setLocation(linkTo, { +					// Store the back location in history so +					// the detail view can use it to return to +					// this page (including query parameters). +					state: { backLocation: backLocation } +				}); +			}} +			role="link" +			tabIndex={0} +		> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>Domain:</dt> +					<dd className="text-cutoff">{domain}</dd> +				</div> +				<div className="info-list-entry"> +					<dt>Private comment:</dt> +					<dd className="text-cutoff">{privateComment}</dd> +				</div> +			</dl> +			<div className="action-buttons"> +				<MutationButton +					label={`Delete exclude`} +					title={`Delete exclude`} +					type="button" +					className="button danger" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						deleteExclude(id); +					}} +					disabled={false} +					showError={true} +					result={deleteResult} +				/> +			</div> +		</span> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/excludes/new.tsx b/web/source/settings/views/moderation/domain-permissions/excludes/new.tsx new file mode 100644 index 000000000..ad33070f8 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/excludes/new.tsx @@ -0,0 +1,90 @@ +/* +	GoToSocial +	Copyright (C) GoToSocial Authors admin@gotosocial.org +	SPDX-License-Identifier: AGPL-3.0-or-later + +	This program 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. + +	This program 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 this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import useFormSubmit from "../../../../lib/form/submit"; +import { useCreateDomainPermissionExcludeMutation } from "../../../../lib/query/admin/domain-permissions/excludes"; +import { useTextInput } from "../../../../lib/form"; +import { formDomainValidator } from "../../../../lib/util/formvalidators"; +import MutationButton from "../../../../components/form/mutation-button"; +import { TextArea, TextInput } from "../../../../components/form/inputs"; +import { useLocation } from "wouter"; +import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common"; + +export default function DomainPermissionExcludeNew() { +	const [ _location, setLocation ] = useLocation(); +	 +	const form = { +		domain: useTextInput("domain", { +			validator: formDomainValidator, +		}), +		private_comment: useTextInput("private_comment"), +	}; +		 +	const [formSubmit, result] = useFormSubmit( +		form, +		useCreateDomainPermissionExcludeMutation(), +		{ +			changedOnly: false, +			onFinish: (res) => { +				if (res.data) { +					// Creation successful, +					// redirect to excludes overview. +					setLocation(`/excludes/search`); +				} +			}, +		}); + +	return ( +		<form +			onSubmit={formSubmit} +			// Prevent password managers +			// trying to fill in fields. +			autoComplete="off" +		> +			<div className="form-section-docs"> +				<h2>New Domain Permission Exclude</h2> +				<p><DomainPermissionExcludeHelpText /></p> +				<DomainPermissionExcludeDocsLink /> +			</div> + +			<TextInput +				field={form.domain} +				label={`Domain (without "https://" prefix)`} +				placeholder="example.org" +				autoCapitalize="none" +				spellCheck="false" +			/> + +			<TextArea +				field={form.private_comment} +				label={"Private comment"} +				placeholder="Created an exclude for this domain because we should manage it manually." +				autoCapitalize="sentences" +				rows={3} +			/> + +			<MutationButton +				label="Save" +				result={result} +				disabled={!form.domain.value || !form.domain.valid} +			/> +		</form> +	); +} diff --git a/web/source/settings/views/moderation/domain-permissions/overview.tsx b/web/source/settings/views/moderation/domain-permissions/overview.tsx index b2e675e05..b9a277e59 100644 --- a/web/source/settings/views/moderation/domain-permissions/overview.tsx +++ b/web/source/settings/views/moderation/domain-permissions/overview.tsx @@ -30,6 +30,7 @@ import type { MappedDomainPerms } from "../../../lib/types/domain-permission";  import { NoArg } from "../../../lib/types/query";  import { PermType } from "../../../lib/types/perm";  import { useBaseUrl } from "../../../lib/navigation/util"; +import { useCapitalize } from "../../../lib/util";  export default function DomainPermissionsOverview() {	  	const baseUrl = useBaseUrl(); @@ -42,9 +43,7 @@ export default function DomainPermissionsOverview() {  	const permType = params.permType.slice(0, -1) as PermType;  	// Uppercase first letter of given permType. -	const permTypeUpper = useMemo(() => { -		return permType.charAt(0).toUpperCase() + permType.slice(1);  -	}, [permType]); +	const permTypeUpper = useCapitalize(permType);  	// Fetch / wait for desired perms to load.  	const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 9488b8c30..7ac6f9327 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -116,6 +116,40 @@ function ModerationDomainPermsMenu() {  				itemUrl="import-export"  				icon="fa-floppy-o"  			/> +			<MenuItem +				name="Drafts" +				itemUrl="drafts" +				defaultChild="search" +				icon="fa-pencil" +			> +				<MenuItem +					name="Search" +					itemUrl="search" +					icon="fa-list" +				/> +				<MenuItem +					name="New draft" +					itemUrl="new" +					icon="fa-plus" +				/> +			</MenuItem> +			<MenuItem +				name="Excludes" +				itemUrl="excludes" +				defaultChild="search" +				icon="fa-minus-square" +			> +				<MenuItem +					name="Search" +					itemUrl="search" +					icon="fa-list" +				/> +				<MenuItem +					name="New exclude" +					itemUrl="new" +					icon="fa-plus" +				/> +			</MenuItem>  		</MenuItem>  	);  } diff --git a/web/source/settings/views/moderation/reports/detail.tsx b/web/source/settings/views/moderation/reports/detail.tsx index 298b5bd37..b176b9f1e 100644 --- a/web/source/settings/views/moderation/reports/detail.tsx +++ b/web/source/settings/views/moderation/reports/detail.tsx @@ -25,7 +25,7 @@ import { useValue, useTextInput } from "../../../lib/form";  import useFormSubmit from "../../../lib/form/submit";  import { TextArea } from "../../../components/form/inputs";  import MutationButton from "../../../components/form/mutation-button"; -import Username from "../../../components/username"; +import UsernameLozenge from "../../../components/username-lozenge";  import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";  import { useBaseUrl } from "../../../lib/navigation/util";  import { AdminReport } from "../../../lib/types/report"; @@ -99,7 +99,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {  			<div className="info-list-entry">  				<dt>Reported account</dt>  				<dd> -					<Username +					<UsernameLozenge  						account={target}  						linkTo={`~/settings/moderation/accounts/${target.id}`}  						backLocation={`~${baseUrl}${location}`} @@ -110,7 +110,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {  			<div className="info-list-entry">  				<dt>Reported by</dt>  				<dd> -					<Username +					<UsernameLozenge  						account={from}  						linkTo={`~/settings/moderation/accounts/${from.id}`}  						backLocation={`~${baseUrl}${location}`} @@ -173,7 +173,7 @@ function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {  				<div className="info-list-entry">  					<dt>Handled by</dt>  					<dd> -						<Username +						<UsernameLozenge  							account={handled_by}  							linkTo={`~/settings/moderation/accounts/${handled_by.id}`}  							backLocation={`~${baseUrl}${location}`} diff --git a/web/source/settings/views/moderation/reports/search.tsx b/web/source/settings/views/moderation/reports/search.tsx index da0c80d69..0ae3ec0e0 100644 --- a/web/source/settings/views/moderation/reports/search.tsx +++ b/web/source/settings/views/moderation/reports/search.tsx @@ -25,7 +25,7 @@ import { PageableList } from "../../../components/pageable-list";  import { Select } from "../../../components/form/inputs";  import MutationButton from "../../../components/form/mutation-button";  import { useLocation, useSearch } from "wouter"; -import Username from "../../../components/username"; +import UsernameLozenge from "../../../components/username-lozenge";  import { AdminReport } from "../../../lib/types/report";  export default function ReportsSearch() { @@ -206,7 +206,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {  				<div className="info-list-entry">  					<dt>Reported account:</dt>  					<dd className="text-cutoff"> -						<Username +						<UsernameLozenge  							account={target}  							classNames={["text-cutoff report-byline"]}  						/> @@ -216,7 +216,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {  				<div className="info-list-entry">  					<dt>Reported by:</dt>  					<dd className="text-cutoff reported-by"> -						<Username account={from} /> +						<UsernameLozenge account={from} />  					</dd>  				</div> diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index 93f7e481a..779498ffe 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -29,6 +29,12 @@ import DomainPermDetail from "./domain-permissions/detail";  import AccountsSearch from "./accounts";  import AccountsPending from "./accounts/pending";  import AccountDetail from "./accounts/detail"; +import DomainPermissionDraftsSearch from "./domain-permissions/drafts"; +import DomainPermissionDraftNew from "./domain-permissions/drafts/new"; +import DomainPermissionDraftDetail from "./domain-permissions/drafts/detail"; +import DomainPermissionExcludeDetail from "./domain-permissions/excludes/detail"; +import DomainPermissionExcludesSearch from "./domain-permissions/excludes"; +import DomainPermissionExcludeNew from "./domain-permissions/excludes/new";  /*  	EXPORTED COMPONENTS @@ -139,6 +145,12 @@ function ModerationDomainPermsRouter() {  					<Switch>  						<Route path="/import-export" component={ImportExport} />  						<Route path="/process" component={ImportExport} /> +						<Route path="/drafts/search" component={DomainPermissionDraftsSearch} /> +						<Route path="/drafts/new" component={DomainPermissionDraftNew} /> +						<Route path="/drafts/:permDraftId" component={DomainPermissionDraftDetail} /> +						<Route path="/excludes/search" component={DomainPermissionExcludesSearch} /> +						<Route path="/excludes/new" component={DomainPermissionExcludeNew} /> +						<Route path="/excludes/:excludeId" component={DomainPermissionExcludeDetail} />  						<Route path="/:permType" component={DomainPermissionsOverview} />  						<Route path="/:permType/:domain" component={DomainPermDetail} />  						<Route><Redirect to="/blocks"/></Route>  | 
