summaryrefslogtreecommitdiff
path: root/web/source/settings/views/moderation
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-04-24 12:12:47 +0200
committerLibravatar GitHub <noreply@github.com>2024-04-24 11:12:47 +0100
commit7a1e6394831fb07e303c5ed0900dfe1ea4820de5 (patch)
treebcd526463b19a85fbe821dcad2276da401daec18 /web/source/settings/views/moderation
parent[chore]: Bump codeberg.org/gruf/go-mutexes from 1.4.0 to 1.4.1 (#2860) (diff)
downloadgotosocial-7a1e6394831fb07e303c5ed0900dfe1ea4820de5.tar.xz
[chore] Refactor settings panel routing (and other fixes) (#2864)
Diffstat (limited to 'web/source/settings/views/moderation')
-rw-r--r--web/source/settings/views/moderation/accounts/detail/actions.tsx89
-rw-r--r--web/source/settings/views/moderation/accounts/detail/handlesignup.tsx118
-rw-r--r--web/source/settings/views/moderation/accounts/detail/index.tsx167
-rw-r--r--web/source/settings/views/moderation/accounts/index.tsx35
-rw-r--r--web/source/settings/views/moderation/accounts/pending/index.tsx40
-rw-r--r--web/source/settings/views/moderation/accounts/search/index.tsx131
-rw-r--r--web/source/settings/views/moderation/domain-permissions/detail.tsx262
-rw-r--r--web/source/settings/views/moderation/domain-permissions/export-format-table.tsx65
-rw-r--r--web/source/settings/views/moderation/domain-permissions/form.tsx153
-rw-r--r--web/source/settings/views/moderation/domain-permissions/import-export.tsx88
-rw-r--r--web/source/settings/views/moderation/domain-permissions/overview.tsx197
-rw-r--r--web/source/settings/views/moderation/domain-permissions/process.tsx400
-rw-r--r--web/source/settings/views/moderation/reports/detail.tsx243
-rw-r--r--web/source/settings/views/moderation/reports/overview.tsx99
-rw-r--r--web/source/settings/views/moderation/reports/username.tsx54
-rw-r--r--web/source/settings/views/moderation/routes.tsx201
16 files changed, 2342 insertions, 0 deletions
diff --git a/web/source/settings/views/moderation/accounts/detail/actions.tsx b/web/source/settings/views/moderation/accounts/detail/actions.tsx
new file mode 100644
index 000000000..74c5371f1
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/detail/actions.tsx
@@ -0,0 +1,89 @@
+/*
+ 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 { useActionAccountMutation } from "../../../../lib/query";
+
+import MutationButton from "../../../../components/form/mutation-button";
+
+import useFormSubmit from "../../../../lib/form/submit";
+import {
+ useValue,
+ useTextInput,
+ useBoolInput,
+} from "../../../../lib/form";
+
+import { Checkbox, TextInput } from "../../../../components/form/inputs";
+import { AdminAccount } from "../../../../lib/types/account";
+
+export interface AccountActionsProps {
+ account: AdminAccount,
+}
+
+export function AccountActions({ account }: AccountActionsProps) {
+ const form = {
+ id: useValue("id", account.id),
+ reason: useTextInput("text")
+ };
+
+ const reallySuspend = useBoolInput("reallySuspend");
+ const [accountAction, result] = useFormSubmit(form, useActionAccountMutation());
+
+ return (
+ <form
+ onSubmit={accountAction}
+ aria-labelledby="account-moderation-actions"
+ >
+ <h3 id="account-moderation-actions">Account Moderation Actions</h3>
+ <div>
+ Currently only the "suspend" action is implemented.<br/>
+ Suspending an account will delete it from your server, and remove all of its media, posts, relationships, etc.<br/>
+ If the suspended account is local, suspending will also send out a "delete" message to other servers, requesting them to remove its data from their instance as well.<br/>
+ <b>Account suspension cannot be reversed.</b>
+ </div>
+ <TextInput
+ field={form.reason}
+ placeholder="Reason for this action"
+ />
+ <div className="action-buttons">
+ {/* <MutationButton
+ label="Disable"
+ name="disable"
+ result={result}
+ />
+ <MutationButton
+ label="Silence"
+ name="silence"
+ result={result}
+ /> */}
+ <MutationButton
+ disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false}
+ label="Suspend"
+ name="suspend"
+ result={result}
+ />
+ <Checkbox
+ label="Really suspend"
+ field={reallySuspend}
+ ></Checkbox>
+ </div>
+ </form>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx b/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx
new file mode 100644
index 000000000..5655421ea
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx
@@ -0,0 +1,118 @@
+/*
+ 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 } from "wouter";
+
+import { useHandleSignupMutation } from "../../../../lib/query";
+
+import MutationButton from "../../../../components/form/mutation-button";
+
+import useFormSubmit from "../../../../lib/form/submit";
+import {
+ useValue,
+ useTextInput,
+ useBoolInput,
+} from "../../../../lib/form";
+
+import { Checkbox, Select, TextInput } from "../../../../components/form/inputs";
+import { AdminAccount } from "../../../../lib/types/account";
+
+export interface HandleSignupProps {
+ account: AdminAccount,
+ backLocation: string,
+}
+
+export function HandleSignup({account, backLocation}: HandleSignupProps) {
+ const form = {
+ id: useValue("id", account.id),
+ approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }),
+ privateComment: useTextInput("private_comment"),
+ message: useTextInput("message"),
+ sendEmail: useBoolInput("send_email"),
+ };
+
+ const [_location, setLocation] = useLocation();
+
+ const [handleSignup, result] = useFormSubmit(form, useHandleSignupMutation(), {
+ changedOnly: false,
+ // After submitting the form, redirect back to
+ // /settings/admin/accounts if rejecting, since
+ // account will no longer be available at
+ // /settings/admin/accounts/:accountID endpoint.
+ onFinish: (res) => {
+ if (form.approveOrReject.value === "approve") {
+ // An approve request:
+ // stay on this page and
+ // serve updated details.
+ return;
+ }
+
+ if (res.data) {
+ // "reject" successful,
+ // redirect to accounts page.
+ setLocation(backLocation);
+ }
+ }
+ });
+
+ return (
+ <form
+ onSubmit={handleSignup}
+ aria-labelledby="account-handle-signup"
+ >
+ <h3 id="account-handle-signup">Handle Account Sign-Up</h3>
+ <Select
+ field={form.approveOrReject}
+ label="Approve or Reject"
+ options={
+ <>
+ <option value="approve">Approve</option>
+ <option value="reject">Reject</option>
+ </>
+ }
+ >
+ </Select>
+ { form.approveOrReject.value === "reject" &&
+ // Only show form fields relevant
+ // to "reject" if rejecting.
+ // On "approve" these fields will
+ // be ignored anyway.
+ <>
+ <TextInput
+ field={form.privateComment}
+ label="(Optional) private comment on why sign-up was rejected (shown to other admins only)"
+ />
+ <Checkbox
+ field={form.sendEmail}
+ label="Send email to applicant"
+ />
+ <TextInput
+ field={form.message}
+ label={"(Optional) message to include in email to applicant, if send email is checked"}
+ />
+ </> }
+ <MutationButton
+ disabled={false}
+ label={form.approveOrReject.value === "approve" ? "Approve" : "Reject"}
+ result={result}
+ />
+ </form>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/detail/index.tsx b/web/source/settings/views/moderation/accounts/detail/index.tsx
new file mode 100644
index 000000000..f507391d3
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/detail/index.tsx
@@ -0,0 +1,167 @@
+/*
+ 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 { useGetAccountQuery } from "../../../../lib/query";
+
+import FormWithData from "../../../../lib/form/form-with-data";
+
+import FakeProfile from "../../../../components/fake-profile";
+
+import { AdminAccount } from "../../../../lib/types/account";
+import { HandleSignup } from "./handlesignup";
+import { AccountActions } from "./actions";
+import { useParams } from "wouter";
+
+export default function AccountDetail() {
+ const params: { accountID: string } = useParams();
+
+ return (
+ <div className="account-detail">
+ <h1>Account Details</h1>
+ <FormWithData
+ dataQuery={useGetAccountQuery}
+ queryArg={params.accountID}
+ DataForm={AccountDetailForm}
+ />
+ </div>
+ );
+}
+
+interface AccountDetailFormProps {
+ backLocation: string,
+ data: AdminAccount,
+}
+
+function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
+ let yesOrNo = (b: boolean) => {
+ return b ? "yes" : "no";
+ };
+
+ let created = new Date(adminAcct.created_at).toDateString();
+ let lastPosted = "never";
+ if (adminAcct.account.last_status_at) {
+ lastPosted = new Date(adminAcct.account.last_status_at).toDateString();
+ }
+ const local = !adminAcct.domain;
+
+ return (
+ <>
+ <FakeProfile {...adminAcct.account} />
+ <h3>General Account Details</h3>
+ { adminAcct.suspended &&
+ <div className="info">
+ <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
+ <b>Account is suspended.</b>
+ </div>
+ }
+ <dl className="info-list">
+ { !local &&
+ <div className="info-list-entry">
+ <dt>Domain</dt>
+ <dd>{adminAcct.domain}</dd>
+ </div>}
+ <div className="info-list-entry">
+ <dt>Created</dt>
+ <dd><time dateTime={adminAcct.created_at}>{created}</time></dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Last posted</dt>
+ <dd>{lastPosted}</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Suspended</dt>
+ <dd>{yesOrNo(adminAcct.suspended)}</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Silenced</dt>
+ <dd>{yesOrNo(adminAcct.silenced)}</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Statuses</dt>
+ <dd>{adminAcct.account.statuses_count}</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Followers</dt>
+ <dd>{adminAcct.account.followers_count}</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Following</dt>
+ <dd>{adminAcct.account.following_count}</dd>
+ </div>
+ </dl>
+ { local &&
+ // Only show local account details
+ // if this is a local account!
+ <>
+ <h3>Local Account Details</h3>
+ { !adminAcct.approved &&
+ <div className="info">
+ <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
+ <b>Account is pending.</b>
+ </div>
+ }
+ { !adminAcct.confirmed &&
+ <div className="info">
+ <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
+ <b>Account email not yet confirmed.</b>
+ </div>
+ }
+ <dl className="info-list">
+ <div className="info-list-entry">
+ <dt>Email</dt>
+ <dd>{adminAcct.email} {<b>{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"}</b> }</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Disabled</dt>
+ <dd>{yesOrNo(adminAcct.disabled)}</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Approved</dt>
+ <dd>{yesOrNo(adminAcct.approved)}</dd>
+ </div>
+ <div className="info-list-entry">
+ <dt>Sign-Up Reason</dt>
+ <dd>{adminAcct.invite_request ?? <i>none provided</i>}</dd>
+ </div>
+ { (adminAcct.ip && adminAcct.ip !== "0.0.0.0") &&
+ <div className="info-list-entry">
+ <dt>Sign-Up IP</dt>
+ <dd>{adminAcct.ip}</dd>
+ </div> }
+ { adminAcct.locale &&
+ <div className="info-list-entry">
+ <dt>Locale</dt>
+ <dd>{adminAcct.locale}</dd>
+ </div> }
+ </dl>
+ </> }
+ { local && !adminAcct.approved
+ ?
+ <HandleSignup
+ account={adminAcct}
+ backLocation={backLocation}
+ />
+ :
+ <AccountActions account={adminAcct} />
+ }
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/index.tsx b/web/source/settings/views/moderation/accounts/index.tsx
new file mode 100644
index 000000000..79ba2c674
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/index.tsx
@@ -0,0 +1,35 @@
+/*
+ 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 { AccountSearchForm } from "./search";
+
+export default function AccountsOverview({ }) {
+ return (
+ <div className="accounts-view">
+ <h1>Accounts Overview</h1>
+ <span>
+ You can perform actions on an account by clicking
+ its name in a report, or by searching for the account
+ using the form below and clicking on its name.
+ </span>
+ <AccountSearchForm />
+ </div>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx
new file mode 100644
index 000000000..96b7796e5
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/pending/index.tsx
@@ -0,0 +1,40 @@
+/*
+ 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 { useSearchAccountsQuery } from "../../../../lib/query";
+import { AccountList } from "../../../../components/account-list";
+
+export default function AccountsPending() {
+ const searchRes = useSearchAccountsQuery({status: "pending"});
+
+ return (
+ <div className="accounts-view">
+ <h1>Pending Accounts</h1>
+ <AccountList
+ isLoading={searchRes.isLoading}
+ isSuccess={searchRes.isSuccess}
+ data={searchRes.data}
+ isError={searchRes.isError}
+ error={searchRes.error}
+ emptyMessage="No pending account sign-ups."
+ />
+ </div>
+ );
+}
diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx
new file mode 100644
index 000000000..7d5515a43
--- /dev/null
+++ b/web/source/settings/views/moderation/accounts/search/index.tsx
@@ -0,0 +1,131 @@
+/*
+ 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 { useLazySearchAccountsQuery } from "../../../../lib/query";
+import { useTextInput } from "../../../../lib/form";
+
+import { AccountList } from "../../../../components/account-list";
+import { SearchAccountParams } from "../../../../lib/types/account";
+import { Select, TextInput } from "../../../../components/form/inputs";
+import MutationButton from "../../../../components/form/mutation-button";
+
+export function AccountSearchForm() {
+ const form = {
+ origin: useTextInput("origin"),
+ status: useTextInput("status"),
+ permissions: useTextInput("permissions"),
+ username: useTextInput("username"),
+ display_name: useTextInput("display_name"),
+ by_domain: useTextInput("by_domain"),
+ email: useTextInput("email"),
+ ip: useTextInput("ip"),
+ };
+
+ function submitSearch(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) {
+ return null;
+ }
+ return [[k, v.value]];
+ }).flatMap(kv => {
+ // Remove any nulls.
+ return kv || [];
+ });
+ const params: SearchAccountParams = Object.fromEntries(entries);
+ searchAcct(params);
+ }
+
+ const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
+
+ return (
+ <>
+ <form
+ onSubmit={submitSearch}
+ // Prevent password managers trying
+ // to fill in username/email fields.
+ autoComplete="off"
+ >
+ <TextInput
+ field={form.username}
+ label={"(Optional) username (without leading '@' symbol)"}
+ placeholder="someone"
+ />
+ <TextInput
+ field={form.by_domain}
+ label={"(Optional) domain"}
+ placeholder="example.org"
+ />
+ <Select
+ field={form.origin}
+ label="Account origin"
+ options={
+ <>
+ <option value="">Local or remote</option>
+ <option value="local">Local only</option>
+ <option value="remote">Remote only</option>
+ </>
+ }
+ ></Select>
+ <TextInput
+ field={form.email}
+ label={"(Optional) email address (local accounts only)"}
+ placeholder={"someone@example.org"}
+ // Get email validation for free.
+ {...{type: "email"}}
+ />
+ <TextInput
+ field={form.ip}
+ label={"(Optional) IP address (local accounts only)"}
+ placeholder={"198.51.100.0"}
+ />
+ <Select
+ field={form.status}
+ label="Account status"
+ options={
+ <>
+ <option value="">Any</option>
+ <option value="pending">Pending only</option>
+ <option value="disabled">Disabled only</option>
+ <option value="suspended">Suspended only</option>
+ </>
+ }
+ ></Select>
+ <MutationButton
+ disabled={false}
+ label={"Search"}
+ result={searchRes}
+ />
+ </form>
+ <AccountList
+ isLoading={searchRes.isLoading}
+ isSuccess={searchRes.isSuccess}
+ data={searchRes.data}
+ isError={searchRes.isError}
+ error={searchRes.error}
+ emptyMessage="No accounts found that match your query"
+ />
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/detail.tsx b/web/source/settings/views/moderation/domain-permissions/detail.tsx
new file mode 100644
index 000000000..b9d439aee
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/detail.tsx
@@ -0,0 +1,262 @@
+/*
+ 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 { useMemo } from "react";
+import { useLocation, useParams, useSearch } from "wouter";
+
+import { useTextInput, useBoolInput } from "../../../lib/form";
+
+import useFormSubmit from "../../../lib/form/submit";
+
+import { TextInput, Checkbox, TextArea } from "../../../components/form/inputs";
+
+import Loading from "../../../components/loading";
+import BackButton from "../../../components/back-button";
+import MutationButton from "../../../components/form/mutation-button";
+
+import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
+import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update";
+import { DomainPerm, PermType } from "../../../lib/types/domain-permission";
+import { NoArg } from "../../../lib/types/query";
+import { Error } from "../../../components/error";
+import { useBaseUrl } from "../../../lib/navigation/util";
+
+export default function DomainPermDetail() {
+ const baseUrl = useBaseUrl();
+
+ // Parse perm type from routing params.
+ let params = useParams();
+ if (params.permType !== "blocks" && params.permType !== "allows") {
+ throw "unrecognized perm type " + params.permType;
+ }
+ const permType = params.permType.slice(0, -1) as PermType;
+
+ 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";
+ }
+
+ // 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);
+ const searchDomain = searchParams.get("domain");
+ if (!searchDomain) {
+ throw "empty view domain";
+ }
+
+ domain = searchDomain;
+ }
+
+ // 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]);
+
+ 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>;
+ } 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>
+ </div>
+ );
+ }
+
+ return (
+ <div>
+ <h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1>
+ {infoContent}
+ <DomainPermForm
+ defaultDomain={domain}
+ perm={existingPerm}
+ permType={permType}
+ />
+ </div>
+ );
+}
+
+interface DomainPermFormProps {
+ defaultDomain: string;
+ perm?: DomainPerm;
+ permType: PermType;
+}
+
+function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps) {
+ const isExistingPerm = perm !== undefined;
+ const disabledForm = isExistingPerm
+ ? {
+ disabled: true,
+ title: "Domain permissions currently cannot be edited."
+ }
+ : {
+ disabled: false,
+ title: "",
+ };
+
+ const form = {
+ domain: useTextInput("domain", { source: perm, defaultValue: defaultDomain }),
+ obfuscate: useBoolInput("obfuscate", { source: perm }),
+ commentPrivate: useTextInput("private_comment", { source: perm }),
+ commentPublic: useTextInput("public_comment", { source: perm })
+ };
+
+ // Check which perm type we're meant to be handling
+ // here, and use appropriate mutations and results.
+ // We can't call these hooks conditionally because
+ // react is like "weh" (mood), but we can decide
+ // which ones to use conditionally.
+ const [ addBlock, addBlockResult ] = useAddDomainBlockMutation();
+ const [ removeBlock, removeBlockResult] = useRemoveDomainBlockMutation({ fixedCacheKey: perm?.id });
+ const [ addAllow, addAllowResult ] = useAddDomainAllowMutation();
+ const [ removeAllow, removeAllowResult ] = useRemoveDomainAllowMutation({ fixedCacheKey: perm?.id });
+
+ const [
+ addTrigger,
+ addResult,
+ removeTrigger,
+ removeResult,
+ ] = useMemo(() => {
+ return permType == "block"
+ ? [
+ addBlock,
+ addBlockResult,
+ removeBlock,
+ removeBlockResult,
+ ]
+ : [
+ addAllow,
+ addAllowResult,
+ removeAllow,
+ removeAllowResult,
+ ];
+ }, [permType,
+ addBlock, addBlockResult, removeBlock, removeBlockResult,
+ addAllow, addAllowResult, removeAllow, removeAllowResult,
+ ]);
+
+ // Use appropriate submission params for this permType.
+ 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 [location, setLocation] = useLocation();
+
+ function verifyUrlThenSubmit(e) {
+ // Adding a new domain permissions happens on a url like
+ // "/settings/admin/domain-permissions/:permType/domain.com",
+ // but if domain input changes, that doesn't match anymore
+ // and causes issues later on so, before submitting the form,
+ // silently change url, and THEN submit.
+ let correctUrl = `/${permType}s/${form.domain.value}`;
+ if (location != correctUrl) {
+ setLocation(correctUrl);
+ }
+ return submitForm(e);
+ }
+
+ return (
+ <form onSubmit={verifyUrlThenSubmit}>
+ <TextInput
+ field={form.domain}
+ label="Domain"
+ placeholder="example.com"
+ {...disabledForm}
+ />
+
+ <Checkbox
+ field={form.obfuscate}
+ label="Obfuscate domain in public lists"
+ {...disabledForm}
+ />
+
+ <TextArea
+ field={form.commentPrivate}
+ label="Private comment"
+ rows={3}
+ {...disabledForm}
+ />
+
+ <TextArea
+ field={form.commentPublic}
+ label="Public comment"
+ rows={3}
+ {...disabledForm}
+ />
+
+ <div className="action-buttons row">
+ <MutationButton
+ label={permTypeUpper}
+ result={submitFormResult}
+ showError={false}
+ {...disabledForm}
+ />
+
+ {
+ isExistingPerm &&
+ <MutationButton
+ type="button"
+ onClick={() => removeTrigger(perm.id?? "")}
+ label="Remove"
+ result={removeResult}
+ className="button danger"
+ showError={false}
+ disabled={!isExistingPerm}
+ />
+ }
+ </div>
+
+ <>
+ {addResult.error && <Error error={addResult.error} />}
+ {removeResult.error && <Error error={removeResult.error} />}
+ </>
+
+ </form>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/export-format-table.tsx b/web/source/settings/views/moderation/domain-permissions/export-format-table.tsx
new file mode 100644
index 000000000..8971fdca8
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/export-format-table.tsx
@@ -0,0 +1,65 @@
+/*
+ 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 default function ExportFormatTable() {
+ return (
+ <div className="export-format-table-wrapper">
+ <table className="export-format-table">
+ <thead>
+ <tr>
+ <th rowSpan={2} />
+ <th colSpan={2}>Includes</th>
+ <th colSpan={2}>Importable by</th>
+ </tr>
+ <tr>
+ <th>Domain</th>
+ <th>Public comment</th>
+ <th>GoToSocial</th>
+ <th>Mastodon</th>
+ </tr>
+ </thead>
+ <tbody>
+ <Format name="Text" info={[true, false, true, false]} />
+ <Format name="JSON" info={[true, true, true, false]} />
+ <Format name="CSV" info={[true, true, true, true]} />
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function Format({ name, info }) {
+ return (
+ <tr>
+ <td><b>{name}</b></td>
+ {info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)}
+ </tr>
+ );
+}
+
+function bool(val) {
+ return (
+ <>
+ <i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i>
+ <span className="sr-only">{val ? "Yes" : "No"}</span>
+ </>
+ );
+} \ No newline at end of file
diff --git a/web/source/settings/views/moderation/domain-permissions/form.tsx b/web/source/settings/views/moderation/domain-permissions/form.tsx
new file mode 100644
index 000000000..ba0808873
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/form.tsx
@@ -0,0 +1,153 @@
+/*
+ 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 { useEffect } from "react";
+
+import { useExportDomainListMutation } from "../../../lib/query/admin/domain-permissions/export";
+import useFormSubmit from "../../../lib/form/submit";
+
+import {
+ RadioGroup,
+ TextArea,
+ Select,
+} from "../../../components/form/inputs";
+
+import MutationButton from "../../../components/form/mutation-button";
+
+import { Error } from "../../../components/error";
+import ExportFormatTable from "./export-format-table";
+
+import type {
+ FormSubmitFunction,
+ FormSubmitResult,
+ RadioFormInputHook,
+ TextFormInputHook,
+} from "../../../lib/form/types";
+
+export interface ImportExportFormProps {
+ form: {
+ domains: TextFormInputHook;
+ exportType: TextFormInputHook;
+ permType: RadioFormInputHook;
+ };
+ submitParse: FormSubmitFunction;
+ parseResult: FormSubmitResult;
+}
+
+export default function ImportExportForm({ form, submitParse, parseResult }: ImportExportFormProps) {
+ const [submitExport, exportResult] = useFormSubmit(form, useExportDomainListMutation());
+
+ function fileChanged(e) {
+ const reader = new FileReader();
+ reader.onload = function (read) {
+ const res = read.target?.result;
+ if (typeof res === "string") {
+ form.domains.value = res;
+ submitParse();
+ }
+ };
+ reader.readAsText(e.target.files[0]);
+ }
+
+ useEffect(() => {
+ if (exportResult.isSuccess) {
+ form.domains.setter(exportResult.data);
+ }
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, [exportResult]);
+
+ return (
+ <>
+ <h1>Import / Export domain permissions</h1>
+ <p>This page can be used to import and export lists of domain permissions.</p>
+ <p>Exports can be done in various formats, with varying functionality and support in other software.</p>
+ <p>Imports will automatically detect what format is being processed.</p>
+ <ExportFormatTable />
+ <div className="import-export">
+ <TextArea
+ field={form.domains}
+ label="Domains"
+ placeholder={`google.com\nfacebook.com`}
+ rows={8}
+ />
+
+ <RadioGroup
+ field={form.permType}
+ />
+
+ <div className="button-grid">
+ <MutationButton
+ label="Import"
+ type="button"
+ onClick={() => submitParse()}
+ result={parseResult}
+ showError={false}
+ disabled={form.permType.value === undefined || form.permType.value.length === 0}
+ />
+ <label className={`button with-icon${form.permType.value === undefined || form.permType.value.length === 0 ? " disabled" : ""}`}>
+ <i className="fa fa-fw " aria-hidden="true" />
+ Import file
+ <input
+ type="file"
+ className="hidden"
+ onChange={fileChanged}
+ accept="application/json,text/plain,text/csv"
+ disabled={form.permType.value === undefined || form.permType.value.length === 0}
+ />
+ </label>
+ <b /> {/* grid filler */}
+ <MutationButton
+ label="Export"
+ type="button"
+ onClick={() => submitExport("export")}
+ result={exportResult} showError={false}
+ disabled={form.permType.value === undefined || form.permType.value.length === 0}
+ />
+ <MutationButton
+ label="Export to file"
+ wrapperClassName="export-file-button"
+ type="button"
+ onClick={() => submitExport("export-file")}
+ result={exportResult}
+ showError={false}
+ disabled={form.permType.value === undefined || form.permType.value.length === 0}
+ />
+ <div className="export-file">
+ <span>
+ as
+ </span>
+ <Select
+ field={form.exportType}
+ options={<>
+ <option value="plain">Text</option>
+ <option value="json">JSON</option>
+ <option value="csv">CSV</option>
+ </>}
+ />
+ </div>
+ </div>
+
+ {parseResult.error && <Error error={parseResult.error} />}
+ {exportResult.error && <Error error={exportResult.error} />}
+ </div>
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/import-export.tsx b/web/source/settings/views/moderation/domain-permissions/import-export.tsx
new file mode 100644
index 000000000..89f385107
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/import-export.tsx
@@ -0,0 +1,88 @@
+/*
+ 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 { Switch, Route, Redirect, useLocation } from "wouter";
+import { useProcessDomainPermissionsMutation } from "../../../lib/query/admin/domain-permissions/process";
+import { useTextInput, useRadioInput } from "../../../lib/form";
+import useFormSubmit from "../../../lib/form/submit";
+import { ProcessImport } from "./process";
+import ImportExportForm from "./form";
+
+export default function ImportExport() {
+ const form = {
+ domains: useTextInput("domains"),
+ exportType: useTextInput("exportType", {
+ defaultValue: "plain",
+ dontReset: true,
+ }),
+ permType: useRadioInput("permType", {
+ options: {
+ block: "Domain blocks",
+ allow: "Domain allows",
+ }
+ })
+ };
+
+ const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false });
+ const [_location, setLocation] = useLocation();
+
+ return (
+ <Switch>
+ <Route path={"/process"}>
+ {
+ parseResult.isSuccess
+ ? (
+ <>
+ <h1>
+ <span
+ className="button"
+ onClick={() => {
+ parseResult.reset();
+ setLocation("");
+ }}
+ >
+ &lt; back
+ </span>
+ &nbsp; Confirm import of domain {form.permType.value}s:
+ </h1>
+ <ProcessImport
+ list={parseResult.data}
+ permType={form.permType}
+ />
+ </>
+ )
+ : <Redirect to={""} />
+ }
+ </Route>
+ <Route>
+ {
+ parseResult.isSuccess
+ ? <Redirect to={"/process"} />
+ : <ImportExportForm
+ form={form}
+ submitParse={submitParse}
+ parseResult={parseResult}
+ />
+ }
+ </Route>
+ </Switch>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/overview.tsx b/web/source/settings/views/moderation/domain-permissions/overview.tsx
new file mode 100644
index 000000000..d2bb77087
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/overview.tsx
@@ -0,0 +1,197 @@
+/*
+ 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 { useMemo } from "react";
+import { Link, useLocation, useParams } from "wouter";
+import { matchSorter } from "match-sorter";
+
+import { useTextInput } from "../../../lib/form";
+
+import { TextInput } from "../../../components/form/inputs";
+
+import Loading from "../../../components/loading";
+import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
+import type { MappedDomainPerms, PermType } from "../../../lib/types/domain-permission";
+import { NoArg } from "../../../lib/types/query";
+
+export default function DomainPermissionsOverview() {
+ // Parse perm type from routing params.
+ let params = useParams();
+ if (params.permType !== "blocks" && params.permType !== "allows") {
+ throw "unrecognized perm type " + params.permType;
+ }
+ 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]);
+
+ // Fetch / wait for desired perms to load.
+ const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
+ const { data: allows, isLoading: isLoadingAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
+
+ let data: MappedDomainPerms | undefined;
+ let isLoading: boolean;
+
+ if (permType == "block") {
+ data = blocks;
+ isLoading = isLoadingBlocks;
+ } else {
+ data = allows;
+ isLoading = isLoadingAllows;
+ }
+
+ if (isLoading || data === undefined) {
+ return <Loading />;
+ }
+
+ return (
+ <>
+ <h1>Domain {permTypeUpper}s</h1>
+ { permType == "block" ? <BlockHelperText/> : <AllowHelperText/> }
+ <DomainPermsList
+ data={data}
+ permType={permType}
+ permTypeUpper={permTypeUpper}
+ />
+ <Link to="/settings/admin/domain-permissions/import-export">
+ Or use the bulk import/export interface
+ </Link>
+ </>
+ );
+}
+
+interface DomainPermsListProps {
+ data: MappedDomainPerms;
+ permType: PermType;
+ permTypeUpper: string;
+}
+
+function DomainPermsList({ data, permType, permTypeUpper }: DomainPermsListProps) {
+ // Format perms into a list.
+ const perms = useMemo(() => {
+ return Object.values(data);
+ }, [data]);
+
+ const [_location, setLocation] = useLocation();
+ const filterField = useTextInput("filter");
+
+ function filterFormSubmit(e) {
+ e.preventDefault();
+ setLocation(`/${filter}`);
+ }
+
+ const filter = filterField.value ?? "";
+ const filteredPerms = useMemo(() => {
+ return matchSorter(perms, filter, { keys: ["domain"] });
+ }, [perms, filter]);
+ const filtered = perms.length - filteredPerms.length;
+
+ const filterInfo = (
+ <span>
+ {perms.length} {permType}ed domain{perms.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`}
+ </span>
+ );
+
+ const entries = filteredPerms.map((entry) => {
+ return (
+ <Link
+ className="entry nounderline"
+ key={entry.domain}
+ to={`/${permType}s/${entry.domain}`}
+ >
+ <span id="domain">{entry.domain}</span>
+ <span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span>
+ </Link>
+ );
+ });
+
+ return (
+ <div className="domain-permissions-list">
+ <form className="filter" role="search" onSubmit={filterFormSubmit}>
+ <TextInput
+ field={filterField}
+ placeholder="example.org"
+ label={`Search or add domain ${permType}`}
+ />
+ <Link
+ className="button"
+ to={`/${permType}s/${filter}`}
+ >
+ {permTypeUpper}&nbsp;{filter}
+ </Link>
+ </form>
+ <div>
+ {filterInfo}
+ <div className="list">
+ <div className="entries scrolling">
+ {entries}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function BlockHelperText() {
+ return (
+ <p>
+ Blocking a domain blocks interaction between your instance, and all current and future accounts on
+ instance(s) running on the blocked domain. Stored content will be removed, and no more data is sent to
+ the remote server. This extends to all subdomains as well, so blocking 'example.com' also blocks 'social.example.com'.
+ <br/>
+ <a
+ href="https://docs.gotosocial.org/en/latest/admin/domain_blocks/"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about domain blocks (opens in a new tab)
+ </a>
+ <br/>
+ </p>
+ );
+}
+
+function AllowHelperText() {
+ return (
+ <p>
+ Allowing a domain explicitly allows instance(s) running on that domain to interact with your instance.
+ If you're running in allowlist mode, this is how you "allow" instances through.
+ If you're running in blocklist mode (the default federation mode), you can use explicit domain allows
+ to override domain blocks. In blocklist mode, explicitly allowed instances will be able to interact with
+ your instance regardless of any domain blocks in place. This extends to all subdomains as well, so allowing
+ 'example.com' also allows 'social.example.com'. This is useful when you're importing a block list but
+ there are some domains on the list you don't want to block: just create an explicit allow for those domains
+ before importing the list.
+ <br/>
+ <a
+ href="https://docs.gotosocial.org/en/latest/admin/federation_modes/"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about federation modes (opens in a new tab)
+ </a>
+ </p>
+ );
+}
diff --git a/web/source/settings/views/moderation/domain-permissions/process.tsx b/web/source/settings/views/moderation/domain-permissions/process.tsx
new file mode 100644
index 000000000..6c7cb218e
--- /dev/null
+++ b/web/source/settings/views/moderation/domain-permissions/process.tsx
@@ -0,0 +1,400 @@
+/*
+ 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 { memo, useMemo, useCallback, useEffect } from "react";
+
+import { isValidDomainPermission, hasBetterScope } from "../../../lib/util/domain-permission";
+
+import {
+ useTextInput,
+ useBoolInput,
+ useRadioInput,
+ useCheckListInput,
+} from "../../../lib/form";
+
+import {
+ Select,
+ TextArea,
+ RadioGroup,
+ Checkbox,
+ TextInput,
+} from "../../../components/form/inputs";
+
+import useFormSubmit from "../../../lib/form/submit";
+
+import CheckList from "../../../components/check-list";
+import MutationButton from "../../../components/form/mutation-button";
+import FormWithData from "../../../lib/form/form-with-data";
+
+import { useImportDomainPermsMutation } from "../../../lib/query/admin/domain-permissions/import";
+import {
+ useDomainAllowsQuery,
+ useDomainBlocksQuery
+} from "../../../lib/query/admin/domain-permissions/get";
+
+import type { DomainPerm, MappedDomainPerms } from "../../../lib/types/domain-permission";
+import type { ChecklistInputHook, RadioFormInputHook } from "../../../lib/form/types";
+
+export interface ProcessImportProps {
+ list: DomainPerm[],
+ permType: RadioFormInputHook,
+}
+
+export const ProcessImport = memo(
+ function ProcessImport({ list, permType }: ProcessImportProps) {
+ return (
+ <FormWithData
+ dataQuery={permType.value == "allow"
+ ? useDomainAllowsQuery
+ : useDomainBlocksQuery
+ }
+ DataForm={ImportList}
+ {...{ list, permType }}
+ />
+ );
+ }
+);
+
+export interface ImportListProps {
+ list: Array<DomainPerm>,
+ data: MappedDomainPerms,
+ permType: RadioFormInputHook,
+}
+
+function ImportList({ list, data: domainPerms, permType }: ImportListProps) {
+ const hasComment = useMemo(() => {
+ let hasPublic = false;
+ let hasPrivate = false;
+
+ list.some((entry) => {
+ if (entry.public_comment) {
+ hasPublic = true;
+ }
+
+ if (entry.private_comment) {
+ hasPrivate = true;
+ }
+
+ return hasPublic && hasPrivate;
+ });
+
+ if (hasPublic && hasPrivate) {
+ return { both: true };
+ } else if (hasPublic) {
+ return { type: "public_comment" };
+ } else if (hasPrivate) {
+ return { type: "private_comment" };
+ } else {
+ return {};
+ }
+ }, [list]);
+
+ const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
+
+ const form = {
+ domains: useCheckListInput("domains", { entries: list }), // DomainPerm is actually also a Checkable.
+ obfuscate: useBoolInput("obfuscate"),
+ privateComment: useTextInput("private_comment", {
+ defaultValue: `Imported on ${new Date().toLocaleString()}`
+ }),
+ privateCommentBehavior: useRadioInput("private_comment_behavior", {
+ defaultValue: "append",
+ options: {
+ append: "Append to",
+ replace: "Replace"
+ }
+ }),
+ publicComment: useTextInput("public_comment"),
+ publicCommentBehavior: useRadioInput("public_comment_behavior", {
+ defaultValue: "append",
+ options: {
+ append: "Append to",
+ replace: "Replace"
+ }
+ }),
+ permType: permType,
+ };
+
+ const [importDomains, importResult] = useFormSubmit(form, useImportDomainPermsMutation(), { changedOnly: false });
+
+ return (
+ <>
+ <form
+ onSubmit={importDomains}
+ className="domain-perm-import-list"
+ >
+ <span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
+
+ {hasComment.both &&
+ <Select field={showComment} options={
+ <>
+ <option value="public_comment">Show public comments</option>
+ <option value="private_comment">Show private comments</option>
+ </>
+ } />
+ }
+
+ <div className="checkbox-list-wrapper">
+ <DomainCheckList
+ field={form.domains}
+ domainPerms={domainPerms}
+ commentType={showComment.value as "public_comment" | "private_comment"}
+ permType={form.permType}
+ />
+ </div>
+
+ <TextArea
+ field={form.privateComment}
+ label="Private comment"
+ rows={3}
+ />
+ <RadioGroup
+ field={form.privateCommentBehavior}
+ label="imported private comment"
+ />
+
+ <TextArea
+ field={form.publicComment}
+ label="Public comment"
+ rows={3}
+ />
+ <RadioGroup
+ field={form.publicCommentBehavior}
+ label="imported public comment"
+ />
+
+ <Checkbox
+ field={form.obfuscate}
+ label="Obfuscate domains in public lists"
+ />
+
+ <MutationButton
+ label="Import"
+ disabled={false}
+ result={importResult}
+ />
+ </form>
+ </>
+ );
+}
+
+interface DomainCheckListProps {
+ field: ChecklistInputHook,
+ domainPerms: MappedDomainPerms,
+ commentType: "public_comment" | "private_comment",
+ permType: RadioFormInputHook,
+}
+
+function DomainCheckList({ field, domainPerms, commentType, permType }: DomainCheckListProps) {
+ const getExtraProps = useCallback((entry: DomainPerm) => {
+ return {
+ comment: entry[commentType],
+ alreadyExists: entry.domain in domainPerms,
+ permType: permType,
+ };
+ }, [domainPerms, commentType, permType]);
+
+ const entriesWithSuggestions = useMemo(() => {
+ const fieldValue = (field.value ?? {}) as { [k: string]: DomainPerm; };
+ return Object.values(fieldValue).filter((entry) => entry.suggest);
+ }, [field.value]);
+
+ return (
+ <>
+ <CheckList
+ field={field as ChecklistInputHook}
+ header={<>
+ <b>Domain</b>
+ <b>
+ {commentType == "public_comment" && "Public comment"}
+ {commentType == "private_comment" && "Private comment"}
+ </b>
+ </>}
+ EntryComponent={DomainEntry}
+ getExtraProps={getExtraProps}
+ />
+ <UpdateHint
+ entries={entriesWithSuggestions}
+ updateEntry={field.onChange}
+ updateMultiple={field.updateMultiple}
+ />
+ </>
+ );
+}
+
+interface UpdateHintProps {
+ entries,
+ updateEntry,
+ updateMultiple,
+}
+
+const UpdateHint = memo(
+ function UpdateHint({ entries, updateEntry, updateMultiple }: UpdateHintProps) {
+ if (entries.length == 0) {
+ return null;
+ }
+
+ function changeAll() {
+ updateMultiple(
+ entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }])
+ );
+ }
+
+ return (
+ <div className="update-hints">
+ <p>
+ {entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain,
+ which you might want to change to the main domain, as that includes all it's (future) subdomains.
+ </p>
+ <div className="hints">
+ {entries.map((entry) => (
+ <UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} />
+ ))}
+ </div>
+ {entries.length > 0 && <a onClick={changeAll}>change all</a>}
+ </div>
+ );
+ }
+);
+
+interface UpdateableEntryProps {
+ entry,
+ updateEntry,
+}
+
+const UpdateableEntry = memo(
+ function UpdateableEntry({ entry, updateEntry }: UpdateableEntryProps) {
+ return (
+ <>
+ <span className="text-cutoff">{entry.domain}</span>
+ <i className="fa fa-long-arrow-right" aria-hidden="true"></i>
+ <span>{entry.suggest}</span>
+ <a role="button" onClick={() =>
+ updateEntry(entry.key, { domain: entry.suggest, suggest: null })
+ }>change</a>
+ </>
+ );
+ }
+);
+
+function domainValidationError(isValid) {
+ return isValid ? "" : "Invalid domain";
+}
+
+interface DomainEntryProps {
+ entry;
+ onChange;
+ extraProps: {
+ alreadyExists: boolean;
+ comment: string;
+ permType: RadioFormInputHook;
+ };
+}
+
+function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment, permType } }: DomainEntryProps) {
+ const domainField = useTextInput("domain", {
+ defaultValue: entry.domain,
+ showValidation: entry.checked,
+ initValidation: domainValidationError(entry.valid),
+ validator: (value) => domainValidationError(isValidDomainPermission(value))
+ });
+
+ useEffect(() => {
+ if (entry.valid != domainField.valid) {
+ onChange({ valid: domainField.valid });
+ }
+ }, [onChange, entry.valid, domainField.valid]);
+
+ useEffect(() => {
+ if (entry.domain != domainField.value) {
+ domainField.setter(entry.domain);
+ }
+ // domainField.setter is enough, eslint wants domainField
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [entry.domain, domainField.setter]);
+
+ useEffect(() => {
+ onChange({ suggest: hasBetterScope(domainField.value ?? "") });
+ // only need this update if it's the entry.checked that updated, not onChange
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [domainField.value]);
+
+ function clickIcon(e) {
+ if (entry.suggest) {
+ e.stopPropagation();
+ e.preventDefault();
+ domainField.setter(entry.suggest);
+ onChange({ domain: entry.suggest, checked: true });
+ }
+ }
+
+ return (
+ <>
+ <div className="domain-input">
+ <TextInput
+ field={domainField}
+ onChange={(e) => {
+ domainField.onChange(e);
+ onChange({ domain: e.target.value, checked: true });
+ }}
+ />
+ <span id="icon" onClick={clickIcon}>
+ <DomainEntryIcon
+ alreadyExists={alreadyExists}
+ suggestion={entry.suggest}
+ permTypeString={permType.value?? ""}
+ />
+ </span>
+ </div>
+ <p>{comment}</p>
+ </>
+ );
+}
+
+interface DomainEntryIconProps {
+ alreadyExists: boolean;
+ suggestion: string;
+ permTypeString: string;
+}
+
+function DomainEntryIcon({ alreadyExists, suggestion, permTypeString }: DomainEntryIconProps) {
+ let icon;
+ let text;
+
+ if (suggestion) {
+ icon = "fa-info-circle suggest-changes";
+ text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`;
+ } else if (alreadyExists) {
+ icon = "fa-history permission-already-exists";
+ text = `Domain ${permTypeString} already exists.`;
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+ <>
+ <i className={`fa fa-fw ${icon}`} aria-hidden="true" title={text}></i>
+ <span className="sr-only">{text}</span>
+ </>
+ );
+}
diff --git a/web/source/settings/views/moderation/reports/detail.tsx b/web/source/settings/views/moderation/reports/detail.tsx
new file mode 100644
index 000000000..9bb2de6b2
--- /dev/null
+++ b/web/source/settings/views/moderation/reports/detail.tsx
@@ -0,0 +1,243 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React, { useState } from "react";
+import { useParams } from "wouter";
+import FormWithData from "../../../lib/form/form-with-data";
+import BackButton from "../../../components/back-button";
+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 "./username";
+import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
+import { useBaseUrl } from "../../../lib/navigation/util";
+
+export default function ReportDetail({ }) {
+ const baseUrl = useBaseUrl();
+ const params = useParams();
+
+ return (
+ <div className="reports">
+ <h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
+ <FormWithData
+ dataQuery={useGetReportQuery}
+ queryArg={params.reportId}
+ DataForm={ReportDetailForm}
+ />
+ </div>
+ );
+}
+
+function ReportDetailForm({ data: report }) {
+ const from = report.account;
+ const target = report.target_account;
+
+ return (
+ <div className="report detail">
+ <div className="usernames">
+ <Username user={from} /> reported <Username user={target} />
+ </div>
+
+ {report.action_taken &&
+ <div className="info">
+ <h3>Resolved by @{report.action_taken_by_account.account.acct}</h3>
+ <span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span>
+ <br />
+ <b>Comment: </b><span>{report.action_taken_comment}</span>
+ </div>
+ }
+
+ <div className="info-block">
+ <h3>Report info:</h3>
+ <div className="details">
+ <b>Created: </b>
+ <span>{new Date(report.created_at).toLocaleString()}</span>
+
+ <b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span>
+ <b>Category: </b> <span>{report.category}</span>
+
+ <b>Reason: </b>
+ {report.comment.length > 0
+ ? <p>{report.comment}</p>
+ : <i className="no-comment">none provided</i>
+ }
+
+ </div>
+ </div>
+
+ {!report.action_taken && <ReportActionForm report={report} />}
+
+ {
+ report.statuses.length > 0 &&
+ <div className="info-block">
+ <h3>Reported toots ({report.statuses.length}):</h3>
+ <div className="reported-toots">
+ {report.statuses.map((status) => (
+ <ReportedToot key={status.id} toot={status} />
+ ))}
+ </div>
+ </div>
+ }
+ </div>
+ );
+}
+
+function ReportActionForm({ report }) {
+ const form = {
+ id: useValue("id", report.id),
+ comment: useTextInput("action_taken_comment")
+ };
+
+ const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
+
+ return (
+ <form onSubmit={submit} className="info-block">
+ <h3>Resolving this report</h3>
+ <p>
+ An optional comment can be included while resolving this report.
+ Useful for providing an explanation about what action was taken (if any) before the report was marked as resolved.<br />
+ <b>This will be visible to the user that created the report!</b>
+ </p>
+ <TextArea
+ field={form.comment}
+ label="Comment"
+ />
+ <MutationButton
+ disabled={false}
+ label="Resolve"
+ result={result}
+ />
+ </form>
+ );
+}
+
+function ReportedToot({ toot }) {
+ const account = toot.account;
+
+ return (
+ <article className="status expanded">
+ <header className="status-header">
+ <address>
+ <a style={{margin: 0}}>
+ <img className="avatar" src={account.avatar} alt="" />
+ <dl className="author-strap">
+ <dt className="sr-only">Display name</dt>
+ <dd className="displayname text-cutoff">
+ {account.display_name.trim().length > 0 ? account.display_name : account.username}
+ </dd>
+ <dt className="sr-only">Username</dt>
+ <dd className="username text-cutoff">@{account.username}</dd>
+ </dl>
+ </a>
+ </address>
+ </header>
+ <section className="status-body">
+ <div className="text">
+ <div className="content">
+ {toot.spoiler_text?.length > 0
+ ? <TootCW content={toot.content} note={toot.spoiler_text} />
+ : toot.content
+ }
+ </div>
+ </div>
+ {toot.media_attachments?.length > 0 &&
+ <TootMedia media={toot.media_attachments} sensitive={toot.sensitive} />
+ }
+ </section>
+ <aside className="status-info">
+ <dl className="status-stats">
+ <div className="stats-grouping">
+ <div className="stats-item published-at text-cutoff">
+ <dt className="sr-only">Published</dt>
+ <dd>
+ <time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
+ </dd>
+ </div>
+ </div>
+ </dl>
+ </aside>
+ </article>
+ );
+}
+
+function TootCW({ note, content }) {
+ const [visible, setVisible] = useState(false);
+
+ function toggleVisible() {
+ setVisible(!visible);
+ }
+
+ return (
+ <>
+ <div className="spoiler">
+ <span>{note}</span>
+ <label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label>
+ </div>
+ {visible && content}
+ </>
+ );
+}
+
+function TootMedia({ media, sensitive }) {
+ let classes = (media.length % 2 == 0) ? "even" : "odd";
+ if (media.length == 1) {
+ classes += " single";
+ }
+
+ return (
+ <div className={`media photoswipe-gallery ${classes}`}>
+ {media.map((m) => (
+ <div key={m.id} className="media-wrapper">
+ {sensitive && <>
+ <input id={`sensitiveMedia-${m.id}`} type="checkbox" className="sensitive-checkbox hidden" />
+ <div className="sensitive">
+ <div className="open">
+ <label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
+ <i className="fa fa-eye-slash" title="Hide sensitive media"></i>
+ </label>
+ </div>
+ <div className="closed" title={m.description}>
+ <label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
+ Show sensitive media
+ </label>
+ </div>
+ </div>
+ </>}
+ <a
+ href={m.url}
+ title={m.description}
+ target="_blank"
+ rel="noreferrer"
+ data-cropped="true"
+ data-pswp-width={`${m.meta?.original.width}px`}
+ data-pswp-height={`${m.meta?.original.height}px`}
+ >
+ <img
+ alt={m.description}
+ src={m.url}
+ // thumb={m.preview_url}
+ sizes={m.meta?.original}
+ />
+ </a>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/web/source/settings/views/moderation/reports/overview.tsx b/web/source/settings/views/moderation/reports/overview.tsx
new file mode 100644
index 000000000..ca8fc185c
--- /dev/null
+++ b/web/source/settings/views/moderation/reports/overview.tsx
@@ -0,0 +1,99 @@
+/*
+ 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 { Link } from "wouter";
+
+import FormWithData from "../../../lib/form/form-with-data";
+
+import Username from "./username";
+import { useListReportsQuery } from "../../../lib/query/admin/reports";
+
+export function ReportOverview({ }) {
+ return (
+ <FormWithData
+ dataQuery={useListReportsQuery}
+ DataForm={ReportsList}
+ />
+ );
+}
+
+function ReportsList({ data: reports }) {
+ return (
+ <div className="reports">
+ <div className="form-section-docs">
+ <h1>Reports</h1>
+ <p>
+ Here you can view and resolve reports made to your
+ instance, originating from local and remote users.
+ </p>
+ <a
+ href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about this (opens in a new tab)
+ </a>
+ </div>
+ <div className="list">
+ {reports.map((report) => (
+ <ReportEntry key={report.id} report={report} />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function ReportEntry({ report }) {
+ const from = report.account;
+ const target = report.target_account;
+
+ let comment = report.comment.length > 200
+ ? report.comment.slice(0, 200) + "..."
+ : report.comment;
+
+ return (
+ <Link
+ to={`/${report.id}`}
+ className="nounderline"
+ >
+ <div className={`report entry${report.action_taken ? " resolved" : ""}`}>
+ <div className="byline">
+ <div className="usernames">
+ <Username user={from} link={false} /> reported <Username user={target} link={false} />
+ </div>
+ <h3 className="report-status">
+ {report.action_taken ? "Resolved" : "Open"}
+ </h3>
+ </div>
+ <div className="details">
+ <b>Created: </b>
+ <span>{new Date(report.created_at).toLocaleString()}</span>
+
+ <b>Reason: </b>
+ {comment.length > 0
+ ? <p>{comment}</p>
+ : <i className="no-comment">none provided</i>
+ }
+ </div>
+ </div>
+ </Link>
+ );
+}
diff --git a/web/source/settings/views/moderation/reports/username.tsx b/web/source/settings/views/moderation/reports/username.tsx
new file mode 100644
index 000000000..6fba0b804
--- /dev/null
+++ b/web/source/settings/views/moderation/reports/username.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";
+import { Link } from "wouter";
+
+export default function Username({ user, link = true }) {
+ let className = "user";
+ let isLocal = user.domain == null;
+
+ if (user.suspended) {
+ className += " suspended";
+ }
+
+ if (isLocal) {
+ className += " local";
+ }
+
+ let icon = isLocal
+ ? { fa: "fa-home", info: "Local user" }
+ : { fa: "fa-external-link-square", info: "Remote user" };
+
+ let Element: any = "div";
+ let href: any = null;
+
+ if (link) {
+ Element = Link;
+ href = `/settings/admin/accounts/${user.id}`;
+ }
+
+ return (
+ <Element className={className} to={href}>
+ <span className="acct">@{user.account.acct}</span>
+ <i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} />
+ <span className="sr-only">{icon.info}</span>
+ </Element>
+ );
+}
diff --git a/web/source/settings/views/moderation/routes.tsx b/web/source/settings/views/moderation/routes.tsx
new file mode 100644
index 000000000..238abaff6
--- /dev/null
+++ b/web/source/settings/views/moderation/routes.tsx
@@ -0,0 +1,201 @@
+/*
+ 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 { MenuItem } from "../../lib/navigation/menu";
+import React from "react";
+import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
+import { Redirect, Route, Router, Switch } from "wouter";
+import AccountsOverview from "./accounts";
+import AccountsPending from "./accounts/pending";
+import AccountDetail from "./accounts/detail";
+import { ReportOverview } from "./reports/overview";
+import DomainPermissionsOverview from "./domain-permissions/overview";
+import DomainPermDetail from "./domain-permissions/detail";
+import ImportExport from "./domain-permissions/import-export";
+import ReportDetail from "./reports/detail";
+
+/*
+ EXPORTED COMPONENTS
+*/
+
+/**
+ * Moderation menu. Reports, accounts,
+ * domain permissions import + export.
+ */
+export function ModerationMenu() {
+ return (
+ <MenuItem
+ name="Moderation"
+ itemUrl="moderation"
+ defaultChild="reports"
+ permissions={["moderator"]}
+ >
+ <ModerationReportsMenu />
+ <ModerationAccountsMenu />
+ <ModerationDomainPermsMenu />
+ </MenuItem>
+ );
+}
+
+/**
+ * Moderation router. Reports, accounts,
+ * domain permissions import + export.
+ */
+export function ModerationRouter() {
+ const parentUrl = useBaseUrl();
+ const thisBase = "/moderation";
+ const absBase = parentUrl + thisBase;
+
+ return (
+ <BaseUrlContext.Provider value={absBase}>
+ <Router base={thisBase}>
+ <ModerationReportsRouter />
+ <ModerationAccountsRouter />
+ <ModerationDomainPermsRouter />
+ </Router>
+ </BaseUrlContext.Provider>
+ );
+}
+
+/*
+ INTERNAL COMPONENTS
+*/
+
+/*
+ MENUS
+*/
+
+function ModerationReportsMenu() {
+ return (
+ <MenuItem
+ name="Reports"
+ itemUrl="reports"
+ icon="fa-flag"
+ />
+ );
+}
+
+function ModerationAccountsMenu() {
+ return (
+ <MenuItem
+ name="Accounts"
+ itemUrl="accounts"
+ defaultChild="overview"
+ icon="fa-users"
+ >
+ <MenuItem
+ name="Overview"
+ itemUrl="overview"
+ icon="fa-list"
+ />
+ <MenuItem
+ name="Pending"
+ itemUrl="pending"
+ icon="fa-question"
+ />
+ </MenuItem>
+ );
+}
+
+function ModerationDomainPermsMenu() {
+ return (
+ <MenuItem
+ name="Domain Permissions"
+ itemUrl="domain-permissions"
+ defaultChild="blocks"
+ icon="fa-hubzilla"
+ >
+ <MenuItem
+ name="Blocks"
+ itemUrl="blocks"
+ icon="fa-close"
+ />
+ <MenuItem
+ name="Allows"
+ itemUrl="allows"
+ icon="fa-check"
+ />
+ <MenuItem
+ name="Import/Export"
+ itemUrl="import-export"
+ icon="fa-floppy-o"
+ />
+ </MenuItem>
+ );
+}
+
+/*
+ ROUTERS
+*/
+
+function ModerationReportsRouter() {
+ const parentUrl = useBaseUrl();
+ const thisBase = "/reports";
+ const absBase = parentUrl + thisBase;
+
+ return (
+ <BaseUrlContext.Provider value={absBase}>
+ <Router base={thisBase}>
+ <Switch>
+ <Route path={"/:reportId"} component={ReportDetail} />
+ <Route component={ReportOverview}/>
+ </Switch>
+ </Router>
+ </BaseUrlContext.Provider>
+ );
+}
+
+function ModerationAccountsRouter() {
+ const parentUrl = useBaseUrl();
+ const thisBase = "/accounts";
+ const absBase = parentUrl + thisBase;
+
+ return (
+ <BaseUrlContext.Provider value={absBase}>
+ <Router base={thisBase}>
+ <Switch>
+ <Route path="/overview" component={AccountsOverview}/>
+ <Route path="/pending" component={AccountsPending}/>
+ <Route path="/:accountID" component={AccountDetail}/>
+ <Route><Redirect to="/overview"/></Route>
+ </Switch>
+ </Router>
+ </BaseUrlContext.Provider>
+ );
+}
+
+function ModerationDomainPermsRouter() {
+ const parentUrl = useBaseUrl();
+ const thisBase = "/domain-permissions";
+ const absBase = parentUrl + thisBase;
+
+ return (
+ <BaseUrlContext.Provider value={absBase}>
+ <Router base={thisBase}>
+ <Switch>
+ <Route path="/import-export" component={ImportExport} />
+ <Route path="/process" component={ImportExport} />
+ <Route path="/:permType/:domain" component={DomainPermDetail} />
+ <Route path="/:permType" component={DomainPermissionsOverview} />
+ <Route><Redirect to="/blocks"/></Route>
+ </Switch>
+ </Router>
+ </BaseUrlContext.Provider>
+ );
+}