From 725a21b02721f92ed0420ed3f807ee921de77992 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 1 May 2024 15:11:22 +0200 Subject: [feature] Page through accounts as moderator (#2881) * [feature] Page through accounts as moderator * aaaaa * use COLLATE "C" for Postgres to ensure same ordering as SQLite * fix typo, test paging up * don't show moderation / info for our instance acct --- .../views/moderation/accounts/detail/actions.tsx | 126 +++++++++++++++-- .../moderation/accounts/detail/handlesignup.tsx | 114 --------------- .../views/moderation/accounts/detail/index.tsx | 157 +++++++++++++-------- .../views/moderation/accounts/detail/util.tsx | 43 ++++++ .../settings/views/moderation/accounts/index.tsx | 4 +- .../views/moderation/accounts/pending/index.tsx | 28 +++- .../views/moderation/accounts/search/index.tsx | 80 ++++++++--- web/source/settings/views/moderation/menu.tsx | 8 +- .../settings/views/moderation/reports/detail.tsx | 12 +- .../settings/views/moderation/reports/overview.tsx | 4 +- .../settings/views/moderation/reports/username.tsx | 66 --------- web/source/settings/views/moderation/router.tsx | 10 +- 12 files changed, 360 insertions(+), 292 deletions(-) delete mode 100644 web/source/settings/views/moderation/accounts/detail/handlesignup.tsx create mode 100644 web/source/settings/views/moderation/accounts/detail/util.tsx delete mode 100644 web/source/settings/views/moderation/reports/username.tsx (limited to 'web/source/settings/views/moderation') diff --git a/web/source/settings/views/moderation/accounts/detail/actions.tsx b/web/source/settings/views/moderation/accounts/detail/actions.tsx index 212bb4089..4132b778a 100644 --- a/web/source/settings/views/moderation/accounts/detail/actions.tsx +++ b/web/source/settings/views/moderation/accounts/detail/actions.tsx @@ -19,7 +19,7 @@ import React from "react"; -import { useActionAccountMutation } from "../../../../lib/query/admin"; +import { useActionAccountMutation, useHandleSignupMutation } from "../../../../lib/query/admin"; import MutationButton from "../../../../components/form/mutation-button"; import useFormSubmit from "../../../../lib/form/submit"; import { @@ -27,22 +27,50 @@ import { useTextInput, useBoolInput, } from "../../../../lib/form"; -import { Checkbox, TextInput } from "../../../../components/form/inputs"; +import { Checkbox, Select, TextInput } from "../../../../components/form/inputs"; import { AdminAccount } from "../../../../lib/types/account"; +import { useLocation } from "wouter"; export interface AccountActionsProps { account: AdminAccount, + backLocation: string, } -export function AccountActions({ account }: AccountActionsProps) { +export function AccountActions({ account, backLocation }: AccountActionsProps) { + const local = !account.domain; + + // Available actions differ depending + // on the account's current status. + switch (true) { + case account.suspended: + // Can't do anything with + // suspended accounts currently. + return null; + case local && !account.approved: + // Unapproved local account sign-up, + // only show HandleSignup form. + return ( + + ); + default: + // Normal local or remote account, show + // full range of moderation options. + return ; + } +} + +function ModerateAccount({ account }: { account: AdminAccount }) { const form = { id: useValue("id", account.id), reason: useTextInput("text") }; - + const reallySuspend = useBoolInput("reallySuspend"); const [accountAction, result] = useFormSubmit(form, useActionAccountMutation()); - + return (
- {/* - */} ); } + +function HandleSignup({ account, backLocation }: { account: AdminAccount, backLocation: string }) { + 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 ( + +

Handle Account Sign-Up

+ + { form.approveOrReject.value === "reject" && + // Only show form fields relevant + // to "reject" if rejecting. + // On "approve" these fields will + // be ignored anyway. + <> + + + + } + + + ); +} diff --git a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx b/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx deleted file mode 100644 index 59fa8bc65..000000000 --- a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - 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 . -*/ - -import React from "react"; -import { useLocation } from "wouter"; -import { useHandleSignupMutation } from "../../../../lib/query/admin"; -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 ( -
-

Handle Account Sign-Up

- - { form.approveOrReject.value === "reject" && - // Only show form fields relevant - // to "reject" if rejecting. - // On "approve" these fields will - // be ignored anyway. - <> - - - - } - - - ); -} diff --git a/web/source/settings/views/moderation/accounts/detail/index.tsx b/web/source/settings/views/moderation/accounts/detail/index.tsx index f34bc7481..830a894cb 100644 --- a/web/source/settings/views/moderation/accounts/detail/index.tsx +++ b/web/source/settings/views/moderation/accounts/detail/index.tsx @@ -23,51 +23,89 @@ import { useGetAccountQuery } from "../../../../lib/query/admin"; 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"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { UseOurInstanceAccount, yesOrNo } from "./util"; export default function AccountDetail() { const params: { accountID: string } = useParams(); - + const baseUrl = useBaseUrl(); + const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`; + return (
-

Account Details

+

Account Details

); } interface AccountDetailFormProps { - backLocation: string, - data: AdminAccount, + data: AdminAccount; + backLocation: string; } -function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) { - let yesOrNo = (b: boolean) => { - return b ? "yes" : "no"; - }; +function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) { + // If this is our instance account, don't + // bother returning detailed account information. + const ourInstanceAccount = UseOurInstanceAccount(adminAcct); + if (ourInstanceAccount) { + return ( + <> + +
+ + + This is the service account for your instance; you + cannot perform moderation actions on this account. + +
+ + ); + } + + const local = !adminAcct.domain; + return ( + <> + + + { + // Only show local account details + // if this is a local account! + local && + } + + + ); +} - let created = new Date(adminAcct.created_at).toDateString(); +function GeneralAccountDetails({ adminAcct } : { adminAcct: AdminAccount }) { + const local = !adminAcct.domain; + const 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 ( <> -

General Account Details

- { adminAcct.suspended && -
- - Account is suspended. -
+ { adminAcct.suspended && +
+ + Account is suspended. +
}
{ !local && @@ -75,6 +113,18 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
Domain
{adminAcct.domain}
} +
+
Profile URL
+
+ + {adminAcct.account.url} (opens in a new tab) + +
+
Created
@@ -104,61 +154,54 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
{adminAcct.account.following_count}
- { local && - // Only show local account details - // if this is a local account! - <> -

Local Account Details

- { !adminAcct.approved && + + ); +} + +function LocalAccountDetails({ adminAcct }: { adminAcct: AdminAccount }) { + return ( + <> +

Local Account Details

+ { !adminAcct.approved &&
Account is pending.
- } - { !adminAcct.confirmed && + } + { !adminAcct.confirmed &&
Account email not yet confirmed.
- } -
-
-
Email
-
{adminAcct.email} {{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"} }
-
-
-
Disabled
-
{yesOrNo(adminAcct.disabled)}
-
-
-
Approved
-
{yesOrNo(adminAcct.approved)}
-
-
-
Sign-Up Reason
-
{adminAcct.invite_request ?? none provided}
-
- { (adminAcct.ip && adminAcct.ip !== "0.0.0.0") && + } +
+
+
Email
+
{adminAcct.email} {{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"} }
+
+
+
Disabled
+
{yesOrNo(adminAcct.disabled)}
+
+
+
Approved
+
{yesOrNo(adminAcct.approved)}
+
+
+
Sign-Up Reason
+
{adminAcct.invite_request ?? none provided}
+
+ { (adminAcct.ip && adminAcct.ip !== "0.0.0.0") &&
Sign-Up IP
{adminAcct.ip}
} - { adminAcct.locale && + { adminAcct.locale &&
Locale
{adminAcct.locale}
} -
- } - { local && !adminAcct.approved - ? - - : - - } - +
+ ); } diff --git a/web/source/settings/views/moderation/accounts/detail/util.tsx b/web/source/settings/views/moderation/accounts/detail/util.tsx new file mode 100644 index 000000000..b82d44a6e --- /dev/null +++ b/web/source/settings/views/moderation/accounts/detail/util.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 . +*/ + +import { useMemo } from "react"; + +import { AdminAccount } from "../../../../lib/types/account"; +import { store } from "../../../../redux/store"; + +export function yesOrNo(b: boolean): string { + return b ? "yes" : "no"; +} + +export function UseOurInstanceAccount(account: AdminAccount): boolean { + // Pull our own URL out of storage so we can + // tell if account is our instance account. + const ourDomain = useMemo(() => { + const instanceUrlStr = store.getState().oauth.instanceUrl; + if (!instanceUrlStr) { + return ""; + } + + const instanceUrl = new URL(instanceUrlStr); + return instanceUrl.host; + }, []); + + return !account.domain && account.username == ourDomain; +} diff --git a/web/source/settings/views/moderation/accounts/index.tsx b/web/source/settings/views/moderation/accounts/index.tsx index 79ba2c674..946ed323d 100644 --- a/web/source/settings/views/moderation/accounts/index.tsx +++ b/web/source/settings/views/moderation/accounts/index.tsx @@ -20,10 +20,10 @@ import React from "react"; import { AccountSearchForm } from "./search"; -export default function AccountsOverview({ }) { +export default function AccountsSearch({ }) { return (
-

Accounts Overview

+

Accounts Search

You can perform actions on an account by clicking its name in a report, or by searching for the account diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx index d5a32f09b..b72de52bf 100644 --- a/web/source/settings/views/moderation/accounts/pending/index.tsx +++ b/web/source/settings/views/moderation/accounts/pending/index.tsx @@ -17,20 +17,40 @@ along with this program. If not, see . */ -import React from "react"; +import React, { ReactNode } from "react"; import { useSearchAccountsQuery } from "../../../../lib/query/admin"; -import { AccountList } from "../../../../components/account-list"; +import { PageableList } from "../../../../components/pageable-list"; +import { useLocation } from "wouter"; +import Username from "../../../../components/username"; +import { AdminAccount } from "../../../../lib/types/account"; export default function AccountsPending() { + const [ location, _setLocation ] = useLocation(); const searchRes = useSearchAccountsQuery({status: "pending"}); + // Function to map an item to a list entry. + function itemToEntry(account: AdminAccount): ReactNode { + const acc = account.account; + return ( + + ); + } + return (

Pending Accounts

- . */ -import React from "react"; +import React, { ReactNode, useEffect, useMemo } from "react"; import { useLazySearchAccountsQuery } from "../../../../lib/query/admin"; import { useTextInput } from "../../../../lib/form"; -import { AccountList } from "../../../../components/account-list"; -import { SearchAccountParams } from "../../../../lib/types/account"; +import { PageableList } from "../../../../components/pageable-list"; 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"; export function AccountSearchForm() { + const [ location, setLocation ] = useLocation(); + const search = useSearch(); + const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); + const [ searchAcct, searchRes ] = useLazySearchAccountsQuery(); + + // Populate search form using values from + // urlQueryParams, to allow paging. 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"), + origin: useTextInput("origin", { defaultValue: urlQueryParams.get("origin") ?? ""}), + status: useTextInput("status", { defaultValue: urlQueryParams.get("status") ?? ""}), + permissions: useTextInput("permissions", { defaultValue: urlQueryParams.get("permissions") ?? ""}), + username: useTextInput("username", { defaultValue: urlQueryParams.get("username") ?? ""}), + display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}), + by_domain: useTextInput("by_domain", { defaultValue: urlQueryParams.get("by_domain") ?? ""}), + email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}), + ip: useTextInput("ip", { defaultValue: urlQueryParams.get("ip") ?? ""}), + limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "50"}) }; - function submitSearch(e) { + // 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. + useEffect(() => { + if (urlQueryParams.size > 0) { + searchAcct(Object.fromEntries(urlQueryParams), true); + } + }, [urlQueryParams, searchAcct]); + + // 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. @@ -52,16 +77,32 @@ export function AccountSearchForm() { // Remove any nulls. return kv || []; }); - const params: SearchAccountParams = Object.fromEntries(entries); - searchAcct(params); + + const searchParams = new URLSearchParams(entries); + setLocation(location + "?" + searchParams.toString()); } - const [ searchAcct, searchRes ] = useLazySearchAccountsQuery(); + // Location to return to when user clicks "back" on the account detail view. + const backLocation = location + (urlQueryParams ? `?${urlQueryParams}` : ""); + + // Function to map an item to a list entry. + function itemToEntry(account: AdminAccount): ReactNode { + const acc = account.account; + return ( + + ); + } return ( <>
- ); diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 4f01e0798..9488b8c30 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -28,7 +28,7 @@ import { useHasPermission } from "../../lib/navigation/util"; /** * - /settings/moderation/reports/overview * - /settings/moderation/reports/:reportId - * - /settings/moderation/accounts/overview + * - /settings/moderation/accounts/search * - /settings/moderation/accounts/pending * - /settings/moderation/accounts/:accountID * - /settings/moderation/domain-permissions/:permType @@ -76,12 +76,12 @@ function ModerationAccountsMenu() {
<> reported
diff --git a/web/source/settings/views/moderation/reports/overview.tsx b/web/source/settings/views/moderation/reports/overview.tsx index 03ce1a382..18eb5492a 100644 --- a/web/source/settings/views/moderation/reports/overview.tsx +++ b/web/source/settings/views/moderation/reports/overview.tsx @@ -20,7 +20,7 @@ import React from "react"; import { Link } from "wouter"; import FormWithData from "../../../lib/form/form-with-data"; -import Username from "./username"; +import Username from "../../../components/username"; import { useListReportsQuery } from "../../../lib/query/admin/reports"; export function ReportOverview({ }) { @@ -75,7 +75,7 @@ function ReportEntry({ report }) {
- reported + reported

{report.action_taken ? "Resolved" : "Open"} diff --git a/web/source/settings/views/moderation/reports/username.tsx b/web/source/settings/views/moderation/reports/username.tsx deleted file mode 100644 index 294d97e8b..000000000 --- a/web/source/settings/views/moderation/reports/username.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - 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 . -*/ - -import React from "react"; -import { Link } from "wouter"; -import { AdminAccount } from "../../../lib/types/account"; - -interface UsernameProps { - user: AdminAccount; - link?: string; -} - -export default function Username({ user, link }: UsernameProps) { - 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" }; - - const content = ( - <> - @{user.account.acct} -