gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 89dcbd5a201f830812e49ed5d8e37c00d16b838b
parent b47661f0330f9f6902e19a3bf6fb664dcfa85907
Author: f0x52 <f0x@cthu.lu>
Date:   Sat, 13 May 2023 12:17:22 +0200

[frontend] Basic user moderation actions (#1728)

* remove info banner

* update swagger definition for AccountAction

* basic user view, suspend action

* clean up suspended user display

* basic user searching

* rename User -> Account for clarity

* refactor error boundary component to give better info

* appease the linter
Diffstat:
Minternal/api/client/admin/accountaction.go | 2+-
Mweb/source/package.json | 1-
Aweb/source/settings/admin/accounts/detail.jsx | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/accounts/index.jsx | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/admin/reports/detail.jsx | 2+-
Mweb/source/settings/admin/reports/index.jsx | 7-------
Mweb/source/settings/admin/reports/username.jsx | 7++++---
Mweb/source/settings/components/error.jsx | 14++++++++------
Mweb/source/settings/components/fake-profile.jsx | 8++++----
Mweb/source/settings/index.js | 1+
Mweb/source/settings/lib/navigation/components.jsx | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mweb/source/settings/lib/query/admin/index.js | 26++++++++++++++++++++++++++
Mweb/source/settings/lib/query/base.js | 2+-
Mweb/source/settings/style.css | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/user/profile.js | 2+-
Mweb/source/yarn.lock | 7-------
16 files changed, 421 insertions(+), 37 deletions(-)

diff --git a/internal/api/client/admin/accountaction.go b/internal/api/client/admin/accountaction.go @@ -53,7 +53,7 @@ import ( // - // name: type // in: formData -// description: Type of action to be taken (`disable`, `silence`, or `suspend`). +// description: Type of action to be taken, currently only supports `suspend`. // type: string // required: true // - diff --git a/web/source/package.json b/web/source/package.json @@ -28,7 +28,6 @@ "psl": "^1.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-error-boundary": "^3.1.4", "react-redux": "^8.0.4", "redux": "^4.2.0", "redux-persist": "^6.0.0", diff --git a/web/source/settings/admin/accounts/detail.jsx b/web/source/settings/admin/accounts/detail.jsx @@ -0,0 +1,114 @@ +/* + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const { useRoute, Redirect } = require("wouter"); + +const query = require("../../lib/query"); + +const FormWithData = require("../../lib/form/form-with-data"); + +const { useBaseUrl } = require("../../lib/navigation/util"); +const FakeProfile = require("../../components/fake-profile"); +const MutationButton = require("../../components/form/mutation-button"); + +const useFormSubmit = require("../../lib/form/submit"); +const { useValue, useTextInput } = require("../../lib/form"); +const { TextInput } = require("../../components/form/inputs"); + +module.exports = function AccountDetail({ }) { + const baseUrl = useBaseUrl(); + + let [_match, params] = useRoute(`${baseUrl}/:accountId`); + + if (params?.accountId == undefined) { + return <Redirect to={baseUrl} />; + } else { + return ( + <div className="account-detail"> + <h1> + Account Details + </h1> + <FormWithData + dataQuery={query.useGetAccountQuery} + queryArg={params.accountId} + DataForm={AccountDetailForm} + /> + </div> + ); + } +}; + +function AccountDetailForm({ data: account }) { + let content; + if (account.suspended) { + content = ( + <h2 className="error">Account is suspended.</h2> + ); + } else { + content = <ModifyAccount account={account} />; + } + + return ( + <> + <FakeProfile {...account} /> + + {content} + </> + ); +} + +function ModifyAccount({ account }) { + const form = { + id: useValue("id", account.id), + reason: useTextInput("text", {}) + }; + + const [modifyAccount, result] = useFormSubmit(form, query.useActionAccountMutation()); + + return ( + <form onSubmit={modifyAccount}> + <h2>Actions</h2> + <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 + label="Suspend" + name="suspend" + result={result} + /> + </div> + </form> + ); +} +\ No newline at end of file diff --git a/web/source/settings/admin/accounts/index.jsx b/web/source/settings/admin/accounts/index.jsx @@ -0,0 +1,140 @@ +/* + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const { Switch, Route, Link } = require("wouter"); + +const query = require("../../lib/query"); +const { useTextInput } = require("../../lib/form"); + +const AccountDetail = require("./detail"); +const { useBaseUrl } = require("../../lib/navigation/util"); +const { Error } = require("../../components/error"); + +module.exports = function Accounts({ baseUrl }) { + return ( + <div className="accounts"> + <Switch> + <Route path={`${baseUrl}/:accountId`}> + <AccountDetail /> + </Route> + <AccountOverview /> + </Switch> + </div> + ); +}; + +function AccountOverview({ }) { + return ( + <> + <h1>Accounts</h1> + <div> + Pending <a href="https://github.com/superseriousbusiness/gotosocial/issues/581">#581</a>, + there is currently no way to list accounts.<br /> + You can perform actions on reported accounts by clicking their name in the report, or searching for a username below. + </div> + + <AccountSearchForm /> + </> + ); +} + +function AccountSearchForm() { + const [searchAccount, result] = query.useSearchAccountMutation(); + + const [onAccountChange, _resetAccount, { account }] = useTextInput("account"); + + function submitSearch(e) { + e.preventDefault(); + if (account.trim().length != 0) { + searchAccount(account); + } + } + + return ( + <div className="account-search"> + <form onSubmit={submitSearch}> + <div className="form-field text"> + <label htmlFor="url"> + Account: + </label> + <div className="row"> + <input + type="text" + id="account" + name="account" + onChange={onAccountChange} + value={account} + /> + <button disabled={result.isLoading}> + <i className={[ + "fa fa-fw", + (result.isLoading + ? "fa-refresh fa-spin" + : "fa-search") + ].join(" ")} aria-hidden="true" title="Search" /> + <span className="sr-only">Search</span> + </button> + </div> + </div> + </form> + <AccountList + isSuccess={result.isSuccess} + data={result.data} + isError={result.isError} + error={result.error} + /> + </div> + ); +} + +function AccountList({ isSuccess, data, isError, error }) { + const baseUrl = useBaseUrl(); + + if (!(isSuccess || isError)) { + return null; + } + + if (error) { + return <Error error={error} />; + } + + if (data.length == 0) { + return <b>No accounts found that match your query</b>; + } + + return ( + <> + <h2>Results:</h2> + <div className="list"> + {data.map((acc) => ( + <Link key={acc.acct} className="account entry" to={`${baseUrl}/${acc.id}`}> + {acc.display_name?.length > 0 + ? acc.display_name + : acc.username + } + <span id="username">(@{acc.acct})</span> + </Link> + ))} + </div> + </> + ); +} +\ No newline at end of file diff --git a/web/source/settings/admin/reports/detail.jsx b/web/source/settings/admin/reports/detail.jsx @@ -165,7 +165,7 @@ function ReportedToot({ toot }) { } </section> <aside className="info"> - <time datetime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time> + <time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time> </aside> </article> ); diff --git a/web/source/settings/admin/reports/index.jsx b/web/source/settings/admin/reports/index.jsx @@ -48,13 +48,6 @@ function ReportOverview({ }) { <> <h1>Reports</h1> <div> - <div className="info"> - <i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i> - <p> - <b>This interface is currently very limited</b>, only providing a basic overview. <br /> - Work is in progress on a more full-fledged moderation experience. - </p> - </div> <p> Here you can view and resolve reports made to your instance, originating from local and remote users. </p> diff --git a/web/source/settings/admin/reports/username.jsx b/web/source/settings/admin/reports/username.jsx @@ -20,6 +20,7 @@ "use strict"; const React = require("react"); +const { Link } = require("wouter"); module.exports = function Username({ user, link = true }) { let className = "user"; @@ -41,12 +42,12 @@ module.exports = function Username({ user, link = true }) { let href = null; if (link) { - Element = "a"; - href = user.account.url; + Element = Link; + href = `/settings/admin/accounts/${user.id}`; } return ( - <Element className={className} href={href} target="_blank" rel="noreferrer" > + <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> diff --git a/web/source/settings/components/error.jsx b/web/source/settings/components/error.jsx @@ -31,12 +31,14 @@ function ErrorFallback({ error, resetErrorBoundary }) { <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>. <br />Include the details below: </p> - <pre> - {error.name}: {error.message} - </pre> - <pre> - {error.stack} - </pre> + <div className="details"> + <pre> + {error.name}: {error.message} + </pre> + <pre> + {error.stack} + </pre> + </div> <p> <button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a> </p> diff --git a/web/source/settings/components/fake-profile.jsx b/web/source/settings/components/fake-profile.jsx @@ -29,7 +29,7 @@ module.exports = function FakeProfile({ avatar, header, display_name, username, <img src={header} alt={header ? `header image for ${username}` : "None set"} /> </div> <div className="basic-info" aria-hidden="true"> - <a className="avatar" href="{{.account.Avatar}}"> + <a className="avatar" href={avatar}> <img src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /> </a> <span className="displayname text-cutoff"> @@ -37,9 +37,9 @@ module.exports = function FakeProfile({ avatar, header, display_name, username, <span className="sr-only">.</span> </span> <span className="username text-cutoff">@{username}</span> - {(role && role != "user") && - <div className={`role ${role}`}> - <span className="sr-only">Role: </span>{role} + {(role && role.name != "user") && + <div className={`role ${role.name}`}> + <span className="sr-only">Role: </span>{role.name} </div> } </div> diff --git a/web/source/settings/index.js b/web/source/settings/index.js @@ -44,6 +44,7 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [ permissions: ["admin"] }, [ Item("Reports", { icon: "fa-flag", wildcard: true }, require("./admin/reports")), + Item("Accounts", { icon: "fa-users", wildcard: true }, require("./admin/accounts")), Menu("Federation", { icon: "fa-hubzilla" }, [ Item("Federation", { icon: "fa-hubzilla", url: "", wildcard: true }, require("./admin/federation")), Item("Import/Export", { icon: "fa-floppy-o", wildcard: true }, require("./admin/federation/import-export")), diff --git a/web/source/settings/lib/navigation/components.jsx b/web/source/settings/lib/navigation/components.jsx @@ -21,11 +21,8 @@ const React = require("react"); const { Link, Route, Redirect, Switch, useLocation, useRouter } = require("wouter"); -const { ErrorBoundary } = require("react-error-boundary"); const syncpipe = require("syncpipe"); -const { ErrorFallback } = require("../../components/error"); - const { RoleContext, useHasPermission, @@ -72,8 +69,8 @@ function ViewRouter(routing, defaultRoute) { (_) => _.map((route) => { return ( <Route path={route.routingUrl} key={route.key}> - <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> - {/* FIXME: implement onReset */} + <ErrorBoundary> + {/* FIXME: implement reset */} <BaseUrlContext.Provider value={route.url}> {route.view} </BaseUrlContext.Provider> @@ -134,6 +131,71 @@ function MenuComponent({ type, name, url, icon, permissions, links, level, child ); } +class ErrorBoundary extends React.Component { + + constructor() { + super(); + this.state = {}; + + this.resetErrorBoundary = () => { + this.setState({}); + }; + } + + static getDerivedStateFromError(error) { + return { hadError: true, error }; + } + + componentDidCatch(_e, info) { + this.setState({ + ...this.state, + componentStack: info.componentStack + }); + } + + render() { + if (this.state.hadError) { + return ( + <ErrorFallback + error={this.state.error} + componentStack={this.state.componentStack} + resetErrorBoundary={this.resetErrorBoundary} + /> + ); + } else { + return this.props.children; + } + } +} + +function ErrorFallback({ error, componentStack, resetErrorBoundary }) { + return ( + <div className="error"> + <p> + {"An error occured, please report this on the "} + <a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a> + {" or "} + <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>. + <br />Include the details below: + </p> + <div className="details"> + <pre> + {error.name}: {error.message} + + {componentStack && [ + "\n\nComponent trace:", + componentStack + ]} + {["\n\nError trace: ", error.stack]} + </pre> + </div> + <p> + <button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a> + </p> + </div> + ); +} + module.exports = { Sidebar, ViewRouter, diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js @@ -78,6 +78,32 @@ const endpoints = (build) => ({ } }) }), + getAccount: build.query({ + query: (id) => ({ + url: `/api/v1/accounts/${id}` + }), + providesTags: (_, __, id) => [{ type: "Account", id }] + }), + actionAccount: build.mutation({ + query: ({ id, action, reason }) => ({ + method: "POST", + url: `/api/v1/admin/accounts/${id}/action`, + asForm: true, + body: { + type: action, + text: reason + } + }), + invalidatesTags: (_, __, { id }) => [{ type: "Account", id }] + }), + searchAccount: build.mutation({ + query: (username) => ({ + url: `/api/v2/search?q=${encodeURIComponent(username)}&resolve=true` + }), + transformResponse: (res) => { + return res.accounts ?? []; + } + }), ...require("./import-export")(build), ...require("./custom-emoji")(build), ...require("./reports")(build) diff --git a/web/source/settings/lib/query/base.js b/web/source/settings/lib/query/base.js @@ -73,7 +73,7 @@ function instanceBasedQuery(args, api, extraOptions) { module.exports = createApi({ reducerPath: "api", baseQuery: instanceBasedQuery, - tagTypes: ["Auth", "Emoji", "Reports"], + tagTypes: ["Auth", "Emoji", "Reports", "Account"], endpoints: (build) => ({ instance: build.query({ query: () => ({ diff --git a/web/source/settings/style.css b/web/source/settings/style.css @@ -61,6 +61,7 @@ header { background: $bg-accent; padding: 2rem; border-radius: $br; + max-width: 100%; & > div, & > form { border-left: 0.2rem solid $border-accent; @@ -92,6 +93,10 @@ header { padding-left: 0; } } + + & > .error { + display: grid; /* prevents error overflowing */ + } } .sidebar { @@ -250,11 +255,20 @@ input, select, textarea { font-weight: bold; padding: 0.5rem; white-space: pre-wrap; + position: relative; a { color: $error-link; } + .details { + max-width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + pre { background: $bg; color: $fg; @@ -395,6 +409,7 @@ section.with-sidebar > div, section.with-sidebar > form { .user-profile { .overview { display: grid; + max-width: 60rem; grid-template-columns: 70% 30%; grid-template-rows: 100%; gap: 1rem; @@ -1062,6 +1077,42 @@ button.with-padding { } } +.account-search { + form { + margin-bottom: 1rem; + } + + .list { + margin: 0.5rem 0; + + a { + color: $fg; + text-decoration: none; + + #username { + color: $link-fg; + margin-left: 0.5em; + } + } + } +} + +.account-detail { + display: flex; + flex-direction: column; + gap: 1rem; + + .profile { + overflow: hidden; + max-width: 60rem; + } + + .action-buttons { + display: flex; + gap: 0.5rem; + } +} + @media screen and (orientation: portrait) { .reports .report .byline { grid-template-columns: 1fr; diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.js @@ -91,7 +91,7 @@ function UserProfileForm({ data: profile }) { header={form.header.previewValue ?? profile.header} display_name={form.displayName.value ?? profile.username} username={profile.username} - role={profile.role.name} + role={profile.role} /> <div className="files"> <div> diff --git a/web/source/yarn.lock b/web/source/yarn.lock @@ -4605,13 +4605,6 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" -react-error-boundary@^3.1.4: - version "3.1.4" - resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" - integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== - dependencies: - "@babel/runtime" "^7.12.5" - react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"