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