[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
This commit is contained in:
f0x52 2023-05-13 12:17:22 +02:00 committed by GitHub
parent b47661f033
commit 89dcbd5a20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 419 additions and 37 deletions

View file

@ -53,7 +53,7 @@ import (
// - // -
// name: type // name: type
// in: formData // 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 // type: string
// required: true // required: true
// - // -

View file

@ -28,7 +28,6 @@
"psl": "^1.9.0", "psl": "^1.9.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-redux": "^8.0.4", "react-redux": "^8.0.4",
"redux": "^4.2.0", "redux": "^4.2.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -165,7 +165,7 @@ function ReportedToot({ toot }) {
} }
</section> </section>
<aside className="info"> <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> </aside>
</article> </article>
); );

View file

@ -48,13 +48,6 @@ function ReportOverview({ }) {
<> <>
<h1>Reports</h1> <h1>Reports</h1>
<div> <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> <p>
Here you can view and resolve reports made to your instance, originating from local and remote users. Here you can view and resolve reports made to your instance, originating from local and remote users.
</p> </p>

View file

@ -20,6 +20,7 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const { Link } = require("wouter");
module.exports = function Username({ user, link = true }) { module.exports = function Username({ user, link = true }) {
let className = "user"; let className = "user";
@ -41,12 +42,12 @@ module.exports = function Username({ user, link = true }) {
let href = null; let href = null;
if (link) { if (link) {
Element = "a"; Element = Link;
href = user.account.url; href = `/settings/admin/accounts/${user.id}`;
} }
return ( return (
<Element className={className} href={href} target="_blank" rel="noreferrer" > <Element className={className} to={href}>
<span className="acct">@{user.account.acct}</span> <span className="acct">@{user.account.acct}</span>
<i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} /> <i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} />
<span className="sr-only">{icon.info}</span> <span className="sr-only">{icon.info}</span>

View file

@ -31,12 +31,14 @@ function ErrorFallback({ error, resetErrorBoundary }) {
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>. <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
<br />Include the details below: <br />Include the details below:
</p> </p>
<pre> <div className="details">
{error.name}: {error.message} <pre>
</pre> {error.name}: {error.message}
<pre> </pre>
{error.stack} <pre>
</pre> {error.stack}
</pre>
</div>
<p> <p>
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a> <button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
</p> </p>

View file

@ -29,7 +29,7 @@ module.exports = function FakeProfile({ avatar, header, display_name, username,
<img src={header} alt={header ? `header image for ${username}` : "None set"} /> <img src={header} alt={header ? `header image for ${username}` : "None set"} />
</div> </div>
<div className="basic-info" aria-hidden="true"> <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"} /> <img src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} />
</a> </a>
<span className="displayname text-cutoff"> <span className="displayname text-cutoff">
@ -37,9 +37,9 @@ module.exports = function FakeProfile({ avatar, header, display_name, username,
<span className="sr-only">.</span> <span className="sr-only">.</span>
</span> </span>
<span className="username text-cutoff">@{username}</span> <span className="username text-cutoff">@{username}</span>
{(role && role != "user") && {(role && role.name != "user") &&
<div className={`role ${role}`}> <div className={`role ${role.name}`}>
<span className="sr-only">Role: </span>{role} <span className="sr-only">Role: </span>{role.name}
</div> </div>
} }
</div> </div>

View file

@ -44,6 +44,7 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
permissions: ["admin"] permissions: ["admin"]
}, [ }, [
Item("Reports", { icon: "fa-flag", wildcard: true }, require("./admin/reports")), 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" }, [ Menu("Federation", { icon: "fa-hubzilla" }, [
Item("Federation", { icon: "fa-hubzilla", url: "", wildcard: true }, require("./admin/federation")), 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")), Item("Import/Export", { icon: "fa-floppy-o", wildcard: true }, require("./admin/federation/import-export")),

View file

@ -21,11 +21,8 @@
const React = require("react"); const React = require("react");
const { Link, Route, Redirect, Switch, useLocation, useRouter } = require("wouter"); const { Link, Route, Redirect, Switch, useLocation, useRouter } = require("wouter");
const { ErrorBoundary } = require("react-error-boundary");
const syncpipe = require("syncpipe"); const syncpipe = require("syncpipe");
const { ErrorFallback } = require("../../components/error");
const { const {
RoleContext, RoleContext,
useHasPermission, useHasPermission,
@ -72,8 +69,8 @@ function ViewRouter(routing, defaultRoute) {
(_) => _.map((route) => { (_) => _.map((route) => {
return ( return (
<Route path={route.routingUrl} key={route.key}> <Route path={route.routingUrl} key={route.key}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> <ErrorBoundary>
{/* FIXME: implement onReset */} {/* FIXME: implement reset */}
<BaseUrlContext.Provider value={route.url}> <BaseUrlContext.Provider value={route.url}>
{route.view} {route.view}
</BaseUrlContext.Provider> </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 = { module.exports = {
Sidebar, Sidebar,
ViewRouter, ViewRouter,

View file

@ -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("./import-export")(build),
...require("./custom-emoji")(build), ...require("./custom-emoji")(build),
...require("./reports")(build) ...require("./reports")(build)

View file

@ -73,7 +73,7 @@ function instanceBasedQuery(args, api, extraOptions) {
module.exports = createApi({ module.exports = createApi({
reducerPath: "api", reducerPath: "api",
baseQuery: instanceBasedQuery, baseQuery: instanceBasedQuery,
tagTypes: ["Auth", "Emoji", "Reports"], tagTypes: ["Auth", "Emoji", "Reports", "Account"],
endpoints: (build) => ({ endpoints: (build) => ({
instance: build.query({ instance: build.query({
query: () => ({ query: () => ({

View file

@ -61,6 +61,7 @@ header {
background: $bg-accent; background: $bg-accent;
padding: 2rem; padding: 2rem;
border-radius: $br; border-radius: $br;
max-width: 100%;
& > div, & > form { & > div, & > form {
border-left: 0.2rem solid $border-accent; border-left: 0.2rem solid $border-accent;
@ -92,6 +93,10 @@ header {
padding-left: 0; padding-left: 0;
} }
} }
& > .error {
display: grid; /* prevents error overflowing */
}
} }
.sidebar { .sidebar {
@ -250,11 +255,20 @@ input, select, textarea {
font-weight: bold; font-weight: bold;
padding: 0.5rem; padding: 0.5rem;
white-space: pre-wrap; white-space: pre-wrap;
position: relative;
a { a {
color: $error-link; color: $error-link;
} }
.details {
max-width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
pre { pre {
background: $bg; background: $bg;
color: $fg; color: $fg;
@ -395,6 +409,7 @@ section.with-sidebar > div, section.with-sidebar > form {
.user-profile { .user-profile {
.overview { .overview {
display: grid; display: grid;
max-width: 60rem;
grid-template-columns: 70% 30%; grid-template-columns: 70% 30%;
grid-template-rows: 100%; grid-template-rows: 100%;
gap: 1rem; 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) { @media screen and (orientation: portrait) {
.reports .report .byline { .reports .report .byline {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -91,7 +91,7 @@ function UserProfileForm({ data: profile }) {
header={form.header.previewValue ?? profile.header} header={form.header.previewValue ?? profile.header}
display_name={form.displayName.value ?? profile.username} display_name={form.displayName.value ?? profile.username}
username={profile.username} username={profile.username}
role={profile.role.name} role={profile.role}
/> />
<div className="files"> <div className="files">
<div> <div>

View file

@ -4605,13 +4605,6 @@ react-dom@^18.2.0:
loose-envify "^1.1.0" loose-envify "^1.1.0"
scheduler "^0.23.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: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"