[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

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