diff options
Diffstat (limited to 'web/source/settings/views')
19 files changed, 1301 insertions, 178 deletions
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> |