[chore] Refactor settings panel routing (and other fixes) (#2864)

This commit is contained in:
tobi 2024-04-24 12:12:47 +02:00 committed by GitHub
commit 7a1e639483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1788 additions and 1445 deletions

View file

@ -0,0 +1,89 @@
/*
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 { useActionAccountMutation } from "../../../../lib/query";
import MutationButton from "../../../../components/form/mutation-button";
import useFormSubmit from "../../../../lib/form/submit";
import {
useValue,
useTextInput,
useBoolInput,
} from "../../../../lib/form";
import { Checkbox, TextInput } from "../../../../components/form/inputs";
import { AdminAccount } from "../../../../lib/types/account";
export interface AccountActionsProps {
account: AdminAccount,
}
export function AccountActions({ account }: AccountActionsProps) {
const form = {
id: useValue("id", account.id),
reason: useTextInput("text")
};
const reallySuspend = useBoolInput("reallySuspend");
const [accountAction, result] = useFormSubmit(form, useActionAccountMutation());
return (
<form
onSubmit={accountAction}
aria-labelledby="account-moderation-actions"
>
<h3 id="account-moderation-actions">Account Moderation Actions</h3>
<div>
Currently only the "suspend" action is implemented.<br/>
Suspending an account will delete it from your server, and remove all of its media, posts, relationships, etc.<br/>
If the suspended account is local, suspending will also send out a "delete" message to other servers, requesting them to remove its data from their instance as well.<br/>
<b>Account suspension cannot be reversed.</b>
</div>
<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
disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false}
label="Suspend"
name="suspend"
result={result}
/>
<Checkbox
label="Really suspend"
field={reallySuspend}
></Checkbox>
</div>
</form>
);
}

View file

@ -0,0 +1,118 @@
/*
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";
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

@ -0,0 +1,167 @@
/*
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 { useGetAccountQuery } from "../../../../lib/query";
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";
export default function AccountDetail() {
const params: { accountID: string } = useParams();
return (
<div className="account-detail">
<h1>Account Details</h1>
<FormWithData
dataQuery={useGetAccountQuery}
queryArg={params.accountID}
DataForm={AccountDetailForm}
/>
</div>
);
}
interface AccountDetailFormProps {
backLocation: string,
data: AdminAccount,
}
function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
let yesOrNo = (b: boolean) => {
return b ? "yes" : "no";
};
let 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>
}
<dl className="info-list">
{ !local &&
<div className="info-list-entry">
<dt>Domain</dt>
<dd>{adminAcct.domain}</dd>
</div>}
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={adminAcct.created_at}>{created}</time></dd>
</div>
<div className="info-list-entry">
<dt>Last posted</dt>
<dd>{lastPosted}</dd>
</div>
<div className="info-list-entry">
<dt>Suspended</dt>
<dd>{yesOrNo(adminAcct.suspended)}</dd>
</div>
<div className="info-list-entry">
<dt>Silenced</dt>
<dd>{yesOrNo(adminAcct.silenced)}</dd>
</div>
<div className="info-list-entry">
<dt>Statuses</dt>
<dd>{adminAcct.account.statuses_count}</dd>
</div>
<div className="info-list-entry">
<dt>Followers</dt>
<dd>{adminAcct.account.followers_count}</dd>
</div>
<div className="info-list-entry">
<dt>Following</dt>
<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 &&
<div className="info">
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
<b>Account is pending.</b>
</div>
}
{ !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") &&
<div className="info-list-entry">
<dt>Sign-Up IP</dt>
<dd>{adminAcct.ip}</dd>
</div> }
{ 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} />
}
</>
);
}

View file

@ -0,0 +1,35 @@
/*
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 { AccountSearchForm } from "./search";
export default function AccountsOverview({ }) {
return (
<div className="accounts-view">
<h1>Accounts Overview</h1>
<span>
You can perform actions on an account by clicking
its name in a report, or by searching for the account
using the form below and clicking on its name.
</span>
<AccountSearchForm />
</div>
);
}

View file

@ -0,0 +1,40 @@
/*
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 { useSearchAccountsQuery } from "../../../../lib/query";
import { AccountList } from "../../../../components/account-list";
export default function AccountsPending() {
const searchRes = useSearchAccountsQuery({status: "pending"});
return (
<div className="accounts-view">
<h1>Pending Accounts</h1>
<AccountList
isLoading={searchRes.isLoading}
isSuccess={searchRes.isSuccess}
data={searchRes.data}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage="No pending account sign-ups."
/>
</div>
);
}

View file

@ -0,0 +1,131 @@
/*
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 { useLazySearchAccountsQuery } from "../../../../lib/query";
import { useTextInput } from "../../../../lib/form";
import { AccountList } from "../../../../components/account-list";
import { SearchAccountParams } from "../../../../lib/types/account";
import { Select, TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
export function AccountSearchForm() {
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"),
};
function submitSearch(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined || v.value.length === 0) {
return null;
}
return [[k, v.value]];
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const params: SearchAccountParams = Object.fromEntries(entries);
searchAcct(params);
}
const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
return (
<>
<form
onSubmit={submitSearch}
// Prevent password managers trying
// to fill in username/email fields.
autoComplete="off"
>
<TextInput
field={form.username}
label={"(Optional) username (without leading '@' symbol)"}
placeholder="someone"
/>
<TextInput
field={form.by_domain}
label={"(Optional) domain"}
placeholder="example.org"
/>
<Select
field={form.origin}
label="Account origin"
options={
<>
<option value="">Local or remote</option>
<option value="local">Local only</option>
<option value="remote">Remote only</option>
</>
}
></Select>
<TextInput
field={form.email}
label={"(Optional) email address (local accounts only)"}
placeholder={"someone@example.org"}
// Get email validation for free.
{...{type: "email"}}
/>
<TextInput
field={form.ip}
label={"(Optional) IP address (local accounts only)"}
placeholder={"198.51.100.0"}
/>
<Select
field={form.status}
label="Account status"
options={
<>
<option value="">Any</option>
<option value="pending">Pending only</option>
<option value="disabled">Disabled only</option>
<option value="suspended">Suspended only</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<AccountList
isLoading={searchRes.isLoading}
isSuccess={searchRes.isSuccess}
data={searchRes.data}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage="No accounts found that match your query"
/>
</>
);
}

View file

@ -0,0 +1,262 @@
/*
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 { useMemo } from "react";
import { useLocation, useParams, useSearch } from "wouter";
import { useTextInput, useBoolInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { TextInput, Checkbox, TextArea } from "../../../components/form/inputs";
import Loading from "../../../components/loading";
import BackButton from "../../../components/back-button";
import MutationButton from "../../../components/form/mutation-button";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update";
import { DomainPerm, PermType } from "../../../lib/types/domain-permission";
import { NoArg } from "../../../lib/types/query";
import { Error } from "../../../components/error";
import { useBaseUrl } from "../../../lib/navigation/util";
export default function DomainPermDetail() {
const baseUrl = useBaseUrl();
// Parse perm type from routing params.
let params = useParams();
if (params.permType !== "blocks" && params.permType !== "allows") {
throw "unrecognized perm type " + params.permType;
}
const permType = params.permType.slice(0, -1) as PermType;
const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
let isLoading;
switch (permType) {
case "block":
isLoading = isLoadingDomainBlocks;
break;
case "allow":
isLoading = isLoadingDomainAllows;
break;
default:
throw "perm type unknown";
}
// Parse domain from routing params.
let domain = params.domain ?? "unknown";
const search = useSearch();
if (domain === "view") {
// Retrieve domain from form field submission.
const searchParams = new URLSearchParams(search);
const searchDomain = searchParams.get("domain");
if (!searchDomain) {
throw "empty view domain";
}
domain = searchDomain;
}
// Normalize / decode domain (it may be URL-encoded).
domain = decodeURIComponent(domain);
// Check if we already have a perm of the desired type for this domain.
const existingPerm: DomainPerm | undefined = useMemo(() => {
if (permType == "block") {
return domainBlocks[domain];
} else {
return domainAllows[domain];
}
}, [domainBlocks, domainAllows, domain, permType]);
let infoContent: React.JSX.Element;
if (isLoading) {
infoContent = <Loading />;
} else if (existingPerm == undefined) {
infoContent = <span>No stored {permType} yet, you can add one below:</span>;
} else {
infoContent = (
<div className="info">
<i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i>
<b>Editing domain permissions isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>
</div>
);
}
return (
<div>
<h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1>
{infoContent}
<DomainPermForm
defaultDomain={domain}
perm={existingPerm}
permType={permType}
/>
</div>
);
}
interface DomainPermFormProps {
defaultDomain: string;
perm?: DomainPerm;
permType: PermType;
}
function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps) {
const isExistingPerm = perm !== undefined;
const disabledForm = isExistingPerm
? {
disabled: true,
title: "Domain permissions currently cannot be edited."
}
: {
disabled: false,
title: "",
};
const form = {
domain: useTextInput("domain", { source: perm, defaultValue: defaultDomain }),
obfuscate: useBoolInput("obfuscate", { source: perm }),
commentPrivate: useTextInput("private_comment", { source: perm }),
commentPublic: useTextInput("public_comment", { source: perm })
};
// Check which perm type we're meant to be handling
// here, and use appropriate mutations and results.
// We can't call these hooks conditionally because
// react is like "weh" (mood), but we can decide
// which ones to use conditionally.
const [ addBlock, addBlockResult ] = useAddDomainBlockMutation();
const [ removeBlock, removeBlockResult] = useRemoveDomainBlockMutation({ fixedCacheKey: perm?.id });
const [ addAllow, addAllowResult ] = useAddDomainAllowMutation();
const [ removeAllow, removeAllowResult ] = useRemoveDomainAllowMutation({ fixedCacheKey: perm?.id });
const [
addTrigger,
addResult,
removeTrigger,
removeResult,
] = useMemo(() => {
return permType == "block"
? [
addBlock,
addBlockResult,
removeBlock,
removeBlockResult,
]
: [
addAllow,
addAllowResult,
removeAllow,
removeAllowResult,
];
}, [permType,
addBlock, addBlockResult, removeBlock, removeBlockResult,
addAllow, addAllowResult, removeAllow, removeAllowResult,
]);
// Use appropriate submission params for this permType.
const [submitForm, submitFormResult] = useFormSubmit(form, [addTrigger, addResult], { changedOnly: false });
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
const [location, setLocation] = useLocation();
function verifyUrlThenSubmit(e) {
// Adding a new domain permissions happens on a url like
// "/settings/admin/domain-permissions/:permType/domain.com",
// but if domain input changes, that doesn't match anymore
// and causes issues later on so, before submitting the form,
// silently change url, and THEN submit.
let correctUrl = `/${permType}s/${form.domain.value}`;
if (location != correctUrl) {
setLocation(correctUrl);
}
return submitForm(e);
}
return (
<form onSubmit={verifyUrlThenSubmit}>
<TextInput
field={form.domain}
label="Domain"
placeholder="example.com"
{...disabledForm}
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domain in public lists"
{...disabledForm}
/>
<TextArea
field={form.commentPrivate}
label="Private comment"
rows={3}
{...disabledForm}
/>
<TextArea
field={form.commentPublic}
label="Public comment"
rows={3}
{...disabledForm}
/>
<div className="action-buttons row">
<MutationButton
label={permTypeUpper}
result={submitFormResult}
showError={false}
{...disabledForm}
/>
{
isExistingPerm &&
<MutationButton
type="button"
onClick={() => removeTrigger(perm.id?? "")}
label="Remove"
result={removeResult}
className="button danger"
showError={false}
disabled={!isExistingPerm}
/>
}
</div>
<>
{addResult.error && <Error error={addResult.error} />}
{removeResult.error && <Error error={removeResult.error} />}
</>
</form>
);
}

View file

@ -0,0 +1,65 @@
/*
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";
export default function ExportFormatTable() {
return (
<div className="export-format-table-wrapper">
<table className="export-format-table">
<thead>
<tr>
<th rowSpan={2} />
<th colSpan={2}>Includes</th>
<th colSpan={2}>Importable by</th>
</tr>
<tr>
<th>Domain</th>
<th>Public comment</th>
<th>GoToSocial</th>
<th>Mastodon</th>
</tr>
</thead>
<tbody>
<Format name="Text" info={[true, false, true, false]} />
<Format name="JSON" info={[true, true, true, false]} />
<Format name="CSV" info={[true, true, true, true]} />
</tbody>
</table>
</div>
);
}
function Format({ name, info }) {
return (
<tr>
<td><b>{name}</b></td>
{info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)}
</tr>
);
}
function bool(val) {
return (
<>
<i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i>
<span className="sr-only">{val ? "Yes" : "No"}</span>
</>
);
}

View file

@ -0,0 +1,153 @@
/*
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 { useEffect } from "react";
import { useExportDomainListMutation } from "../../../lib/query/admin/domain-permissions/export";
import useFormSubmit from "../../../lib/form/submit";
import {
RadioGroup,
TextArea,
Select,
} from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { Error } from "../../../components/error";
import ExportFormatTable from "./export-format-table";
import type {
FormSubmitFunction,
FormSubmitResult,
RadioFormInputHook,
TextFormInputHook,
} from "../../../lib/form/types";
export interface ImportExportFormProps {
form: {
domains: TextFormInputHook;
exportType: TextFormInputHook;
permType: RadioFormInputHook;
};
submitParse: FormSubmitFunction;
parseResult: FormSubmitResult;
}
export default function ImportExportForm({ form, submitParse, parseResult }: ImportExportFormProps) {
const [submitExport, exportResult] = useFormSubmit(form, useExportDomainListMutation());
function fileChanged(e) {
const reader = new FileReader();
reader.onload = function (read) {
const res = read.target?.result;
if (typeof res === "string") {
form.domains.value = res;
submitParse();
}
};
reader.readAsText(e.target.files[0]);
}
useEffect(() => {
if (exportResult.isSuccess) {
form.domains.setter(exportResult.data);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [exportResult]);
return (
<>
<h1>Import / Export domain permissions</h1>
<p>This page can be used to import and export lists of domain permissions.</p>
<p>Exports can be done in various formats, with varying functionality and support in other software.</p>
<p>Imports will automatically detect what format is being processed.</p>
<ExportFormatTable />
<div className="import-export">
<TextArea
field={form.domains}
label="Domains"
placeholder={`google.com\nfacebook.com`}
rows={8}
/>
<RadioGroup
field={form.permType}
/>
<div className="button-grid">
<MutationButton
label="Import"
type="button"
onClick={() => submitParse()}
result={parseResult}
showError={false}
disabled={form.permType.value === undefined || form.permType.value.length === 0}
/>
<label className={`button with-icon${form.permType.value === undefined || form.permType.value.length === 0 ? " disabled" : ""}`}>
<i className="fa fa-fw " aria-hidden="true" />
Import file
<input
type="file"
className="hidden"
onChange={fileChanged}
accept="application/json,text/plain,text/csv"
disabled={form.permType.value === undefined || form.permType.value.length === 0}
/>
</label>
<b /> {/* grid filler */}
<MutationButton
label="Export"
type="button"
onClick={() => submitExport("export")}
result={exportResult} showError={false}
disabled={form.permType.value === undefined || form.permType.value.length === 0}
/>
<MutationButton
label="Export to file"
wrapperClassName="export-file-button"
type="button"
onClick={() => submitExport("export-file")}
result={exportResult}
showError={false}
disabled={form.permType.value === undefined || form.permType.value.length === 0}
/>
<div className="export-file">
<span>
as
</span>
<Select
field={form.exportType}
options={<>
<option value="plain">Text</option>
<option value="json">JSON</option>
<option value="csv">CSV</option>
</>}
/>
</div>
</div>
{parseResult.error && <Error error={parseResult.error} />}
{exportResult.error && <Error error={exportResult.error} />}
</div>
</>
);
}

View file

@ -0,0 +1,88 @@
/*
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 { Switch, Route, Redirect, useLocation } from "wouter";
import { useProcessDomainPermissionsMutation } from "../../../lib/query/admin/domain-permissions/process";
import { useTextInput, useRadioInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { ProcessImport } from "./process";
import ImportExportForm from "./form";
export default function ImportExport() {
const form = {
domains: useTextInput("domains"),
exportType: useTextInput("exportType", {
defaultValue: "plain",
dontReset: true,
}),
permType: useRadioInput("permType", {
options: {
block: "Domain blocks",
allow: "Domain allows",
}
})
};
const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false });
const [_location, setLocation] = useLocation();
return (
<Switch>
<Route path={"/process"}>
{
parseResult.isSuccess
? (
<>
<h1>
<span
className="button"
onClick={() => {
parseResult.reset();
setLocation("");
}}
>
&lt; back
</span>
&nbsp; Confirm import of domain {form.permType.value}s:
</h1>
<ProcessImport
list={parseResult.data}
permType={form.permType}
/>
</>
)
: <Redirect to={""} />
}
</Route>
<Route>
{
parseResult.isSuccess
? <Redirect to={"/process"} />
: <ImportExportForm
form={form}
submitParse={submitParse}
parseResult={parseResult}
/>
}
</Route>
</Switch>
);
}

View file

@ -0,0 +1,197 @@
/*
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 { useMemo } from "react";
import { Link, useLocation, useParams } from "wouter";
import { matchSorter } from "match-sorter";
import { useTextInput } from "../../../lib/form";
import { TextInput } from "../../../components/form/inputs";
import Loading from "../../../components/loading";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
import type { MappedDomainPerms, PermType } from "../../../lib/types/domain-permission";
import { NoArg } from "../../../lib/types/query";
export default function DomainPermissionsOverview() {
// Parse perm type from routing params.
let params = useParams();
if (params.permType !== "blocks" && params.permType !== "allows") {
throw "unrecognized perm type " + params.permType;
}
const permType = params.permType.slice(0, -1) as PermType;
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
// Fetch / wait for desired perms to load.
const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const { data: allows, isLoading: isLoadingAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
let data: MappedDomainPerms | undefined;
let isLoading: boolean;
if (permType == "block") {
data = blocks;
isLoading = isLoadingBlocks;
} else {
data = allows;
isLoading = isLoadingAllows;
}
if (isLoading || data === undefined) {
return <Loading />;
}
return (
<>
<h1>Domain {permTypeUpper}s</h1>
{ permType == "block" ? <BlockHelperText/> : <AllowHelperText/> }
<DomainPermsList
data={data}
permType={permType}
permTypeUpper={permTypeUpper}
/>
<Link to="/settings/admin/domain-permissions/import-export">
Or use the bulk import/export interface
</Link>
</>
);
}
interface DomainPermsListProps {
data: MappedDomainPerms;
permType: PermType;
permTypeUpper: string;
}
function DomainPermsList({ data, permType, permTypeUpper }: DomainPermsListProps) {
// Format perms into a list.
const perms = useMemo(() => {
return Object.values(data);
}, [data]);
const [_location, setLocation] = useLocation();
const filterField = useTextInput("filter");
function filterFormSubmit(e) {
e.preventDefault();
setLocation(`/${filter}`);
}
const filter = filterField.value ?? "";
const filteredPerms = useMemo(() => {
return matchSorter(perms, filter, { keys: ["domain"] });
}, [perms, filter]);
const filtered = perms.length - filteredPerms.length;
const filterInfo = (
<span>
{perms.length} {permType}ed domain{perms.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`}
</span>
);
const entries = filteredPerms.map((entry) => {
return (
<Link
className="entry nounderline"
key={entry.domain}
to={`/${permType}s/${entry.domain}`}
>
<span id="domain">{entry.domain}</span>
<span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span>
</Link>
);
});
return (
<div className="domain-permissions-list">
<form className="filter" role="search" onSubmit={filterFormSubmit}>
<TextInput
field={filterField}
placeholder="example.org"
label={`Search or add domain ${permType}`}
/>
<Link
className="button"
to={`/${permType}s/${filter}`}
>
{permTypeUpper}&nbsp;{filter}
</Link>
</form>
<div>
{filterInfo}
<div className="list">
<div className="entries scrolling">
{entries}
</div>
</div>
</div>
</div>
);
}
function BlockHelperText() {
return (
<p>
Blocking a domain blocks interaction between your instance, and all current and future accounts on
instance(s) running on the blocked domain. Stored content will be removed, and no more data is sent to
the remote server. This extends to all subdomains as well, so blocking 'example.com' also blocks 'social.example.com'.
<br/>
<a
href="https://docs.gotosocial.org/en/latest/admin/domain_blocks/"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about domain blocks (opens in a new tab)
</a>
<br/>
</p>
);
}
function AllowHelperText() {
return (
<p>
Allowing a domain explicitly allows instance(s) running on that domain to interact with your instance.
If you're running in allowlist mode, this is how you "allow" instances through.
If you're running in blocklist mode (the default federation mode), you can use explicit domain allows
to override domain blocks. In blocklist mode, explicitly allowed instances will be able to interact with
your instance regardless of any domain blocks in place. This extends to all subdomains as well, so allowing
'example.com' also allows 'social.example.com'. This is useful when you're importing a block list but
there are some domains on the list you don't want to block: just create an explicit allow for those domains
before importing the list.
<br/>
<a
href="https://docs.gotosocial.org/en/latest/admin/federation_modes/"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about federation modes (opens in a new tab)
</a>
</p>
);
}

View file

@ -0,0 +1,400 @@
/*
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 { memo, useMemo, useCallback, useEffect } from "react";
import { isValidDomainPermission, hasBetterScope } from "../../../lib/util/domain-permission";
import {
useTextInput,
useBoolInput,
useRadioInput,
useCheckListInput,
} from "../../../lib/form";
import {
Select,
TextArea,
RadioGroup,
Checkbox,
TextInput,
} from "../../../components/form/inputs";
import useFormSubmit from "../../../lib/form/submit";
import CheckList from "../../../components/check-list";
import MutationButton from "../../../components/form/mutation-button";
import FormWithData from "../../../lib/form/form-with-data";
import { useImportDomainPermsMutation } from "../../../lib/query/admin/domain-permissions/import";
import {
useDomainAllowsQuery,
useDomainBlocksQuery
} from "../../../lib/query/admin/domain-permissions/get";
import type { DomainPerm, MappedDomainPerms } from "../../../lib/types/domain-permission";
import type { ChecklistInputHook, RadioFormInputHook } from "../../../lib/form/types";
export interface ProcessImportProps {
list: DomainPerm[],
permType: RadioFormInputHook,
}
export const ProcessImport = memo(
function ProcessImport({ list, permType }: ProcessImportProps) {
return (
<FormWithData
dataQuery={permType.value == "allow"
? useDomainAllowsQuery
: useDomainBlocksQuery
}
DataForm={ImportList}
{...{ list, permType }}
/>
);
}
);
export interface ImportListProps {
list: Array<DomainPerm>,
data: MappedDomainPerms,
permType: RadioFormInputHook,
}
function ImportList({ list, data: domainPerms, permType }: ImportListProps) {
const hasComment = useMemo(() => {
let hasPublic = false;
let hasPrivate = false;
list.some((entry) => {
if (entry.public_comment) {
hasPublic = true;
}
if (entry.private_comment) {
hasPrivate = true;
}
return hasPublic && hasPrivate;
});
if (hasPublic && hasPrivate) {
return { both: true };
} else if (hasPublic) {
return { type: "public_comment" };
} else if (hasPrivate) {
return { type: "private_comment" };
} else {
return {};
}
}, [list]);
const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
const form = {
domains: useCheckListInput("domains", { entries: list }), // DomainPerm is actually also a Checkable.
obfuscate: useBoolInput("obfuscate"),
privateComment: useTextInput("private_comment", {
defaultValue: `Imported on ${new Date().toLocaleString()}`
}),
privateCommentBehavior: useRadioInput("private_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
publicComment: useTextInput("public_comment"),
publicCommentBehavior: useRadioInput("public_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
permType: permType,
};
const [importDomains, importResult] = useFormSubmit(form, useImportDomainPermsMutation(), { changedOnly: false });
return (
<>
<form
onSubmit={importDomains}
className="domain-perm-import-list"
>
<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
{hasComment.both &&
<Select field={showComment} options={
<>
<option value="public_comment">Show public comments</option>
<option value="private_comment">Show private comments</option>
</>
} />
}
<div className="checkbox-list-wrapper">
<DomainCheckList
field={form.domains}
domainPerms={domainPerms}
commentType={showComment.value as "public_comment" | "private_comment"}
permType={form.permType}
/>
</div>
<TextArea
field={form.privateComment}
label="Private comment"
rows={3}
/>
<RadioGroup
field={form.privateCommentBehavior}
label="imported private comment"
/>
<TextArea
field={form.publicComment}
label="Public comment"
rows={3}
/>
<RadioGroup
field={form.publicCommentBehavior}
label="imported public comment"
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domains in public lists"
/>
<MutationButton
label="Import"
disabled={false}
result={importResult}
/>
</form>
</>
);
}
interface DomainCheckListProps {
field: ChecklistInputHook,
domainPerms: MappedDomainPerms,
commentType: "public_comment" | "private_comment",
permType: RadioFormInputHook,
}
function DomainCheckList({ field, domainPerms, commentType, permType }: DomainCheckListProps) {
const getExtraProps = useCallback((entry: DomainPerm) => {
return {
comment: entry[commentType],
alreadyExists: entry.domain in domainPerms,
permType: permType,
};
}, [domainPerms, commentType, permType]);
const entriesWithSuggestions = useMemo(() => {
const fieldValue = (field.value ?? {}) as { [k: string]: DomainPerm; };
return Object.values(fieldValue).filter((entry) => entry.suggest);
}, [field.value]);
return (
<>
<CheckList
field={field as ChecklistInputHook}
header={<>
<b>Domain</b>
<b>
{commentType == "public_comment" && "Public comment"}
{commentType == "private_comment" && "Private comment"}
</b>
</>}
EntryComponent={DomainEntry}
getExtraProps={getExtraProps}
/>
<UpdateHint
entries={entriesWithSuggestions}
updateEntry={field.onChange}
updateMultiple={field.updateMultiple}
/>
</>
);
}
interface UpdateHintProps {
entries,
updateEntry,
updateMultiple,
}
const UpdateHint = memo(
function UpdateHint({ entries, updateEntry, updateMultiple }: UpdateHintProps) {
if (entries.length == 0) {
return null;
}
function changeAll() {
updateMultiple(
entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }])
);
}
return (
<div className="update-hints">
<p>
{entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain,
which you might want to change to the main domain, as that includes all it's (future) subdomains.
</p>
<div className="hints">
{entries.map((entry) => (
<UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} />
))}
</div>
{entries.length > 0 && <a onClick={changeAll}>change all</a>}
</div>
);
}
);
interface UpdateableEntryProps {
entry,
updateEntry,
}
const UpdateableEntry = memo(
function UpdateableEntry({ entry, updateEntry }: UpdateableEntryProps) {
return (
<>
<span className="text-cutoff">{entry.domain}</span>
<i className="fa fa-long-arrow-right" aria-hidden="true"></i>
<span>{entry.suggest}</span>
<a role="button" onClick={() =>
updateEntry(entry.key, { domain: entry.suggest, suggest: null })
}>change</a>
</>
);
}
);
function domainValidationError(isValid) {
return isValid ? "" : "Invalid domain";
}
interface DomainEntryProps {
entry;
onChange;
extraProps: {
alreadyExists: boolean;
comment: string;
permType: RadioFormInputHook;
};
}
function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment, permType } }: DomainEntryProps) {
const domainField = useTextInput("domain", {
defaultValue: entry.domain,
showValidation: entry.checked,
initValidation: domainValidationError(entry.valid),
validator: (value) => domainValidationError(isValidDomainPermission(value))
});
useEffect(() => {
if (entry.valid != domainField.valid) {
onChange({ valid: domainField.valid });
}
}, [onChange, entry.valid, domainField.valid]);
useEffect(() => {
if (entry.domain != domainField.value) {
domainField.setter(entry.domain);
}
// domainField.setter is enough, eslint wants domainField
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry.domain, domainField.setter]);
useEffect(() => {
onChange({ suggest: hasBetterScope(domainField.value ?? "") });
// only need this update if it's the entry.checked that updated, not onChange
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [domainField.value]);
function clickIcon(e) {
if (entry.suggest) {
e.stopPropagation();
e.preventDefault();
domainField.setter(entry.suggest);
onChange({ domain: entry.suggest, checked: true });
}
}
return (
<>
<div className="domain-input">
<TextInput
field={domainField}
onChange={(e) => {
domainField.onChange(e);
onChange({ domain: e.target.value, checked: true });
}}
/>
<span id="icon" onClick={clickIcon}>
<DomainEntryIcon
alreadyExists={alreadyExists}
suggestion={entry.suggest}
permTypeString={permType.value?? ""}
/>
</span>
</div>
<p>{comment}</p>
</>
);
}
interface DomainEntryIconProps {
alreadyExists: boolean;
suggestion: string;
permTypeString: string;
}
function DomainEntryIcon({ alreadyExists, suggestion, permTypeString }: DomainEntryIconProps) {
let icon;
let text;
if (suggestion) {
icon = "fa-info-circle suggest-changes";
text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`;
} else if (alreadyExists) {
icon = "fa-history permission-already-exists";
text = `Domain ${permTypeString} already exists.`;
}
if (!icon) {
return null;
}
return (
<>
<i className={`fa fa-fw ${icon}`} aria-hidden="true" title={text}></i>
<span className="sr-only">{text}</span>
</>
);
}

View file

@ -0,0 +1,243 @@
/*
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, { useState } from "react";
import { useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import BackButton from "../../../components/back-button";
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 { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
import { useBaseUrl } from "../../../lib/navigation/util";
export default function ReportDetail({ }) {
const baseUrl = useBaseUrl();
const params = useParams();
return (
<div className="reports">
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
<FormWithData
dataQuery={useGetReportQuery}
queryArg={params.reportId}
DataForm={ReportDetailForm}
/>
</div>
);
}
function ReportDetailForm({ data: report }) {
const from = report.account;
const target = report.target_account;
return (
<div className="report detail">
<div className="usernames">
<Username user={from} /> reported <Username user={target} />
</div>
{report.action_taken &&
<div className="info">
<h3>Resolved by @{report.action_taken_by_account.account.acct}</h3>
<span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span>
<br />
<b>Comment: </b><span>{report.action_taken_comment}</span>
</div>
}
<div className="info-block">
<h3>Report info:</h3>
<div className="details">
<b>Created: </b>
<span>{new Date(report.created_at).toLocaleString()}</span>
<b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span>
<b>Category: </b> <span>{report.category}</span>
<b>Reason: </b>
{report.comment.length > 0
? <p>{report.comment}</p>
: <i className="no-comment">none provided</i>
}
</div>
</div>
{!report.action_taken && <ReportActionForm report={report} />}
{
report.statuses.length > 0 &&
<div className="info-block">
<h3>Reported toots ({report.statuses.length}):</h3>
<div className="reported-toots">
{report.statuses.map((status) => (
<ReportedToot key={status.id} toot={status} />
))}
</div>
</div>
}
</div>
);
}
function ReportActionForm({ report }) {
const form = {
id: useValue("id", report.id),
comment: useTextInput("action_taken_comment")
};
const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
return (
<form onSubmit={submit} className="info-block">
<h3>Resolving this report</h3>
<p>
An optional comment can be included while resolving this report.
Useful for providing an explanation about what action was taken (if any) before the report was marked as resolved.<br />
<b>This will be visible to the user that created the report!</b>
</p>
<TextArea
field={form.comment}
label="Comment"
/>
<MutationButton
disabled={false}
label="Resolve"
result={result}
/>
</form>
);
}
function ReportedToot({ toot }) {
const account = toot.account;
return (
<article className="status expanded">
<header className="status-header">
<address>
<a style={{margin: 0}}>
<img className="avatar" src={account.avatar} alt="" />
<dl className="author-strap">
<dt className="sr-only">Display name</dt>
<dd className="displayname text-cutoff">
{account.display_name.trim().length > 0 ? account.display_name : account.username}
</dd>
<dt className="sr-only">Username</dt>
<dd className="username text-cutoff">@{account.username}</dd>
</dl>
</a>
</address>
</header>
<section className="status-body">
<div className="text">
<div className="content">
{toot.spoiler_text?.length > 0
? <TootCW content={toot.content} note={toot.spoiler_text} />
: toot.content
}
</div>
</div>
{toot.media_attachments?.length > 0 &&
<TootMedia media={toot.media_attachments} sensitive={toot.sensitive} />
}
</section>
<aside className="status-info">
<dl className="status-stats">
<div className="stats-grouping">
<div className="stats-item published-at text-cutoff">
<dt className="sr-only">Published</dt>
<dd>
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
</dd>
</div>
</div>
</dl>
</aside>
</article>
);
}
function TootCW({ note, content }) {
const [visible, setVisible] = useState(false);
function toggleVisible() {
setVisible(!visible);
}
return (
<>
<div className="spoiler">
<span>{note}</span>
<label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label>
</div>
{visible && content}
</>
);
}
function TootMedia({ media, sensitive }) {
let classes = (media.length % 2 == 0) ? "even" : "odd";
if (media.length == 1) {
classes += " single";
}
return (
<div className={`media photoswipe-gallery ${classes}`}>
{media.map((m) => (
<div key={m.id} className="media-wrapper">
{sensitive && <>
<input id={`sensitiveMedia-${m.id}`} type="checkbox" className="sensitive-checkbox hidden" />
<div className="sensitive">
<div className="open">
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
<i className="fa fa-eye-slash" title="Hide sensitive media"></i>
</label>
</div>
<div className="closed" title={m.description}>
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
Show sensitive media
</label>
</div>
</div>
</>}
<a
href={m.url}
title={m.description}
target="_blank"
rel="noreferrer"
data-cropped="true"
data-pswp-width={`${m.meta?.original.width}px`}
data-pswp-height={`${m.meta?.original.height}px`}
>
<img
alt={m.description}
src={m.url}
// thumb={m.preview_url}
sizes={m.meta?.original}
/>
</a>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,99 @@
/*
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 FormWithData from "../../../lib/form/form-with-data";
import Username from "./username";
import { useListReportsQuery } from "../../../lib/query/admin/reports";
export function ReportOverview({ }) {
return (
<FormWithData
dataQuery={useListReportsQuery}
DataForm={ReportsList}
/>
);
}
function ReportsList({ data: reports }) {
return (
<div className="reports">
<div className="form-section-docs">
<h1>Reports</h1>
<p>
Here you can view and resolve reports made to your
instance, originating from local and remote users.
</p>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about this (opens in a new tab)
</a>
</div>
<div className="list">
{reports.map((report) => (
<ReportEntry key={report.id} report={report} />
))}
</div>
</div>
);
}
function ReportEntry({ report }) {
const from = report.account;
const target = report.target_account;
let comment = report.comment.length > 200
? report.comment.slice(0, 200) + "..."
: report.comment;
return (
<Link
to={`/${report.id}`}
className="nounderline"
>
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
<div className="byline">
<div className="usernames">
<Username user={from} link={false} /> reported <Username user={target} link={false} />
</div>
<h3 className="report-status">
{report.action_taken ? "Resolved" : "Open"}
</h3>
</div>
<div className="details">
<b>Created: </b>
<span>{new Date(report.created_at).toLocaleString()}</span>
<b>Reason: </b>
{comment.length > 0
? <p>{comment}</p>
: <i className="no-comment">none provided</i>
}
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,54 @@
/*
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";
export default function Username({ user, link = true }) {
let className = "user";
let isLocal = user.domain == null;
if (user.suspended) {
className += " suspended";
}
if (isLocal) {
className += " local";
}
let icon = isLocal
? { fa: "fa-home", info: "Local user" }
: { fa: "fa-external-link-square", info: "Remote user" };
let Element: any = "div";
let href: any = null;
if (link) {
Element = Link;
href = `/settings/admin/accounts/${user.id}`;
}
return (
<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>
</Element>
);
}

View file

@ -0,0 +1,201 @@
/*
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 { MenuItem } from "../../lib/navigation/menu";
import React from "react";
import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
import { Redirect, Route, Router, Switch } from "wouter";
import AccountsOverview from "./accounts";
import AccountsPending from "./accounts/pending";
import AccountDetail from "./accounts/detail";
import { ReportOverview } from "./reports/overview";
import DomainPermissionsOverview from "./domain-permissions/overview";
import DomainPermDetail from "./domain-permissions/detail";
import ImportExport from "./domain-permissions/import-export";
import ReportDetail from "./reports/detail";
/*
EXPORTED COMPONENTS
*/
/**
* Moderation menu. Reports, accounts,
* domain permissions import + export.
*/
export function ModerationMenu() {
return (
<MenuItem
name="Moderation"
itemUrl="moderation"
defaultChild="reports"
permissions={["moderator"]}
>
<ModerationReportsMenu />
<ModerationAccountsMenu />
<ModerationDomainPermsMenu />
</MenuItem>
);
}
/**
* Moderation router. Reports, accounts,
* domain permissions import + export.
*/
export function ModerationRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/moderation";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ModerationReportsRouter />
<ModerationAccountsRouter />
<ModerationDomainPermsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/*
INTERNAL COMPONENTS
*/
/*
MENUS
*/
function ModerationReportsMenu() {
return (
<MenuItem
name="Reports"
itemUrl="reports"
icon="fa-flag"
/>
);
}
function ModerationAccountsMenu() {
return (
<MenuItem
name="Accounts"
itemUrl="accounts"
defaultChild="overview"
icon="fa-users"
>
<MenuItem
name="Overview"
itemUrl="overview"
icon="fa-list"
/>
<MenuItem
name="Pending"
itemUrl="pending"
icon="fa-question"
/>
</MenuItem>
);
}
function ModerationDomainPermsMenu() {
return (
<MenuItem
name="Domain Permissions"
itemUrl="domain-permissions"
defaultChild="blocks"
icon="fa-hubzilla"
>
<MenuItem
name="Blocks"
itemUrl="blocks"
icon="fa-close"
/>
<MenuItem
name="Allows"
itemUrl="allows"
icon="fa-check"
/>
<MenuItem
name="Import/Export"
itemUrl="import-export"
icon="fa-floppy-o"
/>
</MenuItem>
);
}
/*
ROUTERS
*/
function ModerationReportsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/reports";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path={"/:reportId"} component={ReportDetail} />
<Route component={ReportOverview}/>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}
function ModerationAccountsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/accounts";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/overview" component={AccountsOverview}/>
<Route path="/pending" component={AccountsPending}/>
<Route path="/:accountID" component={AccountDetail}/>
<Route><Redirect to="/overview"/></Route>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}
function ModerationDomainPermsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/domain-permissions";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/import-export" component={ImportExport} />
<Route path="/process" component={ImportExport} />
<Route path="/:permType/:domain" component={DomainPermDetail} />
<Route path="/:permType" component={DomainPermissionsOverview} />
<Route><Redirect to="/blocks"/></Route>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}