[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:
parent
b47661f033
commit
89dcbd5a20
|
@ -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
|
||||
// -
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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: () => ({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue