summaryrefslogtreecommitdiff
path: root/web/source
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-11-21 14:09:58 +0100
committerLibravatar GitHub <noreply@github.com>2024-11-21 13:09:58 +0000
commit301543616b5376585a7caff097499421acdf1806 (patch)
tree4cac6aea2c33687b1339fc3bc18e6eb64def6f9a /web/source
parent[feature] Allow emoji shortcode to be 1-character length (#3556) (diff)
downloadgotosocial-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')
-rw-r--r--web/source/settings/components/error.tsx6
-rw-r--r--web/source/settings/components/username-lozenge.tsx (renamed from web/source/settings/components/username.tsx)95
-rw-r--r--web/source/settings/lib/navigation/menu.tsx19
-rw-r--r--web/source/settings/lib/query/admin/domain-permissions/drafts.ts173
-rw-r--r--web/source/settings/lib/query/admin/domain-permissions/excludes.ts124
-rw-r--r--web/source/settings/lib/query/admin/domain-permissions/get.ts6
-rw-r--r--web/source/settings/lib/query/admin/domain-permissions/import.ts4
-rw-r--r--web/source/settings/lib/query/gts-api.ts2
-rw-r--r--web/source/settings/lib/types/domain-permission.ts141
-rw-r--r--web/source/settings/lib/util/formvalidators.ts48
-rw-r--r--web/source/settings/lib/util/index.ts13
-rw-r--r--web/source/settings/style.css95
-rw-r--r--web/source/settings/views/admin/actions/keys/expireremote.tsx25
-rw-r--r--web/source/settings/views/admin/http-header-permissions/detail.tsx70
-rw-r--r--web/source/settings/views/admin/http-header-permissions/overview.tsx5
-rw-r--r--web/source/settings/views/moderation/accounts/pending/index.tsx4
-rw-r--r--web/source/settings/views/moderation/accounts/search/index.tsx29
-rw-r--r--web/source/settings/views/moderation/domain-permissions/detail.tsx118
-rw-r--r--web/source/settings/views/moderation/domain-permissions/drafts/common.tsx43
-rw-r--r--web/source/settings/views/moderation/domain-permissions/drafts/detail.tsx210
-rw-r--r--web/source/settings/views/moderation/domain-permissions/drafts/index.tsx293
-rw-r--r--web/source/settings/views/moderation/domain-permissions/drafts/new.tsx119
-rw-r--r--web/source/settings/views/moderation/domain-permissions/excludes/common.tsx54
-rw-r--r--web/source/settings/views/moderation/domain-permissions/excludes/detail.tsx119
-rw-r--r--web/source/settings/views/moderation/domain-permissions/excludes/index.tsx235
-rw-r--r--web/source/settings/views/moderation/domain-permissions/excludes/new.tsx90
-rw-r--r--web/source/settings/views/moderation/domain-permissions/overview.tsx5
-rw-r--r--web/source/settings/views/moderation/menu.tsx34
-rw-r--r--web/source/settings/views/moderation/reports/detail.tsx8
-rw-r--r--web/source/settings/views/moderation/reports/search.tsx6
-rw-r--r--web/source/settings/views/moderation/router.tsx12
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>