diff options
author | 2025-01-05 13:20:33 +0100 | |
---|---|---|
committer | 2025-01-05 13:20:33 +0100 | |
commit | e9bb7ddd3aa11da5c48a75c4a600f8fe5cc1c990 (patch) | |
tree | a2897775112a821aa093b6e2686044814912001f /web/source | |
parent | [chore] Update robots.txt with more AI bots (#3634) (diff) | |
download | gotosocial-e9bb7ddd3aa11da5c48a75c4a600f8fe5cc1c990.tar.xz |
[feature] Create/update/remove domain permission subscriptions (#3623)
* [feature] Create/update/remove domain permission subscriptions
* lint
* envparsing
* remove errant fmt.Println
* create drafts, subs, exclude, from snapshot models
* name etag column correctly
* remove count column
* lint
Diffstat (limited to 'web/source')
18 files changed, 1656 insertions, 7 deletions
diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx index 06075ea87..498499db6 100644 --- a/web/source/settings/components/form/inputs.tsx +++ b/web/source/settings/components/form/inputs.tsx @@ -26,6 +26,7 @@ import type { import type { FileFormInputHook, + NumberFormInputHook, RadioFormInputHook, TextFormInputHook, } from "../../lib/form/types"; @@ -57,6 +58,32 @@ export function TextInput({label, field, ...props}: TextInputProps) { ); } +export interface NumberInputProps extends React.DetailedHTMLProps< + React.InputHTMLAttributes<HTMLInputElement>, + HTMLInputElement +> { + label?: ReactNode; + field: NumberFormInputHook; +} + +export function NumberInput({label, field, ...props}: NumberInputProps) { + const { onChange, value, ref } = field; + + return ( + <div className={`form-field number${field.valid ? "" : " invalid"}`}> + <label> + {label} + <input + onChange={onChange} + value={value} + ref={ref as RefObject<HTMLInputElement>} + {...props} + /> + </label> + </div> + ); +} + export interface TextAreaProps extends React.DetailedHTMLProps< React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement diff --git a/web/source/settings/lib/form/index.ts b/web/source/settings/lib/form/index.ts index 409ef0328..878b7c79b 100644 --- a/web/source/settings/lib/form/index.ts +++ b/web/source/settings/lib/form/index.ts @@ -41,6 +41,7 @@ import type { ChecklistInputHook, FieldArrayInputHook, ArrayInputHook, + NumberFormInputHook, } from "./types"; function capitalizeFirst(str: string) { @@ -102,11 +103,11 @@ function value<T>(name: string, initialValue: T) { name, Name: "", value: initialValue, - hasChanged: () => true, // always included }; } export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts<string>) => TextFormInputHook; +export const useNumberInput = inputHook(text) as (_name: string, _opts?: HookOpts<number>) => NumberFormInputHook; export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts<File>) => FileFormInputHook; export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts<boolean>) => BoolFormInputHook; export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts<string>) => RadioFormInputHook; diff --git a/web/source/settings/lib/form/number.tsx b/web/source/settings/lib/form/number.tsx new file mode 100644 index 000000000..15acf162a --- /dev/null +++ b/web/source/settings/lib/form/number.tsx @@ -0,0 +1,104 @@ +/* + 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, { + useState, + useRef, + useTransition, + useEffect, +} from "react"; + +import type { + CreateHookNames, + HookOpts, + NumberFormInputHook, +} from "./types"; + +const _default = 0; + +export default function useNumberInput( + { name, Name }: CreateHookNames, + { + initialValue = _default, + dontReset = false, + validator, + showValidation = true, + initValidation, + nosubmit = false, + }: HookOpts<number> +): NumberFormInputHook { + const [number, setNumber] = useState(initialValue); + const numberRef = useRef<HTMLInputElement>(null); + + const [validation, setValidation] = useState(initValidation ?? ""); + const [_isValidating, startValidation] = useTransition(); + const valid = validation == ""; + + function onChange(e: React.ChangeEvent<HTMLInputElement>) { + const input = e.target.valueAsNumber; + setNumber(input); + + if (validator) { + startValidation(() => { + setValidation(validator(input)); + }); + } + } + + function reset() { + if (!dontReset) { + setNumber(initialValue); + } + } + + useEffect(() => { + if (validator && numberRef.current) { + if (showValidation) { + numberRef.current.setCustomValidity(validation); + } else { + numberRef.current.setCustomValidity(""); + } + } + }, [validation, validator, showValidation]); + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: number, + [`${name}Ref`]: numberRef, + [`set${Name}`]: setNumber, + [`${name}Valid`]: valid, + } + ], { + onChange, + reset, + name, + Name: "", // Will be set by inputHook function. + nosubmit, + value: number, + ref: numberRef, + setter: setNumber, + valid, + validate: () => setValidation(validator ? validator(number): ""), + hasChanged: () => number != initialValue, + _default + }); +} diff --git a/web/source/settings/lib/form/submit.ts b/web/source/settings/lib/form/submit.ts index aa662086f..498481deb 100644 --- a/web/source/settings/lib/form/submit.ts +++ b/web/source/settings/lib/form/submit.ts @@ -34,8 +34,24 @@ import type { } from "./types"; interface UseFormSubmitOptions { + /** + * Include only changed fields when submitting the form. + * If no fields have been changed, submit will be a noop. + */ changedOnly: boolean; + /** + * Optional function to run when the form has been sent + * and a response has been returned from the server. + */ onFinish?: ((_res: any) => void); + /** + * Can be optionally used to modify the final mutation argument from the + * gathered mutation data before it's passed into the trigger function. + * + * Useful if the mutation trigger function takes not just a simple key/value + * object but a more complicated object. + */ + customizeMutationArgs?: (_mutationData: { [k: string]: any }) => any; } /** @@ -105,7 +121,7 @@ export default function useFormSubmit( usedAction.current = action; // Transform the hooked form into an object. - const { + let { mutationData, updatedFields, } = getFormMutations(form, { changedOnly }); @@ -117,7 +133,12 @@ export default function useFormSubmit( return; } + // Final tweaks on the mutation + // argument before triggering it. mutationData.action = action; + if (opts.customizeMutationArgs) { + mutationData = opts.customizeMutationArgs(mutationData); + } try { const res = await runMutation(mutationData); diff --git a/web/source/settings/lib/form/types.ts b/web/source/settings/lib/form/types.ts index 17fbec53a..6a4162f3a 100644 --- a/web/source/settings/lib/form/types.ts +++ b/web/source/settings/lib/form/types.ts @@ -181,6 +181,13 @@ export interface TextFormInputHook extends FormInputHook<string>, _withValidate, _withRef {} +export interface NumberFormInputHook extends FormInputHook<number>, + _withSetter<number>, + _withOnChange, + _withReset, + _withValidate, + _withRef {} + export interface RadioFormInputHook extends FormInputHook<string>, _withSetter<string>, _withOnChange, diff --git a/web/source/settings/lib/query/admin/domain-permissions/subscriptions.ts b/web/source/settings/lib/query/admin/domain-permissions/subscriptions.ts new file mode 100644 index 000000000..f065aaf54 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/subscriptions.ts @@ -0,0 +1,164 @@ +/* + 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 { + DomainPermSub, + DomainPermSubCreateUpdateParams, + DomainPermSubSearchParams, + DomainPermSubSearchResp, +} from "../../../types/domain-permission"; +import parse from "parse-link-header"; +import { PermType } from "../../../types/perm"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + searchDomainPermissionSubscriptions: build.query<DomainPermSubSearchResp, DomainPermSubSearchParams>({ + 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_subscriptions${query}` + }; + }, + // Headers required for paging. + transformResponse: (apiResp: DomainPermSub[], meta) => { + const subs = apiResp; + const linksStr = meta?.response?.headers.get("Link"); + const links = parse(linksStr); + return { subs, links }; + }, + // Only provide TRANSFORMED tag id since this model is not the same + // as getDomainPermissionSubscription model (due to transformResponse). + providesTags: [{ type: "DomainPermissionSubscription", id: "TRANSFORMED" }] + }), + + getDomainPermissionSubscriptionsPreview: build.query<DomainPermSub[], PermType>({ + query: (permType) => ({ + url: `/api/v1/admin/domain_permission_subscriptions/preview?permission_type=${permType}` + }), + providesTags: (_result, _error, permType) => + // Cache by permission type. + [{ type: "DomainPermissionSubscription", id: `${permType}sByPriority` }] + }), + + getDomainPermissionSubscription: build.query<DomainPermSub, string>({ + query: (id) => ({ + url: `/api/v1/admin/domain_permission_subscriptions/${id}` + }), + providesTags: (_result, _error, id) => [ + { type: 'DomainPermissionSubscription', id } + ], + }), + + createDomainPermissionSubscription: build.mutation<DomainPermSub, DomainPermSubCreateUpdateParams>({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_permission_subscriptions`, + asForm: true, + body: formData, + discardEmpty: true + }), + invalidatesTags: (_res, _error, formData) => + [ + // Invalidate transformed list of all perm subs. + { type: "DomainPermissionSubscription", id: "TRANSFORMED" }, + // Invalidate perm subs of this type sorted by priority. + { type: "DomainPermissionSubscription", id: `${formData.permission_type}sByPriority` } + ] + }), + + updateDomainPermissionSubscription: build.mutation<DomainPermSub, { id: string, permType: PermType, formData: DomainPermSubCreateUpdateParams }>({ + query: ({ id, formData }) => ({ + method: "PATCH", + url: `/api/v1/admin/domain_permission_subscriptions/${id}`, + asForm: true, + body: formData, + }), + invalidatesTags: (_res, _error, { id, permType }) => + [ + // Invalidate this perm sub. + { type: "DomainPermissionSubscription", id: id }, + // Invalidate transformed list of all perms subs. + { type: "DomainPermissionSubscription", id: "TRANSFORMED" }, + // Invalidate perm subs of this type sorted by priority. + { type: "DomainPermissionSubscription", id: `${permType}sByPriority` } + ], + }), + + removeDomainPermissionSubscription: build.mutation<DomainPermSub, { id: string, remove_children: boolean }>({ + query: ({ id, remove_children }) => ({ + method: "POST", + url: `/api/v1/admin/domain_permission_subscriptions/${id}/remove`, + asForm: true, + body: { remove_children: remove_children }, + }), + }) + }), +}); + +/** + * View domain permission subscriptions. + */ +const useLazySearchDomainPermissionSubscriptionsQuery = extended.useLazySearchDomainPermissionSubscriptionsQuery; + +/** + * Get domain permission subscription with the given ID. + */ +const useGetDomainPermissionSubscriptionQuery = extended.useGetDomainPermissionSubscriptionQuery; + +/** + * Create a domain permission subscription with the given parameters. + */ +const useCreateDomainPermissionSubscriptionMutation = extended.useCreateDomainPermissionSubscriptionMutation; + +/** + * View domain permission subscriptions of selected perm type, sorted by priority descending. + */ +const useGetDomainPermissionSubscriptionsPreviewQuery = extended.useGetDomainPermissionSubscriptionsPreviewQuery; + +/** + * Update domain permission subscription. + */ +const useUpdateDomainPermissionSubscriptionMutation = extended.useUpdateDomainPermissionSubscriptionMutation; + +/** + * Remove a domain permission subscription and optionally its children (harsh). + */ +const useRemoveDomainPermissionSubscriptionMutation = extended.useRemoveDomainPermissionSubscriptionMutation; + +export { + useLazySearchDomainPermissionSubscriptionsQuery, + useGetDomainPermissionSubscriptionQuery, + useCreateDomainPermissionSubscriptionMutation, + useGetDomainPermissionSubscriptionsPreviewQuery, + useUpdateDomainPermissionSubscriptionMutation, + useRemoveDomainPermissionSubscriptionMutation, +}; diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 9543819a9..34b66913a 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -170,7 +170,8 @@ export const gtsApi = createApi({ "DefaultInteractionPolicies", "InteractionRequest", "DomainPermissionDraft", - "DomainPermissionExclude" + "DomainPermissionExclude", + "DomainPermissionSubscription" ], 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 1a0a9bd0b..c4560d79b 100644 --- a/web/source/settings/lib/types/domain-permission.ts +++ b/web/source/settings/lib/types/domain-permission.ts @@ -20,6 +20,7 @@ import typia from "typia"; import { PermType } from "./perm"; import { Links } from "parse-link-header"; +import { PermSubContentType } from "./permsubcontenttype"; export const validateDomainPerms = typia.createValidate<DomainPerm[]>(); @@ -213,3 +214,156 @@ export interface DomainPermExcludeCreateParams { */ private_comment?: string; } + +/** + * API model of one domain permission susbcription. + */ +export interface DomainPermSub { + /** + * The ID of the domain permission subscription. + */ + id: string; + /** + * The priority of the domain permission subscription. + */ + priority: number; + /** + * Time at which the subscription was created (ISO 8601 Datetime). + */ + created_at: string; + /** + * Title of this subscription, as set by admin who created or updated it. + */ + title: string; + /** + * The type of domain permission subscription (allow, block). + */ + permission_type: PermType; + /** + * If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect. + * If false, domain permissions from this subscription will come into force immediately. + */ + as_draft: boolean; + /** + * If true, this domain permission subscription will "adopt" domain permissions + * which already exist on the instance, and which meet the following conditions: + * 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present + * in the subscribed list. Such orphaned domain permissions will be given this + * subscription's subscription ID value and be managed by this subscription. + */ + adopt_orphans: boolean; + /** + * ID of the account that created this subscription. + */ + created_by: string; + /** + * URI to call in order to fetch the permissions list. + */ + uri: string; + /** + * MIME content type to use when parsing the permissions list. + */ + content_type: PermSubContentType; + /** + * (Optional) username to set for basic auth when doing a fetch of URI. + */ + fetch_username?: string; + /** + * (Optional) password to set for basic auth when doing a fetch of URI. + */ + fetch_password?: string; + /** + * Time at which the most recent fetch was attempted (ISO 8601 Datetime). + */ + fetched_at?: string; + /** + * Time of the most recent successful fetch (ISO 8601 Datetime). + */ + successfully_fetched_at?: string; + /** + * If most recent fetch attempt failed, this field will contain an error message related to the fetch attempt. + */ + error?: string; + /** + * Count of domain permission entries discovered at URI on last (successful) fetch. + */ + count: number; +} + +/** + * Parameters for GET to /api/v1/admin/domain_permission_subscriptions. + */ +export interface DomainPermSubSearchParams { + /** + * Return only block or allow subscriptions. + */ + 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 DomainPermSubCreateUpdateParams { + /** + * The priority of the domain permission subscription. + */ + priority?: number; + /** + * Title of this subscription, as set by admin who created or updated it. + */ + title?: string; + /** + * URI to call in order to fetch the permissions list. + */ + uri: string; + /** + * MIME content type to use when parsing the permissions list. + */ + content_type: PermSubContentType; + /** + * If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect. + * If false, domain permissions from this subscription will come into force immediately. + */ + as_draft?: boolean; + /** + * If true, this domain permission subscription will "adopt" domain permissions + * which already exist on the instance, and which meet the following conditions: + * 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present + * in the subscribed list. Such orphaned domain permissions will be given this + * subscription's subscription ID value and be managed by this subscription. + */ + adopt_orphans?: boolean; + /** + * (Optional) username to set for basic auth when doing a fetch of URI. + */ + fetch_username?: string; + /** + * (Optional) password to set for basic auth when doing a fetch of URI. + */ + fetch_password?: string; + /** + * The type of domain permission subscription to create or update (allow, block). + */ + permission_type: PermType; +} + +export interface DomainPermSubSearchResp { + subs: DomainPermSub[]; + links: Links | null; +} diff --git a/web/source/settings/lib/types/permsubcontenttype.ts b/web/source/settings/lib/types/permsubcontenttype.ts new file mode 100644 index 000000000..0468aae4d --- /dev/null +++ b/web/source/settings/lib/types/permsubcontenttype.ts @@ -0,0 +1,20 @@ +/* + 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/>. +*/ + +export type PermSubContentType = "text/plain" | "text/csv" | "application/json"; diff --git a/web/source/settings/lib/util/formvalidators.ts b/web/source/settings/lib/util/formvalidators.ts index c509cf59d..358db616c 100644 --- a/web/source/settings/lib/util/formvalidators.ts +++ b/web/source/settings/lib/util/formvalidators.ts @@ -46,3 +46,22 @@ export function formDomainValidator(domain: string): string { return "invalid domain"; } + +export function urlValidator(urlStr: string): string { + if (urlStr.length === 0) { + return ""; + } + + let url: URL; + try { + url = new URL(urlStr); + } catch (e) { + return e.message; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return `invalid protocol, must be http or https`; + } + + return formDomainValidator(url.host); +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 740c30059..bbb8fd61c 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -1360,9 +1360,12 @@ button.tab-button { } .domain-permission-drafts-view, -.domain-permission-excludes-view { +.domain-permission-excludes-view, +.domain-permission-subscriptions-view, +.domain-permission-subscriptions-preview { .domain-permission-draft, - .domain-permission-exclude { + .domain-permission-exclude, + .domain-permission-subscription { display: flex; flex-direction: column; flex-wrap: nowrap; @@ -1404,14 +1407,18 @@ button.tab-button { } .domain-permission-draft-details, -.domain-permission-exclude-details { +.domain-permission-exclude-details, +.domain-permission-subscription-details { .info-list { margin-top: 1rem; } } .domain-permission-drafts-view, -.domain-permission-draft-details { +.domain-permission-draft-details, +.domain-permission-subscriptions-view, +.domain-permission-subscription-details, +.domain-permission-subscriptions-preview { dd.permission-type { display: flex; gap: 0.35rem; @@ -1419,6 +1426,35 @@ button.tab-button { } } +.domain-permission-subscription-title { + font-size: 1.2rem; + font-weight: bold; +} + +.domain-permission-subscription-create, +.domain-permission-subscription-update { + gap: 1rem; + + .password-show-hide { + display: flex; + gap: 0.5rem; + + .form-field.text { + flex: 1; + } + + .password-show-hide-toggle { + font-size: 1rem; + line-height: 1.4rem; + align-self: flex-end; + } + } +} + +.domain-permission-subscription-remove { + gap: 1rem; +} + .instance-rules { list-style-position: inside; margin: 0; diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/common.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/common.tsx new file mode 100644 index 000000000..8668caa4b --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/common.tsx @@ -0,0 +1,181 @@ +/* + 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, { useMemo } from "react"; +import { useLocation } from "wouter"; +import { DomainPermSub } from "../../../../lib/types/domain-permission"; +import { yesOrNo } from "../../../../lib/util"; + +export function DomainPermissionSubscriptionHelpText() { + return ( + <> + Domain permission subscriptions allow your instance to "subscribe" to a list of block or allows at a given url. + <br/> + Every 24 hours, each subscribed list is fetched by your instance, and any discovered + permissions in each list are loaded into your instance as blocks/allows/drafts. + </> + ); +} + +export function DomainPermissionSubscriptionDocsLink() { + return ( + <a + href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-subscriptions" + target="_blank" + className="docslink" + rel="noreferrer" + > + Learn more about domain permission subscriptions (opens in a new tab) + </a> + ); +} + +export interface SubscriptionEntryProps { + permSub: DomainPermSub; + linkTo: string; + backLocation: string; +} + +export function SubscriptionListEntry({ permSub, linkTo, backLocation }: SubscriptionEntryProps) { + const [ _location, setLocation ] = useLocation(); + + const permType = permSub.permission_type; + if (!permType) { + throw "permission_type was undefined"; + } + + const { + priority, + title, + uri, + as_draft: asDraft, + adopt_orphans: adoptOrphans, + content_type: contentType, + fetched_at: fetchedAt, + successfully_fetched_at: successfullyFetchedAt, + count, + } = permSub; + + const ariaLabel = useMemo(() => { + let ariaLabel = ""; + + // Prepend title. + if (title.length !== 0) { + ariaLabel += `${title}, create `; + } else { + ariaLabel += "Create "; + } + + // Add perm type. + ariaLabel += permType; + + // Alter wording + // if using drafts. + if (asDraft) { + ariaLabel += " drafts from "; + } else { + ariaLabel += "s from "; + } + + // Add url. + ariaLabel += uri; + + return ariaLabel; + }, [title, permType, asDraft, uri]); + + let fetchedAtStr = "never"; + if (fetchedAt) { + fetchedAtStr = new Date(fetchedAt).toDateString(); + } + + let successfullyFetchedAtStr = "never"; + if (successfullyFetchedAt) { + successfullyFetchedAtStr = new Date(successfullyFetchedAt).toDateString(); + } + + return ( + <span + className={`pseudolink domain-permission-subscription entry`} + aria-label={ariaLabel} + title={ariaLabel} + onClick={() => { + // When clicking on a subscription, direct + // to the detail view for that subscription. + 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"> + { permSub.title !== "" && + <span className="domain-permission-subscription-title"> + {title} + </span> + } + <div className="info-list-entry"> + <dt>Priority:</dt> + <dd>{priority}</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>URL:</dt> + <dd className="text-cutoff">{uri}</dd> + </div> + <div className="info-list-entry"> + <dt>Content type:</dt> + <dd>{contentType}</dd> + </div> + <div className="info-list-entry"> + <dt>Create as draft:</dt> + <dd>{yesOrNo(asDraft)}</dd> + </div> + <div className="info-list-entry"> + <dt>Adopt orphans:</dt> + <dd>{yesOrNo(adoptOrphans)}</dd> + </div> + <div className="info-list-entry"> + <dt>Last fetch attempt:</dt> + <dd className="text-cutoff">{fetchedAtStr}</dd> + </div> + <div className="info-list-entry"> + <dt>Last successful fetch:</dt> + <dd className="text-cutoff">{successfullyFetchedAtStr}</dd> + </div> + <div className="info-list-entry"> + <dt>Discovered {permType}s:</dt> + <dd>{count}</dd> + </div> + </dl> + </span> + ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/detail.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/detail.tsx new file mode 100644 index 000000000..408d81b92 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/detail.tsx @@ -0,0 +1,384 @@ +/* + 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, { useState } from "react"; +import { useLocation, useParams } from "wouter"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { useGetDomainPermissionSubscriptionQuery, useRemoveDomainPermissionSubscriptionMutation, useUpdateDomainPermissionSubscriptionMutation } from "../../../../lib/query/admin/domain-permissions/subscriptions"; +import { useBoolInput, useNumberInput, useTextInput } from "../../../../lib/form"; +import FormWithData from "../../../../lib/form/form-with-data"; +import { DomainPermSub } from "../../../../lib/types/domain-permission"; +import MutationButton from "../../../../components/form/mutation-button"; +import { Checkbox, NumberInput, Select, TextInput } from "../../../../components/form/inputs"; +import useFormSubmit from "../../../../lib/form/submit"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import { urlValidator } from "../../../../lib/util/formvalidators"; + +export default function DomainPermissionSubscriptionDetail() { + const params = useParams(); + let id = params.permSubId as string | undefined; + if (!id) { + throw "no permSub ID"; + } + + return ( + <FormWithData + dataQuery={useGetDomainPermissionSubscriptionQuery} + queryArg={id} + DataForm={DomainPermSubForm} + /> + ); +} + +function DomainPermSubForm({ data: permSub }: { data: DomainPermSub }) { + const baseUrl = useBaseUrl(); + const backLocation: string = history.state?.backLocation ?? `~${baseUrl}/subscriptions/search`; + + return ( + <div className="domain-permission-subscription-details"> + <h1><BackButton to={backLocation} /> Domain Permission Subscription Detail</h1> + <DomainPermSubDetails permSub={permSub} /> + <UpdateDomainPermSub permSub={permSub} /> + <DeleteDomainPermSub permSub={permSub} backLocation={backLocation} /> + </div> + ); +} + +function DomainPermSubDetails({ permSub }: { permSub: DomainPermSub }) { + const [ location ] = useLocation(); + const baseUrl = useBaseUrl(); + + const permType = permSub.permission_type; + if (!permType) { + throw "permission_type was undefined"; + } + + const created = new Date(permSub.created_at).toDateString(); + let fetchedAtStr = "never"; + if (permSub.fetched_at) { + fetchedAtStr = new Date(permSub.fetched_at).toDateString(); + } + + let successfullyFetchedAtStr = "never"; + if (permSub.successfully_fetched_at) { + successfullyFetchedAtStr = new Date(permSub.successfully_fetched_at).toDateString(); + } + + return ( + <dl className="info-list"> + <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>ID</dt> + <dd className="monospace">{permSub.id}</dd> + </div> + <div className="info-list-entry"> + <dt>Created</dt> + <dd><time dateTime={permSub.created_at}>{created}</time></dd> + </div> + <div className="info-list-entry"> + <dt>Created By</dt> + <dd> + <UsernameLozenge + account={permSub.created_by} + linkTo={`~/settings/moderation/accounts/${permSub.created_by}`} + backLocation={`~${baseUrl}${location}`} + /> + </dd> + </div> + <div className="info-list-entry"> + <dt>Last fetch attempt:</dt> + <dd>{fetchedAtStr}</dd> + </div> + <div className="info-list-entry"> + <dt>Last successful fetch:</dt> + <dd>{successfullyFetchedAtStr}</dd> + </div> + <div className="info-list-entry"> + <dt>Discovered {permSub.permission_type}s:</dt> + <dd>{permSub.count}</dd> + </div> + </dl> + ); +} + +function UpdateDomainPermSub({ permSub }: { permSub: DomainPermSub }) { + const [ showPassword, setShowPassword ] = useState(false); + const form = { + priority: useNumberInput("priority", { source: permSub }), + uri: useTextInput("uri", { + source: permSub, + validator: urlValidator, + }), + content_type: useTextInput("content_type", { source: permSub }), + title: useTextInput("title", { source: permSub }), + as_draft: useBoolInput("as_draft", { source: permSub }), + adopt_orphans: useBoolInput("adopt_orphans", { source: permSub }), + useBasicAuth: useBoolInput("useBasicAuth", { + defaultValue: + (permSub.fetch_password !== undefined && permSub.fetch_password !== "") || + (permSub.fetch_username !== undefined && permSub.fetch_username !== ""), + nosubmit: true + }), + fetch_username: useTextInput("fetch_username", { + source: permSub + }), + fetch_password: useTextInput("fetch_password", { + source: permSub + }), + }; + + const [submitUpdate, updateResult] = useFormSubmit( + form, + useUpdateDomainPermissionSubscriptionMutation(), + { + changedOnly: true, + customizeMutationArgs: (mutationData) => { + // Clear username + password if they were set, + // but user has selected to not use basic auth. + if (!form.useBasicAuth.value) { + if (permSub.fetch_username !== undefined && permSub.fetch_username !== "") { + mutationData["fetch_username"] = ""; + } + if (permSub.fetch_password !== undefined && permSub.fetch_password !== "") { + mutationData["fetch_password"] = ""; + } + } + + // Remove useBasicAuth if included. + delete mutationData["useBasicAuth"]; + + // Modify mutation argument to + // include ID and permission type. + return { + id: permSub.id, + permType: permSub.permission_type, + formData: mutationData, + }; + }, + onFinish: res => { + // On a successful response that returns data, + // clear the fetch_username and fetch_password + // fields if they weren't set on the returned sub. + if (res.data) { + if (res.data.fetch_username === undefined || res.data.fetch_username === "") { + form.fetch_username.setter(""); + } + if (res.data.fetch_password === undefined || res.data.fetch_password === "") { + form.fetch_password.setter(""); + } + } + } + } + ); + + const submitDisabled = () => { + // If no basic auth, we don't care what + // fetch_password and fetch_username are. + if (!form.useBasicAuth.value) { + return false; + } + + // Either of fetch_password or fetch_username must be set. + return !(form.fetch_password.value || form.fetch_username.value); + }; + + return ( + <form + className="domain-permission-subscription-update" + onSubmit={submitUpdate} + // Prevent password managers + // trying to fill in fields. + autoComplete="off" + > + <h2>Edit Subscription</h2> + <TextInput + field={form.title} + label={`Subscription title`} + placeholder={`Some List of ${permSub.permission_type === "block" ? "Baddies" : "Goodies"}`} + autoCapitalize="words" + spellCheck="false" + /> + + <NumberInput + field={form.priority} + label={`Subscription priority (0-255)`} + type="number" + min="0" + max="255" + /> + + <TextInput + field={form.uri} + label={`Permission list URL (http or https)`} + placeholder="https://example.org/files/some_list_somewhere" + autoCapitalize="none" + spellCheck="false" + type="url" + /> + + <Select + field={form.content_type} + label="Content type" + options={ + <> + <option value="text/csv">CSV</option> + <option value="application/json">JSON</option> + <option value="text/plain">Plain</option> + </> + } + /> + + <Checkbox + label={ + <> + <>Use </> + <a + href="https://en.wikipedia.org/wiki/Basic_access_authentication" + target="_blank" + rel="noreferrer" + >basic auth</a> + <> when fetching</> + </> + } + field={form.useBasicAuth} + /> + + { form.useBasicAuth.value && + <> + <TextInput + field={form.fetch_username} + label={`Basic auth username`} + autoCapitalize="none" + spellCheck="false" + autoComplete="off" + required={form.useBasicAuth.value && !form.fetch_password.value} + /> + <div className="password-show-hide"> + <TextInput + field={form.fetch_password} + label={`Basic auth password`} + autoCapitalize="none" + spellCheck="false" + type={showPassword ? "" : "password"} + autoComplete="off" + required={form.useBasicAuth.value && !form.fetch_username.value} + /> + <button + className="password-show-hide-toggle" + type="button" + title={!showPassword ? "Show password" : "Hide password"} + onClick={e => { + e.preventDefault(); + setShowPassword(!showPassword); + }} + > + { !showPassword ? "Show" : "Hide" } + </button> + </div> + </> + } + + <Checkbox + label="Adopt orphan permissions" + field={form.adopt_orphans} + /> + + <Checkbox + label="Create permissions as drafts" + field={form.as_draft} + /> + + { !form.as_draft.value && + <div className="info"> + <i className="fa fa-fw fa-exclamation-circle" aria-hidden="true"></i> + <b> + Unchecking "create permissions as drafts" means that permissions found on the + subscribed list will be enforced immediately the next time the list is fetched. + <br/> + If you're subscribing to a block list, this means that blocks will be created + automatically from the given list, potentially severing any existing follow + relationships with accounts on the blocked domain. + <br/> + Before saving, make sure this is what you really want to do, and consider + creating domain excludes for domains that you want to manage manually. + </b> + </div> + } + + <MutationButton + label="Save" + result={updateResult} + disabled={submitDisabled()} + /> + + </form> + ); +} + +function DeleteDomainPermSub({ permSub, backLocation }: { permSub: DomainPermSub, backLocation: string }) { + const permType = permSub.permission_type; + if (!permType) { + throw "permission_type was undefined"; + } + + const [_location, setLocation] = useLocation(); + const [ removeSub, result ] = useRemoveDomainPermissionSubscriptionMutation(); + const removeChildren = useBoolInput("remove_children", { defaultValue: false }); + + return ( + <form className="domain-permission-subscription-remove"> + <h2>Remove Subscription</h2> + + <Checkbox + label={`Also remove any ${permType}s created by this subscription`} + field={removeChildren} + /> + + <MutationButton + label={`Remove`} + title={`Remove`} + type="button" + className="button danger" + onClick={(e) => { + e.preventDefault(); + const id = permSub.id; + const remove_children = removeChildren.value as boolean; + removeSub({ id, remove_children }).then(res => { + if ("data" in res) { + setLocation(backLocation); + } + }); + }} + disabled={false} + showError={true} + result={result} + /> + </form> + ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/index.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/index.tsx new file mode 100644 index 000000000..10f6fd1cf --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/index.tsx @@ -0,0 +1,170 @@ +/* + 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 { useLazySearchDomainPermissionSubscriptionsQuery } from "../../../../lib/query/admin/domain-permissions/subscriptions"; +import { DomainPermSub } from "../../../../lib/types/domain-permission"; +import { Select } from "../../../../components/form/inputs"; +import { DomainPermissionSubscriptionDocsLink, DomainPermissionSubscriptionHelpText, SubscriptionListEntry } from "./common"; + +export default function DomainPermissionSubscriptionsSearch() { + return ( + <div className="domain-permission-subscriptions-view"> + <div className="form-section-docs"> + <h1>Domain Permission Subscriptions</h1> + <p> + You can use the form below to search through domain permission + subscriptions, sorted by creation time (newer to older). + <br/> + <DomainPermissionSubscriptionHelpText /> + </p> + <DomainPermissionSubscriptionDocsLink /> + </div> + <DomainPermissionSubscriptionsSearchForm /> + </div> + ); +} + +function DomainPermissionSubscriptionsSearchForm() { + const [ location, setLocation ] = useLocation(); + const search = useSearch(); + const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); + const hasParams = urlQueryParams.size != 0; + const [ searchSubscriptions, searchRes ] = useLazySearchDomainPermissionSubscriptionsQuery(); + + const form = { + 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) { + searchSubscriptions(Object.fromEntries(urlQueryParams)); + } else { + setLocation(location + "?limit=20"); + } + }, [ + urlQueryParams, + hasParams, + searchSubscriptions, + 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(permSub: DomainPermSub): ReactNode { + return ( + <SubscriptionListEntry + key={permSub.id} + permSub={permSub} + linkTo={`/subscriptions/${permSub.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> + <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?.subs} + itemToEntry={itemToEntry} + isError={searchRes.isError} + error={searchRes.error} + emptyMessage={<b>No subscriptions found that match your query.</b>} + prevNextLinks={searchRes.data?.links} + /> + </> + ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/new.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/new.tsx new file mode 100644 index 000000000..e29e3d755 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/new.tsx @@ -0,0 +1,230 @@ +/* + 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, { useState } from "react"; +import useFormSubmit from "../../../../lib/form/submit"; +import { useCreateDomainPermissionSubscriptionMutation } from "../../../../lib/query/admin/domain-permissions/subscriptions"; +import { useBoolInput, useNumberInput, useTextInput } from "../../../../lib/form"; +import { urlValidator } from "../../../../lib/util/formvalidators"; +import MutationButton from "../../../../components/form/mutation-button"; +import { Checkbox, NumberInput, Select, TextInput } from "../../../../components/form/inputs"; +import { useLocation } from "wouter"; +import { DomainPermissionSubscriptionDocsLink, DomainPermissionSubscriptionHelpText } from "./common"; + +export default function DomainPermissionSubscriptionNew() { + const [ _location, setLocation ] = useLocation(); + + const useBasicAuth = useBoolInput("useBasicAuth", { defaultValue: false }); + const form = { + priority: useNumberInput("priority", { defaultValue: 0 }), + uri: useTextInput("uri", { + validator: urlValidator, + }), + content_type: useTextInput("content_type", { defaultValue: "text/csv" }), + permission_type: useTextInput("permission_type", { defaultValue: "block" }), + title: useTextInput("title"), + as_draft: useBoolInput("as_draft", { defaultValue: true }), + adopt_orphans: useBoolInput("adopt_orphans", { defaultValue: false }), + fetch_username: useTextInput("fetch_username", { + nosubmit: !useBasicAuth.value + }), + fetch_password: useTextInput("fetch_password", { + nosubmit: !useBasicAuth.value + }), + }; + + const [ showPassword, setShowPassword ] = useState(false); + + const [formSubmit, result] = useFormSubmit( + form, + useCreateDomainPermissionSubscriptionMutation(), + { + changedOnly: false, + onFinish: (res) => { + if (res.data) { + // Creation successful, + // redirect to subscription detail. + setLocation(`/subscriptions/${res.data.id}`); + } + }, + }); + + const submitDisabled = () => { + // URI required. + if (!form.uri.value || !form.uri.valid) { + return true; + } + + // If no basic auth, we don't care what + // fetch_password and fetch_username are. + if (!useBasicAuth.value) { + return false; + } + + // Either of fetch_password or fetch_username must be set. + return !(form.fetch_password.value || form.fetch_username.value); + }; + + return ( + <form + className="domain-permission-subscription-create" + onSubmit={formSubmit} + // Prevent password managers + // trying to fill in fields. + autoComplete="off" + > + <div className="form-section-docs"> + <h2>New Domain Permission Subscription</h2> + <p><DomainPermissionSubscriptionHelpText /></p> + <DomainPermissionSubscriptionDocsLink /> + </div> + + <TextInput + field={form.title} + label={`Subscription title`} + placeholder={`Some List of ${form.permission_type.value === "block" ? "Baddies" : "Goodies"}`} + autoCapitalize="words" + spellCheck="false" + /> + + <NumberInput + field={form.priority} + label={`Subscription priority (0-255)`} + type="number" + min="0" + max="255" + /> + + <Select + field={form.permission_type} + label="Permission type" + options={ + <> + <option value="block">Block</option> + <option value="allow">Allow</option> + </> + } + /> + + <TextInput + field={form.uri} + label={`Permission list URL (http or https)`} + placeholder="https://example.org/files/some_list_somewhere" + autoCapitalize="none" + spellCheck="false" + type="url" + /> + + <Select + field={form.content_type} + label="Content type" + options={ + <> + <option value="text/csv">CSV</option> + <option value="application/json">JSON</option> + <option value="text/plain">Plain</option> + </> + } + /> + + <Checkbox + label={ + <> + <>Use </> + <a + href="https://en.wikipedia.org/wiki/Basic_access_authentication" + target="_blank" + rel="noreferrer" + >basic auth</a> + <> when fetching</> + </> + } + field={useBasicAuth} + /> + + { useBasicAuth.value && + <> + <TextInput + field={form.fetch_username} + label={`Basic auth username`} + autoCapitalize="none" + spellCheck="false" + autoComplete="off" + required={useBasicAuth.value && !form.fetch_password.value} + /> + <div className="password-show-hide"> + <TextInput + field={form.fetch_password} + label={`Basic auth password`} + autoCapitalize="none" + spellCheck="false" + type={showPassword ? "" : "password"} + autoComplete="off" + required={useBasicAuth.value && !form.fetch_username.value} + /> + <button + className="password-show-hide-toggle" + type="button" + title={!showPassword ? "Show password" : "Hide password"} + onClick={e => { + e.preventDefault(); + setShowPassword(!showPassword); + }} + > + { !showPassword ? "Show" : "Hide" } + </button> + </div> + </> + } + + <Checkbox + label="Adopt orphan permissions" + field={form.adopt_orphans} + /> + + <Checkbox + label="Create permissions as drafts" + field={form.as_draft} + /> + + { !form.as_draft.value && + <div className="info"> + <i className="fa fa-fw fa-exclamation-circle" aria-hidden="true"></i> + <b> + Unchecking "create permissions as drafts" means that permissions found on the + subscribed list will be enforced immediately the next time the list is fetched. + <br/> + If you're subscribing to a block list, this means that blocks will be created + automatically from the given list, potentially severing any existing follow + relationships with accounts on the blocked domain. + <br/> + Before saving, make sure this is what you really want to do, and consider + creating domain excludes for domains that you want to manage manually. + </b> + </div> + } + + <MutationButton + label="Save" + result={result} + disabled={submitDisabled()} + /> + </form> + ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/preview.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/preview.tsx new file mode 100644 index 000000000..a23c18c9e --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/preview.tsx @@ -0,0 +1,100 @@ +/* + 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 } from "react"; + +import { useTextInput } from "../../../../lib/form"; +import { PageableList } from "../../../../components/pageable-list"; +import { useLocation } from "wouter"; +import { useGetDomainPermissionSubscriptionsPreviewQuery } from "../../../../lib/query/admin/domain-permissions/subscriptions"; +import { DomainPermSub } from "../../../../lib/types/domain-permission"; +import { Select } from "../../../../components/form/inputs"; +import { DomainPermissionSubscriptionDocsLink, SubscriptionListEntry } from "./common"; +import { PermType } from "../../../../lib/types/perm"; + +export default function DomainPermissionSubscriptionsPreview() { + return ( + <div className="domain-permission-subscriptions-preview"> + <div className="form-section-docs"> + <h1>Domain Permission Subscriptions Preview</h1> + <p> + You can use the form below to view through domain permission subscriptions sorted by priority (high to low). + <br/> + This reflects the order in which they will actually be fetched by your instance, with higher-priority subscriptions + creating permissions first, followed by lower-priority subscriptions. + </p> + <DomainPermissionSubscriptionDocsLink /> + </div> + <DomainPermissionSubscriptionsPreviewForm /> + </div> + ); +} + +function DomainPermissionSubscriptionsPreviewForm() { + const [ location, _setLocation ] = useLocation(); + + const permType = useTextInput("permission_type", { defaultValue: "block" }); + const { + data: permSubs, + isLoading, + isFetching, + isSuccess, + isError, + error, + } = useGetDomainPermissionSubscriptionsPreviewQuery(permType.value as PermType); + + // Function to map an item to a list entry. + function itemToEntry(permSub: DomainPermSub): ReactNode { + return ( + <SubscriptionListEntry + key={permSub.id} + permSub={permSub} + linkTo={`/subscriptions/${permSub.id}`} + backLocation={location} + /> + ); + } + + return ( + <> + <form> + <Select + field={permType} + label="Permission type" + options={ + <> + <option value="block">Block</option> + <option value="allow">Allow</option> + </> + } + ></Select> + </form> + <PageableList + isLoading={isLoading} + isFetching={isFetching} + isSuccess={isSuccess} + items={permSubs} + itemToEntry={itemToEntry} + isError={isError} + error={error} + emptyMessage={<b>No {permType.value}list subscriptions found.</b>} + /> + </> + ); +} diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 7ac6f9327..17b2f18e0 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -150,6 +150,28 @@ function ModerationDomainPermsMenu() { icon="fa-plus" /> </MenuItem> + <MenuItem + name="Subscriptions" + itemUrl="subscriptions" + defaultChild="search" + icon="fa-cloud-download" + > + <MenuItem + name="Search" + itemUrl="search" + icon="fa-list" + /> + <MenuItem + name="New subscription" + itemUrl="new" + icon="fa-plus" + /> + <MenuItem + name="Preview" + itemUrl="preview" + icon="fa-eye" + /> + </MenuItem> </MenuItem> ); } diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index 779498ffe..90214188f 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -35,6 +35,10 @@ 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"; +import DomainPermissionSubscriptionsSearch from "./domain-permissions/subscriptions"; +import DomainPermissionSubscriptionNew from "./domain-permissions/subscriptions/new"; +import DomainPermissionSubscriptionDetail from "./domain-permissions/subscriptions/detail"; +import DomainPermissionSubscriptionsPreview from "./domain-permissions/subscriptions/preview"; /* EXPORTED COMPONENTS @@ -151,6 +155,10 @@ function ModerationDomainPermsRouter() { <Route path="/excludes/search" component={DomainPermissionExcludesSearch} /> <Route path="/excludes/new" component={DomainPermissionExcludeNew} /> <Route path="/excludes/:excludeId" component={DomainPermissionExcludeDetail} /> + <Route path="/subscriptions/search" component={DomainPermissionSubscriptionsSearch} /> + <Route path="/subscriptions/new" component={DomainPermissionSubscriptionNew} /> + <Route path="/subscriptions/preview" component={DomainPermissionSubscriptionsPreview} /> + <Route path="/subscriptions/:permSubId" component={DomainPermissionSubscriptionDetail} /> <Route path="/:permType" component={DomainPermissionsOverview} /> <Route path="/:permType/:domain" component={DomainPermDetail} /> <Route><Redirect to="/blocks"/></Route> |