[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
// 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
// -

View file

@ -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",

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

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"} />
</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>

View file

@ -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")),

View file

@ -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,

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

View file

@ -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: () => ({

View file

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

View file

@ -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>

View file

@ -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"