diff options
Diffstat (limited to 'web/source')
21 files changed, 622 insertions, 364 deletions
| diff --git a/web/source/package.json b/web/source/package.json index 919bf3c83..230c248ad 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -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", diff --git a/web/source/settings/components/account-list.tsx b/web/source/settings/components/account-list.tsx deleted file mode 100644 index c4420b5bc..000000000 --- a/web/source/settings/components/account-list.tsx +++ /dev/null @@ -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> -	); -} diff --git a/web/source/settings/components/pageable-list.tsx b/web/source/settings/components/pageable-list.tsx new file mode 100644 index 000000000..918103ead --- /dev/null +++ b/web/source/settings/components/pageable-list.tsx @@ -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> +	); +} diff --git a/web/source/settings/views/moderation/reports/username.tsx b/web/source/settings/components/username.tsx index 294d97e8b..f7be1cd4a 100644 --- a/web/source/settings/views/moderation/reports/username.tsx +++ b/web/source/settings/components/username.tsx @@ -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> +			  +			<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 ( diff --git a/web/source/settings/index.tsx b/web/source/settings/index.tsx index 977a94150..25e3d1f3c 100644 --- a/web/source/settings/index.tsx +++ b/web/source/settings/index.tsx @@ -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> diff --git a/web/source/settings/lib/query/admin/index.ts b/web/source/settings/lib/query/admin/index.ts index cbe66705b..3e7b1a0a0 100644 --- a/web/source/settings/lib/query/admin/index.ts +++ b/web/source/settings/lib/query/admin/index.ts @@ -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' }], diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index a07f5ff1e..6e5eafeab 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -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. diff --git a/web/source/settings/lib/types/account.ts b/web/source/settings/lib/types/account.ts index 3e7e9640d..db97001ac 100644 --- a/web/source/settings/lib/types/account.ts +++ b/web/source/settings/lib/types/account.ts @@ -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", diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 5af9dbc67..01263c224 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -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; - -		.acct { -			word-break: break-all; -		} - -		&.suspended { -			background: $bg-accent; -			color: $fg; -			text-decoration: line-through; -		} +.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; +	} -		&.local { -			background: $green1; -		} +	&.suspended { +		background: $bg-accent; +		color: $fg; +		text-decoration: line-through;  	} -} -.accounts-view { -	form { -		margin-bottom: 1rem; +	&.local { +		background: $green1;  	} +} -	.list { -		margin: 0.5rem 0; +.spanlink { +	cursor: pointer; +	text-decoration: none; +} -		a { +.accounts-view { +	.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; +				} +			}  		}  	} diff --git a/web/source/settings/views/moderation/accounts/detail/actions.tsx b/web/source/settings/views/moderation/accounts/detail/actions.tsx index 212bb4089..4132b778a 100644 --- a/web/source/settings/views/moderation/accounts/detail/actions.tsx +++ b/web/source/settings/views/moderation/accounts/detail/actions.tsx @@ -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> +	); +} diff --git a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx b/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx deleted file mode 100644 index 59fa8bc65..000000000 --- a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx +++ /dev/null @@ -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> -	); -} diff --git a/web/source/settings/views/moderation/accounts/detail/index.tsx b/web/source/settings/views/moderation/accounts/detail/index.tsx index f34bc7481..830a894cb 100644 --- a/web/source/settings/views/moderation/accounts/detail/index.tsx +++ b/web/source/settings/views/moderation/accounts/detail/index.tsx @@ -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> +			</> +		); +	} + +	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} +			/> +		</> +	); +} -	let created = new Date(adminAcct.created_at).toDateString(); +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 && @@ -76,6 +114,18 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP  					<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>  				</div> @@ -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> +		</>   	);  } diff --git a/web/source/settings/views/moderation/accounts/detail/util.tsx b/web/source/settings/views/moderation/accounts/detail/util.tsx new file mode 100644 index 000000000..b82d44a6e --- /dev/null +++ b/web/source/settings/views/moderation/accounts/detail/util.tsx @@ -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; +} diff --git a/web/source/settings/views/moderation/accounts/index.tsx b/web/source/settings/views/moderation/accounts/index.tsx index 79ba2c674..946ed323d 100644 --- a/web/source/settings/views/moderation/accounts/index.tsx +++ b/web/source/settings/views/moderation/accounts/index.tsx @@ -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 diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx index d5a32f09b..b72de52bf 100644 --- a/web/source/settings/views/moderation/accounts/pending/index.tsx +++ b/web/source/settings/views/moderation/accounts/pending/index.tsx @@ -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." diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx index 8ee579e16..58e2d6505 100644 --- a/web/source/settings/views/moderation/accounts/search/index.tsx +++ b/web/source/settings/views/moderation/accounts/search/index.tsx @@ -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}  			/>  		</>  	); diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 4f01e0798..9488b8c30 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -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 diff --git a/web/source/settings/views/moderation/reports/detail.tsx b/web/source/settings/views/moderation/reports/detail.tsx index bc356edce..ad8d69a47 100644 --- a/web/source/settings/views/moderation/reports/detail.tsx +++ b/web/source/settings/views/moderation/reports/detail.tsx @@ -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> diff --git a/web/source/settings/views/moderation/reports/overview.tsx b/web/source/settings/views/moderation/reports/overview.tsx index 03ce1a382..18eb5492a 100644 --- a/web/source/settings/views/moderation/reports/overview.tsx +++ b/web/source/settings/views/moderation/reports/overview.tsx @@ -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"} diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index 37344462b..d23ab336a 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -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> diff --git a/web/source/yarn.lock b/web/source/yarn.lock index 6e1f650c4..71187718f 100644 --- a/web/source/yarn.lock +++ b/web/source/yarn.lock @@ -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" | 
