[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
This commit is contained in:
tobi 2024-05-01 15:11:22 +02:00 committed by GitHub
commit 725a21b027
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1473 additions and 432 deletions

View file

@ -22,6 +22,7 @@
"nanoid": "^4.0.0",
"object-to-formdata": "^4.4.2",
"papaparse": "^5.3.2",
"parse-link-header": "^2.0.0",
"photoswipe": "^5.3.3",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"plyr": "^3.7.8",
@ -44,6 +45,7 @@
"@joepie91/eslint-config": "^1.1.1",
"@types/is-valid-domain": "^0.0.2",
"@types/papaparse": "^5.3.9",
"@types/parse-link-header": "^2.0.3",
"@types/psl": "^1.1.1",
"@types/react-dom": "^18.2.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",

View file

@ -1,82 +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 <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Link } from "wouter";
import { Error } from "./error";
import { AdminAccount } from "../lib/types/account";
import { SerializedError } from "@reduxjs/toolkit";
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
export interface AccountListProps {
isSuccess: boolean,
data: AdminAccount[] | undefined,
isLoading: boolean,
isError: boolean,
error: FetchBaseQueryError | SerializedError | undefined,
emptyMessage: string,
}
export function AccountList({
isLoading,
isSuccess,
data,
isError,
error,
emptyMessage,
}: AccountListProps) {
if (!(isSuccess || isError)) {
// Hasn't been called yet.
return null;
}
if (isLoading) {
return <i
className="fa fa-fw fa-refresh fa-spin"
aria-hidden="true"
title="Loading..."
/>;
}
if (error) {
return <Error error={error} />;
}
if (data == undefined || data.length == 0) {
return <b>{emptyMessage}</b>;
}
return (
<div className="list">
{data.map(({ account: acc }) => (
<Link
key={acc.acct}
className="account entry"
href={`/${acc.id}`}
>
{acc.display_name?.length > 0
? acc.display_name
: acc.username
}
<span id="username">(@{acc.acct})</span>
</Link>
))}
</div>
);
}

View file

@ -0,0 +1,113 @@
/*
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/>.
*/
import React, { ReactNode } from "react";
import { useLocation } from "wouter";
import { Error } from "./error";
import { SerializedError } from "@reduxjs/toolkit";
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { Links } from "parse-link-header";
import Loading from "./loading";
export interface PageableListProps<T> {
isSuccess: boolean;
items?: T[];
itemToEntry: (_item: T) => ReactNode;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: FetchBaseQueryError | SerializedError | undefined;
emptyMessage: string;
prevNextLinks?: Links | null | undefined;
}
export function PageableList<T>({
isLoading,
isFetching,
isSuccess,
items,
itemToEntry,
isError,
error,
emptyMessage,
prevNextLinks,
}: PageableListProps<T>) {
const [ location, setLocation ] = useLocation();
if (!(isSuccess || isError)) {
// Hasn't been called yet.
return null;
}
if (isLoading || isFetching) {
return <Loading />;
}
if (error) {
return <Error error={error} />;
}
// Map response to items if possible.
let content: ReactNode;
if (items == undefined || items.length == 0) {
content = <b>{emptyMessage}</b>;
} else {
content = (
<div className="entries">
{items.map(item => itemToEntry(item))}
</div>
);
}
// If it's possible to page to next and previous
// pages, instantiate button handlers for this.
let prevClick: (() => void) | undefined;
let nextClick: (() => void) | undefined;
if (prevNextLinks) {
const prev = prevNextLinks["prev"];
if (prev) {
const prevUrl = new URL(prev.url);
const prevParams = prevUrl.search;
prevClick = () => {
setLocation(location + prevParams.toString());
};
}
const next = prevNextLinks["next"];
if (next) {
const nextUrl = new URL(next.url);
const nextParams = nextUrl.search;
nextClick = () => {
setLocation(location + nextParams.toString());
};
}
}
return (
<div className="list pageable-list">
{ content }
{ prevNextLinks &&
<div className="prev-next">
{ prevClick && <button onClick={prevClick}>Previous page</button> }
{ nextClick && <button onClick={nextClick}>Next page</button> }
</div>
}
</div>
);
}

View file

@ -18,19 +18,23 @@
*/
import React from "react";
import { Link } from "wouter";
import { AdminAccount } from "../../../lib/types/account";
import { useLocation } from "wouter";
import { AdminAccount } from "../lib/types/account";
interface UsernameProps {
user: AdminAccount;
link?: string;
account: AdminAccount;
linkTo?: string;
backLocation?: string;
classNames?: string[];
}
export default function Username({ user, link }: UsernameProps) {
let className = "user";
let isLocal = user.domain == null;
export default function Username({ account, linkTo, backLocation, classNames }: UsernameProps) {
const [ _location, setLocation ] = useLocation();
let className = "username-lozenge";
let isLocal = account.domain == null;
if (user.suspended) {
if (account.suspended) {
className += " suspended";
}
@ -38,23 +42,43 @@ export default function Username({ user, link }: UsernameProps) {
className += " local";
}
if (classNames) {
className = [ className, classNames ].flat().join(" ");
}
let icon = isLocal
? { fa: "fa-home", info: "Local user" }
: { fa: "fa-external-link-square", info: "Remote user" };
const content = (
<>
<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>
&nbsp;
<span className="acct">@{account.account.acct}</span>
</>
);
if (link) {
if (linkTo) {
className += " spanlink";
return (
<Link className={className} to={link}>
<span
className={className}
onClick={() => {
// When clicking on an account, direct
// to the detail view for that account.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
{content}
</Link>
</span>
);
} else {
return (

View file

@ -59,11 +59,10 @@ export function App({ account }: AppProps) {
<ModerationRouter />
<AdminRouter />
{/*
Redirect to first part of UserRouter if
just the bare settings page is open, so
user isn't greeted with a blank page.
*/}
<Route><Redirect to="/user/profile" /></Route>
Ensure user ends up somewhere
if they just open /settings.
*/}
<Route path="/"><Redirect to="/user" /></Route>
</ErrorBoundary>
</Router>
</section>

View file

@ -20,8 +20,9 @@
import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers";
import { gtsApi } from "../gts-api";
import { listToKeyedObject } from "../transforms";
import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account";
import { AdminAccount, HandleSignupParams, SearchAccountParams, SearchAccountResp } from "../../types/account";
import { InstanceRule, MappedRules } from "../../types/rules";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
@ -65,7 +66,7 @@ const extended = gtsApi.injectEndpoints({
],
}),
searchAccounts: build.query<AdminAccount[], SearchAccountParams>({
searchAccounts: build.query<SearchAccountResp, SearchAccountParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
@ -83,10 +84,16 @@ const extended = gtsApi.injectEndpoints({
url: `/api/v2/admin/accounts${query}`
};
},
transformResponse: (apiResp: AdminAccount[], meta) => {
const accounts = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { accounts, links };
},
providesTags: (res) =>
res
? [
...res.map(({ id }) => ({ type: 'Account' as const, id })),
...res.accounts.map(({ id }) => ({ type: 'Account' as const, id })),
{ type: 'Account', id: 'LIST' },
]
: [{ type: 'Account', id: 'LIST' }],

View file

@ -24,7 +24,7 @@ import type {
FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import { serialize as serializeForm } from "object-to-formdata";
import type { FetchBaseQueryMeta } from "@reduxjs/toolkit/dist/query/fetchBaseQuery";
import type { RootState } from '../../redux/store';
import { InstanceV1 } from '../types/instance';
@ -65,7 +65,9 @@ export interface GTSFetchArgs extends FetchArgs {
const gtsBaseQuery: BaseQueryFn<
string | GTSFetchArgs,
any,
FetchBaseQueryError
FetchBaseQueryError,
{},
FetchBaseQueryMeta
> = async (args, api, extraOptions) => {
// Retrieve state at the moment
// this function was called.

View file

@ -17,6 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Links } from "parse-link-header";
import { CustomEmoji } from "./custom-emoji";
export interface AdminAccount {
@ -79,6 +80,11 @@ export interface SearchAccountParams {
limit?: number,
}
export interface SearchAccountResp {
accounts: AdminAccount[];
links: Links | null;
}
export interface HandleSignupParams {
id: string,
approve_or_reject: "approve" | "reject",

View file

@ -16,6 +16,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
This source file uses PostCSS syntax.
See: https://postcss.org/
*/
body {
grid-template-rows: auto 1fr;
}
@ -521,6 +526,22 @@ span.form-info {
}
}
.pageable-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
.entries {
color: $fg;
border: 0.1rem solid var(--gray1);
}
.prev-next {
display: flex;
justify-content: space-between;
}
}
.domain-permissions-list {
p {
margin-top: 0;
@ -1098,49 +1119,58 @@ button.with-padding {
}
}
}
}
.user {
line-height: 1.3rem;
display: inline-block;
background: $fg-accent;
color: $bg;
border-radius: $br;
padding: 0.15rem 0.15rem;
margin: 0 0.1rem;
font-weight: bold;
text-decoration: none;
.username-lozenge {
line-height: 1.3rem;
display: inline-block;
background: $fg-accent;
color: $bg;
border-radius: $br;
padding: 0.15rem;
font-weight: bold;
text-decoration: none;
.acct {
word-break: break-all;
}
.acct {
word-break: break-all;
}
&.suspended {
background: $bg-accent;
color: $fg;
text-decoration: line-through;
}
&.suspended {
background: $bg-accent;
color: $fg;
text-decoration: line-through;
}
&.local {
background: $green1;
}
&.local {
background: $green1;
}
}
.spanlink {
cursor: pointer;
text-decoration: none;
}
.accounts-view {
form {
margin-bottom: 1rem;
}
.list {
margin: 0.5rem 0;
a {
.pageable-list {
.username-lozenge {
line-height: inherit;
color: $fg;
text-decoration: none;
font-weight: initial;
width: 100%;
border-radius: 0;
background: $list-entry-bg;
.fa {
align-self: center;
}
&:nth-child(even) {
background: $list-entry-alternate-bg;
}
#username {
color: $link-fg;
margin-left: 0.5em;
.acct {
color: var(--link-fg);
}
}
}
@ -1154,6 +1184,7 @@ button.with-padding {
.profile {
overflow: hidden;
max-width: 60rem;
margin-top: 1rem;
}
h4, h3, h2 {
@ -1185,6 +1216,16 @@ button.with-padding {
dd {
word-break: break-word;
}
dt, dd {
/*
Make sure any fa icons used in keys
or values are properly aligned.
*/
.fa {
vertical-align: middle;
}
}
}
}

View file

@ -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 (
<HandleSignup
account={account}
backLocation={backLocation}
/>
);
default:
// Normal local or remote account, show
// full range of moderation options.
return <ModerateAccount account={account} />;
}
}
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 (
<form
onSubmit={accountAction}
@ -60,16 +88,6 @@ export function AccountActions({ account }: AccountActionsProps) {
placeholder="Reason for this action"
/>
<div className="action-buttons">
{/* <MutationButton
label="Disable"
name="disable"
result={result}
/>
<MutationButton
label="Silence"
name="silence"
result={result}
/> */}
<MutationButton
disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false}
label="Suspend"
@ -84,3 +102,81 @@ export function AccountActions({ account }: AccountActionsProps) {
</form>
);
}
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 (
<form
onSubmit={handleSignup}
aria-labelledby="account-handle-signup"
>
<h3 id="account-handle-signup">Handle Account Sign-Up</h3>
<Select
field={form.approveOrReject}
label="Approve or Reject"
options={
<>
<option value="approve">Approve</option>
<option value="reject">Reject</option>
</>
}
>
</Select>
{ form.approveOrReject.value === "reject" &&
// Only show form fields relevant
// to "reject" if rejecting.
// On "approve" these fields will
// be ignored anyway.
<>
<TextInput
field={form.privateComment}
label="(Optional) private comment on why sign-up was rejected (shown to other admins only)"
/>
<Checkbox
field={form.sendEmail}
label="Send email to applicant"
/>
<TextInput
field={form.message}
label={"(Optional) message to include in email to applicant, if send email is checked"}
/>
</> }
<MutationButton
disabled={false}
label={form.approveOrReject.value === "approve" ? "Approve" : "Reject"}
result={result}
/>
</form>
);
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<form
onSubmit={handleSignup}
aria-labelledby="account-handle-signup"
>
<h3 id="account-handle-signup">Handle Account Sign-Up</h3>
<Select
field={form.approveOrReject}
label="Approve or Reject"
options={
<>
<option value="approve">Approve</option>
<option value="reject">Reject</option>
</>
}
>
</Select>
{ form.approveOrReject.value === "reject" &&
// Only show form fields relevant
// to "reject" if rejecting.
// On "approve" these fields will
// be ignored anyway.
<>
<TextInput
field={form.privateComment}
label="(Optional) private comment on why sign-up was rejected (shown to other admins only)"
/>
<Checkbox
field={form.sendEmail}
label="Send email to applicant"
/>
<TextInput
field={form.message}
label={"(Optional) message to include in email to applicant, if send email is checked"}
/>
</> }
<MutationButton
disabled={false}
label={form.approveOrReject.value === "approve" ? "Approve" : "Reject"}
result={result}
/>
</form>
);
}

View file

@ -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 (
<div className="account-detail">
<h1>Account Details</h1>
<h1><BackButton to={backLocation} /> Account Details</h1>
<FormWithData
dataQuery={useGetAccountQuery}
queryArg={params.accountID}
DataForm={AccountDetailForm}
{...{ backLocation: backLocation }}
/>
</div>
);
}
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 (
<>
<FakeProfile {...adminAcct.account} />
<div className="info">
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
<b>
This is the service account for your instance; you
cannot perform moderation actions on this account.
</b>
</div>
</>
);
}
let created = new Date(adminAcct.created_at).toDateString();
const local = !adminAcct.domain;
return (
<>
<FakeProfile {...adminAcct.account} />
<GeneralAccountDetails adminAcct={adminAcct} />
{
// Only show local account details
// if this is a local account!
local && <LocalAccountDetails adminAcct={adminAcct} />
}
<AccountActions
account={adminAcct}
backLocation={backLocation}
/>
</>
);
}
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 (
<>
<FakeProfile {...adminAcct.account} />
<h3>General Account Details</h3>
{ adminAcct.suspended &&
<div className="info">
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
<b>Account is suspended.</b>
</div>
{ adminAcct.suspended &&
<div className="info">
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
<b>Account is suspended.</b>
</div>
}
<dl className="info-list">
{ !local &&
@ -75,6 +113,18 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
<dt>Domain</dt>
<dd>{adminAcct.domain}</dd>
</div>}
<div className="info-list-entry">
<dt>Profile URL</dt>
<dd>
<a
href={adminAcct.account.url}
target="_blank"
rel="noreferrer"
>
<i className="fa fa-fw fa-external-link" aria-hidden="true"></i> {adminAcct.account.url} (opens in a new tab)
</a>
</dd>
</div>
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={adminAcct.created_at}>{created}</time></dd>
@ -104,61 +154,54 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
<dd>{adminAcct.account.following_count}</dd>
</div>
</dl>
{ local &&
// Only show local account details
// if this is a local account!
<>
<h3>Local Account Details</h3>
{ !adminAcct.approved &&
</>
);
}
function LocalAccountDetails({ adminAcct }: { adminAcct: AdminAccount }) {
return (
<>
<h3>Local Account Details</h3>
{ !adminAcct.approved &&
<div className="info">
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
<b>Account is pending.</b>
</div>
}
{ !adminAcct.confirmed &&
}
{ !adminAcct.confirmed &&
<div className="info">
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
<b>Account email not yet confirmed.</b>
</div>
}
<dl className="info-list">
<div className="info-list-entry">
<dt>Email</dt>
<dd>{adminAcct.email} {<b>{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"}</b> }</dd>
</div>
<div className="info-list-entry">
<dt>Disabled</dt>
<dd>{yesOrNo(adminAcct.disabled)}</dd>
</div>
<div className="info-list-entry">
<dt>Approved</dt>
<dd>{yesOrNo(adminAcct.approved)}</dd>
</div>
<div className="info-list-entry">
<dt>Sign-Up Reason</dt>
<dd>{adminAcct.invite_request ?? <i>none provided</i>}</dd>
</div>
{ (adminAcct.ip && adminAcct.ip !== "0.0.0.0") &&
}
<dl className="info-list">
<div className="info-list-entry">
<dt>Email</dt>
<dd>{adminAcct.email} {<b>{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"}</b> }</dd>
</div>
<div className="info-list-entry">
<dt>Disabled</dt>
<dd>{yesOrNo(adminAcct.disabled)}</dd>
</div>
<div className="info-list-entry">
<dt>Approved</dt>
<dd>{yesOrNo(adminAcct.approved)}</dd>
</div>
<div className="info-list-entry">
<dt>Sign-Up Reason</dt>
<dd>{adminAcct.invite_request ?? <i>none provided</i>}</dd>
</div>
{ (adminAcct.ip && adminAcct.ip !== "0.0.0.0") &&
<div className="info-list-entry">
<dt>Sign-Up IP</dt>
<dd>{adminAcct.ip}</dd>
</div> }
{ adminAcct.locale &&
{ adminAcct.locale &&
<div className="info-list-entry">
<dt>Locale</dt>
<dd>{adminAcct.locale}</dd>
</div> }
</dl>
</> }
{ local && !adminAcct.approved
?
<HandleSignup
account={adminAcct}
backLocation={backLocation}
/>
:
<AccountActions account={adminAcct} />
}
</>
</dl>
</>
);
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}

View file

@ -20,10 +20,10 @@
import React from "react";
import { AccountSearchForm } from "./search";
export default function AccountsOverview({ }) {
export default function AccountsSearch({ }) {
return (
<div className="accounts-view">
<h1>Accounts Overview</h1>
<h1>Accounts Search</h1>
<span>
You can perform actions on an account by clicking
its name in a report, or by searching for the account

View file

@ -17,20 +17,40 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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 (
<Username
key={acc.acct}
account={account}
linkTo={`/${account.id}`}
backLocation={location}
classNames={["entry"]}
/>
);
}
return (
<div className="accounts-view">
<h1>Pending Accounts</h1>
<AccountList
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
data={searchRes.data}
items={searchRes.data?.accounts}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage="No pending account sign-ups."

View file

@ -17,28 +17,53 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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 (
<Username
key={acc.acct}
account={account}
linkTo={`/${account.id}`}
backLocation={backLocation}
classNames={["entry"]}
/>
);
}
return (
<>
<form
onSubmit={submitSearch}
onSubmit={submitQuery}
// Prevent password managers trying
// to fill in username/email fields.
autoComplete="off"
@ -117,13 +158,16 @@ export function AccountSearchForm() {
result={searchRes}
/>
</form>
<AccountList
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
data={searchRes.data}
items={searchRes.data?.accounts}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage="No accounts found that match your query"
prevNextLinks={searchRes.data?.links}
/>
</>
);

View file

@ -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() {
<MenuItem
name="Accounts"
itemUrl="accounts"
defaultChild="overview"
defaultChild="search"
icon="fa-users"
>
<MenuItem
name="Overview"
itemUrl="overview"
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem

View file

@ -25,7 +25,7 @@ import { useValue, useTextInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { TextArea } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import Username from "./username";
import Username from "../../../components/username";
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
import { useBaseUrl } from "../../../lib/navigation/util";
@ -53,13 +53,15 @@ function ReportDetailForm({ data: report }) {
<div className="report detail">
<div className="usernames">
<Username
user={from}
link={`~/settings/moderation/accounts/${from.id}`}
account={from}
linkTo={`~/settings/moderation/accounts/${from.id}`}
backLocation={`~/settings/moderation/reports/${report.id}`}
/>
<> reported </>
<Username
user={target}
link={`~/settings/moderation/accounts/${target.id}`}
account={target}
linkTo={`~/settings/moderation/accounts/${target.id}`}
backLocation={`~/settings/moderation/reports/${report.id}`}
/>
</div>

View file

@ -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 }) {
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
<div className="byline">
<div className="usernames">
<Username user={from} /> reported <Username user={target} />
<Username account={from} /> reported <Username account={target} />
</div>
<h3 className="report-status">
{report.action_taken ? "Resolved" : "Open"}

View file

@ -26,7 +26,7 @@ import { ErrorBoundary } from "../../lib/navigation/error";
import ImportExport from "./domain-permissions/import-export";
import DomainPermissionsOverview from "./domain-permissions/overview";
import DomainPermDetail from "./domain-permissions/detail";
import AccountsOverview from "./accounts";
import AccountsSearch from "./accounts";
import AccountsPending from "./accounts/pending";
import AccountDetail from "./accounts/detail";
@ -37,7 +37,7 @@ import AccountDetail from "./accounts/detail";
/**
* - /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
@ -95,7 +95,7 @@ function ModerationReportsRouter() {
}
/**
* - /settings/moderation/accounts/overview
* - /settings/moderation/accounts/search
* - /settings/moderation/accounts/pending
* - /settings/moderation/accounts/:accountID
*/
@ -109,10 +109,10 @@ function ModerationAccountsRouter() {
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/overview" component={AccountsOverview}/>
<Route path="/search" component={AccountsSearch}/>
<Route path="/pending" component={AccountsPending}/>
<Route path="/:accountID" component={AccountDetail}/>
<Route><Redirect to="/overview"/></Route>
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>

View file

@ -1468,6 +1468,11 @@
dependencies:
"@types/node" "*"
"@types/parse-link-header@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/parse-link-header/-/parse-link-header-2.0.3.tgz#37ad650d12aecb055b64c2d43ddb1534e356ad33"
integrity sha512-ffLAxD6Xqcf2gSbtEJehj8yJ5R/2OZqD4liodQvQQ+hhO4kg1mk9ToEZQPMtNTm/zIQj2GNleQbsjPp9+UQm4Q==
"@types/prop-types@*":
version "15.7.8"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3"
@ -5182,6 +5187,13 @@ parse-json@^2.2.0:
dependencies:
error-ex "^1.2.0"
parse-link-header@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-2.0.0.tgz#949353e284f8aa01f2ac857a98f692b57733f6b7"
integrity sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw==
dependencies:
xtend "~4.0.1"
parse-ms@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"