[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,60 @@
/*
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 { useInstanceKeysExpireMutation } from "../../../../lib/query";
import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useTextInput } from "../../../../lib/form";
export default function ExpireRemote({}) {
const domainField = useTextInput("domain");
const [expire, expireResult] = useInstanceKeysExpireMutation();
function submitExpire(e) {
e.preventDefault();
expire(domainField.value);
}
return (
<form onSubmit={submitExpire}>
<h2>Expire remote instance keys</h2>
<p>
Mark all public keys from the given remote instance as expired.<br/><br/>
This is useful in cases where the remote domain has had to rotate their keys for whatever
reason (security issue, data leak, routine safety procedure, etc), and your instance can no
longer communicate with theirs properly using cached keys. A key marked as expired in this way
will be lazily refetched next time a request is made to your instance signed by the owner of that
key.
</p>
<TextInput
field={domainField}
label="Domain"
type="string"
placeholder="example.org"
/>
<MutationButton
disabled={!domainField.value}
label="Expire keys"
result={expireResult}
/>
</form>
);
}

View file

@ -0,0 +1,30 @@
/*
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 ExpireRemote from "./expireremote";
export default function Keys() {
return (
<>
<h1>Key Actions</h1>
<ExpireRemote />
</>
);
}

View file

@ -0,0 +1,59 @@
/*
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 { useMediaCleanupMutation } from "../../../../lib/query";
import { useTextInput } from "../../../../lib/form";
import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
export default function Cleanup({}) {
const daysField = useTextInput("days", { defaultValue: "30" });
const [mediaCleanup, mediaCleanupResult] = useMediaCleanupMutation();
function submitCleanup(e) {
e.preventDefault();
mediaCleanup(daysField.value);
}
return (
<form onSubmit={submitCleanup}>
<h2>Cleanup</h2>
<p>
Clean up remote media older than the specified number of days.
If the remote instance is still online they will be refetched when needed.
Also cleans up unused headers and avatars from the media cache.
</p>
<TextInput
field={daysField}
label="Days"
type="number"
min="0"
placeholder="30"
/>
<MutationButton
disabled={!daysField.value}
label="Remove old media"
result={mediaCleanupResult}
/>
</form>
);
}

View file

@ -0,0 +1,30 @@
/*
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 Cleanup from "./cleanup";
export default function Media() {
return (
<>
<h1>Media Actions</h1>
<Cleanup />
</>
);
}

View file

@ -0,0 +1,134 @@
/*
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, { useMemo, useEffect, PropsWithChildren, ReactElement } from "react";
import { matchSorter } from "match-sorter";
import ComboBox from "../../../components/combo-box";
import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji";
import { CustomEmoji } from "../../../lib/types/custom-emoji";
import { ComboboxFormInputHook } from "../../../lib/form/types";
import Loading from "../../../components/loading";
import { Error } from "../../../components/error";
/**
* Sort all emoji into a map keyed by
* the category names (or "Unsorted").
*/
export function useEmojiByCategory(emojis: CustomEmoji[]) {
return useMemo(() => {
const byCategory = new Map<string, CustomEmoji[]>();
emojis.forEach((emoji) => {
const key = emoji.category ?? "Unsorted";
const value = byCategory.get(key) ?? [];
value.push(emoji);
byCategory.set(key, value);
});
return byCategory;
}, [emojis]);
}
interface CategorySelectProps {
field: ComboboxFormInputHook;
}
/**
*
* Renders a cute lil searchable "category select" dropdown.
*/
export function CategorySelect({ field, children }: PropsWithChildren<CategorySelectProps>) {
// Get all local emojis.
const {
data: emoji = [],
isLoading,
isSuccess,
isError,
error,
} = useListEmojiQuery({ filter: "domain:local" });
const emojiByCategory = useEmojiByCategory(emoji);
const categories = useMemo(() => new Set(emojiByCategory.keys()), [emojiByCategory]);
const { value, setIsNew } = field;
// Data used by the ComboBox element
// to select an emoji category.
const categoryItems = useMemo(() => {
const categoriesArr = Array.from(categories);
// Sorted by complex algorithm.
const categoryNames = matchSorter(
categoriesArr,
value ?? "",
{ threshold: matchSorter.rankings.NO_MATCH },
);
// Map each category to the static image
// of the first emoji it contains.
const categoryItems: [string, ReactElement][] = [];
categoryNames.forEach((categoryName) => {
let src: string | undefined;
const items = emojiByCategory.get(categoryName);
if (items && items.length > 0) {
src = items[0].static_url;
}
categoryItems.push([
categoryName,
<>
<img
src={src}
aria-hidden="true"
/>
{categoryName}
</>
]);
});
return categoryItems;
}, [emojiByCategory, categories, value]);
// New category if something has been entered
// and we don't have it in categories yet.
useEffect(() => {
if (value !== undefined) {
const trimmed = value.trim();
if (trimmed.length > 0) {
setIsNew(!categories.has(trimmed));
}
}
}, [categories, value, isSuccess, setIsNew]);
if (isLoading) {
return <Loading />;
} else if (isError) {
return <Error error={error} />;
} else {
return (
<ComboBox
field={field}
items={categoryItems}
label="Category"
placeholder="e.g., reactions"
>
{children}
</ComboBox>
);
}
}

View file

@ -0,0 +1,142 @@
/*
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, { useEffect } from "react";
import { Redirect, useParams } from "wouter";
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
import useFormSubmit from "../../../../lib/form/submit";
import { useBaseUrl } from "../../../../lib/navigation/util";
import FakeToot from "../../../../components/fake-toot";
import FormWithData from "../../../../lib/form/form-with-data";
import Loading from "../../../../components/loading";
import { FileInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { Error } from "../../../../components/error";
import { useGetEmojiQuery, useEditEmojiMutation, useDeleteEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { CategorySelect } from "../category-select";
import BackButton from "../../../../components/back-button";
export default function EmojiDetail() {
const baseUrl = useBaseUrl();
const params = useParams();
return (
<div className="emoji-detail">
<BackButton to={`~${baseUrl}/local`} />
<FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div>
);
}
function EmojiDetailForm({ data: emoji }) {
const baseUrl = useBaseUrl();
const form = {
id: useValue("id", emoji.id),
category: useComboBoxInput("category", { source: emoji }),
image: useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024 // TODO: get from instance api
})
};
const [modifyEmoji, result] = useFormSubmit(form, useEditEmojiMutation());
// Automatic submitting of category change
useEffect(() => {
if (
form.category.hasChanged() &&
!form.category.state.open &&
!form.category.isNew) {
modifyEmoji();
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
const [deleteEmoji, deleteResult] = useDeleteEmojiMutation();
if (deleteResult.isSuccess) {
return <Redirect to={`~${baseUrl}/local`} />;
}
return (
<>
<div className="emoji-header">
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
<div>
<h2>{emoji.shortcode}</h2>
<MutationButton
label="Delete"
type="button"
onClick={() => deleteEmoji(emoji.id)}
className="danger"
showError={false}
result={deleteResult}
disabled={false}
/>
</div>
</div>
<form onSubmit={modifyEmoji} className="left-border">
<h2>Modify this emoji {result.isLoading && <Loading />}</h2>
<div className="update-category">
<CategorySelect
field={form.category}
>
<MutationButton
name="create-category"
label="Create"
result={result}
showError={false}
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
disabled={!form.category.value}
/>
</CategorySelect>
</div>
<div className="update-image">
<FileInput
field={form.image}
label="Image"
accept="image/png,image/gif"
/>
<MutationButton
name="image"
label="Replace image"
showError={false}
result={result}
disabled={!form.image.value}
/>
<FakeToot>
Look at this new custom emoji <img
className="emoji"
src={form.image.previewValue ?? emoji.url}
title={`:${emoji.shortcode}:`}
alt={emoji.shortcode}
/> isn&apos;t it cool?
</FakeToot>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</div>
</form>
</>
);
}

View file

@ -0,0 +1,112 @@
/*
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, { useMemo, useEffect } from "react";
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
import useShortcode from "./use-shortcode";
import useFormSubmit from "../../../../lib/form/submit";
import { TextInput, FileInput } from "../../../../components/form/inputs";
import { CategorySelect } from '../category-select';
import FakeToot from "../../../../components/fake-toot";
import MutationButton from "../../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../../lib/query";
export default function NewEmojiForm() {
const shortcode = useShortcode();
const { data: instance } = useInstanceV1Query();
const emojiMaxSize = useMemo(() => {
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
}, [instance]);
const image = useFileInput("image", {
withPreview: true,
maxSize: emojiMaxSize
});
const category = useComboBoxInput("category");
const [submitForm, result] = useFormSubmit({
shortcode, image, category
}, useAddEmojiMutation());
useEffect(() => {
if (shortcode.value === undefined || shortcode.value.length == 0) {
if (image.value != undefined) {
let [name, _ext] = image.value.name.split(".");
shortcode.setter(name);
}
}
/* We explicitly don't want to have 'shortcode' as a dependency here
because we only want to change the shortcode to the filename if the field is empty
at the moment the file is selected, not some time after when the field is emptied
*/
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [image.value]);
let emojiOrShortcode;
if (image.previewValue != undefined) {
emojiOrShortcode = <img
className="emoji"
src={image.previewValue}
title={`:${shortcode.value}:`}
alt={shortcode.value}
/>;
} else if (shortcode.value !== undefined && shortcode.value.length > 0) {
emojiOrShortcode = `:${shortcode.value}:`;
} else {
emojiOrShortcode = `:your_emoji_here:`;
}
return (
<div>
<h2>Add new custom emoji</h2>
<FakeToot>
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot>
<form onSubmit={submitForm} className="form-flex">
<FileInput
field={image}
accept="image/png,image/gif,image/webp"
/>
<TextInput
field={shortcode}
label="Shortcode, must be unique among the instance's local emoji"
/>
<CategorySelect
field={category}
children={[]}
/>
<MutationButton
disabled={image.previewValue === undefined}
label="Upload emoji"
result={result}
/>
</form>
</div>
);
}

View file

@ -0,0 +1,173 @@
/*
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, { useMemo, useState } from "react";
import { Link } from "wouter";
import { matchSorter } from "match-sorter";
import NewEmojiForm from "./new-emoji";
import { useTextInput } from "../../../../lib/form";
import { useEmojiByCategory } from "../category-select";
import Loading from "../../../../components/loading";
import { Error } from "../../../../components/error";
import { TextInput } from "../../../../components/form/inputs";
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
import { CustomEmoji } from "../../../../lib/types/custom-emoji";
export function EmojiOverview() {
const { data: emoji = [], isLoading, isError, error } = useListEmojiQuery({ filter: "domain:local" });
let content: React.JSX.Element;
if (isLoading) {
content = <Loading />;
} else if (isError) {
content = <Error error={error} />;
} else {
content = (
<>
<EmojiList emoji={emoji} />
<NewEmojiForm />
</>
);
}
return (
<>
<h1>Local Custom Emoji</h1>
<p>
To use custom emoji in your toots they have to be 'local' to the instance.
You can either upload them here directly, or copy from those already
present on other (known) instances through the <Link to={`/remote`}>Remote Emoji</Link> page.
</p>
<p>
<strong>Be warned!</strong> If you upload more than about 300-400 custom emojis in
total on your instance, this may lead to rate-limiting issues for users and clients
if they try to load all the emoji images at once (which is what many clients do).
</p>
{content}
</>
);
}
interface EmojiListParams {
emoji: CustomEmoji[];
}
function EmojiList({ emoji }: EmojiListParams) {
const filterField = useTextInput("filter");
const filter = filterField.value ?? "";
const emojiByCategory = useEmojiByCategory(emoji);
// Filter emoji based on shortcode match
// with user input, hiding empty categories.
const { filteredEmojis, filteredCount } = useMemo(() => {
// Amount of emojis removed by the filter.
// Start with the length of the array since
// that's the max that can be filtered out.
let filteredCount = emoji.length;
// Results of the filtering.
const filteredEmojis: [string, CustomEmoji[]][] = [];
// Filter from emojis in this category.
emojiByCategory.forEach((entries, category) => {
const filteredEntries = matchSorter(entries, filter, {
keys: ["shortcode"]
});
if (filteredEntries.length == 0) {
// Nothing left in this category, don't
// bother adding it to filteredEmojis.
return;
}
filteredCount -= filteredEntries.length;
filteredEmojis.push([category, filteredEntries]);
});
return { filteredEmojis, filteredCount };
}, [filter, emojiByCategory, emoji.length]);
return (
<>
<h2>Overview</h2>
{emoji.length > 0
? <span>{emoji.length} custom emoji {filteredCount > 0 && `(${filteredCount} filtered)`}</span>
: <span>No custom emoji yet, you can add one below.</span>
}
<div className="list emoji-list">
<div className="header">
<TextInput
field={filterField}
name="emoji-shortcode"
placeholder="Search"
/>
</div>
<div className="entries scrolling">
{filteredEmojis.length > 0
? (
<div className="entries scrolling">
{filteredEmojis.map(([category, emojis]) => {
return <EmojiCategory key={category} category={category} emojis={emojis} />;
})}
</div>
)
: <div className="entry">No local emoji matched your filter.</div>
}
</div>
</div>
</>
);
}
interface EmojiCategoryProps {
category: string;
emojis: CustomEmoji[];
}
function EmojiCategory({ category, emojis }: EmojiCategoryProps) {
return (
<div className="entry">
<b>{category}</b>
<div className="emoji-group">
{emojis.map((emoji) => {
return (
<Link key={emoji.id} to={`/local/${emoji.id}`} >
<EmojiPreview emoji={emoji} />
</Link>
);
})}
</div>
</div>
);
}
function EmojiPreview({ emoji }) {
const [ animate, setAnimate ] = useState(false);
return (
<img
onMouseEnter={() => { setAnimate(true); }}
onMouseLeave={() => { setAnimate(false); }}
src={animate ? emoji.url : emoji.static_url}
alt={emoji.shortcode}
title={emoji.shortcode}
loading="lazy"
/>
);
}

View file

@ -0,0 +1,56 @@
/*
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 { useTextInput } from "../../../../lib/form";
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
const shortcodeRegex = /^\w{2,30}$/;
export default function useShortcode() {
const { data: emoji = [] } = useListEmojiQuery({
filter: "domain:local"
});
const emojiCodes = useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
return useTextInput("shortcode", {
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (code == "") { return ""; }
if (emojiCodes.has(code)) {
return "Shortcode already in use";
}
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
}
if (!shortcodeRegex.test(code)) {
return "Shortcode must only contain letters, numbers, and underscores";
}
return "";
}
});
}

View file

@ -0,0 +1,46 @@
/*
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, { useMemo } from "react";
import StealThisLook from "./steal-this-look";
import Loading from "../../../../components/loading";
import { Error } from "../../../../components/error";
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
export default function RemoteEmoji() {
// Local emoji are queried for
// shortcode collision detection
const {
data: emoji = [],
isLoading,
error
} = useListEmojiQuery({ filter: "domain:local" });
const emojiCodes = useMemo(() => new Set(emoji.map((e) => e.shortcode)), [emoji]);
return (
<>
<h1>Custom Emoji (remote)</h1>
{error && <Error error={error} />}
{isLoading ? <Loading /> : <StealThisLook emojiCodes={emojiCodes} />}
</>
);
}

View file

@ -0,0 +1,235 @@
/*
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, { useCallback, useEffect } from "react";
import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../../lib/form";
import useFormSubmit from "../../../../lib/form/submit";
import CheckList from "../../../../components/check-list";
import { CategorySelect } from '../category-select';
import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { Error } from "../../../../components/error";
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../../lib/query/admin/custom-emoji";
export default function StealThisLook({ emojiCodes }) {
const [searchStatus, result] = useSearchItemForEmojiMutation();
const urlField = useTextInput("url");
function submitSearch(e) {
e.preventDefault();
if (urlField.value !== undefined && urlField.value.trim().length != 0) {
searchStatus(urlField.value);
}
}
return (
<div className="parse-emoji">
<h2>Steal this look</h2>
<form onSubmit={submitSearch}>
<div className="form-field text">
<label htmlFor="url">
Link to a status:
</label>
<div className="row">
<input
type="text"
id="url"
name="url"
onChange={urlField.onChange}
value={urlField.value}
/>
<button disabled={result.isLoading}>
<i className={[
"fa fa-fw",
(result.isLoading
? "fa-refresh fa-spin"
: "fa-search")
].join(" ")} aria-hidden="true" title="Search" />
<span className="sr-only">Search</span>
</button>
</div>
</div>
</form>
<SearchResult result={result} localEmojiCodes={emojiCodes} />
</div>
);
}
function SearchResult({ result, localEmojiCodes }) {
const { error, data, isSuccess, isError } = result;
if (!(isSuccess || isError)) {
return null;
}
if (error == "NONE_FOUND") {
return "No results found";
} else if (error == "LOCAL_INSTANCE") {
return <b>This is a local user/status, all referenced emoji are already on your instance</b>;
} else if (error != undefined) {
return <Error error={result.error} />;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "status" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={localEmojiCodes}
type={data.type}
emojiList={data.list}
/>
);
}
function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
const form = {
selectedEmoji: useCheckListInput("selectedEmoji", {
entries: emojiList,
uniqueKey: "id"
}),
category: useComboBoxInput("category")
};
const [formSubmit, result] = useFormSubmit(
form,
usePatchRemoteEmojisMutation(),
{
changedOnly: false,
onFinish: ({ data }) => {
if (data) {
// uncheck all successfully processed emoji
const processed = data.map((emoji) => {
return [emoji.id, { checked: false }];
});
form.selectedEmoji.updateMultiple(processed);
}
}
}
);
const buttonsInactive = form.selectedEmoji.someSelected
? {
disabled: false,
title: ""
}
: {
disabled: true,
title: "No emoji selected, cannot perform any actions"
};
const checkListExtraProps = useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]);
return (
<div className="parsed">
<span>This {type == "statuses" ? "status" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<form onSubmit={formSubmit}>
<CheckList
field={form.selectedEmoji}
header={<></>}
EntryComponent={EmojiEntry}
getExtraProps={checkListExtraProps}
/>
<CategorySelect
field={form.category}
children={[]}
/>
<div className="action-buttons row">
<MutationButton
name="copy"
label="Copy to local emoji"
result={result}
showError={false}
{...buttonsInactive}
/>
<MutationButton
name="disable"
label="Disable"
result={result}
className="button danger"
showError={false}
{...buttonsInactive}
/>
</div>
{result.error && (
Array.isArray(result.error)
? <ErrorList errors={result.error} />
: <Error error={result.error} />
)}
</form>
</div>
);
}
function ErrorList({ errors }) {
return (
<div className="error">
One or multiple emoji failed to process:
{errors.map(([shortcode, err]) => (
<div key={shortcode}>
<b>{shortcode}:</b> {err}
</div>
))}
</div>
);
}
function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } }) {
const shortcodeField = useTextInput("shortcode", {
defaultValue: emoji.shortcode,
validator: function validateShortcode(code) {
return (emoji.checked && localEmojiCodes.has(code))
? "Shortcode already in use"
: "";
}
});
useEffect(() => {
if (emoji.valid != shortcodeField.valid) {
onChange({ valid: shortcodeField.valid });
}
}, [onChange, emoji.valid, shortcodeField.valid]);
useEffect(() => {
shortcodeField.validate();
// only need this update if it's the emoji.checked that updated, not shortcodeField
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [emoji.checked]);
return (
<>
<img className="emoji" src={emoji.url} title={emoji.shortcode} />
<TextInput
field={shortcodeField}
onChange={(e) => {
shortcodeField.onChange(e);
onChange({ shortcode: e.target.value, checked: true });
}}
/>
</>
);
}

View file

@ -0,0 +1,177 @@
/*
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 { Route, Router, Switch } from "wouter";
import EmojiDetail from "./emoji/local/detail";
import { EmojiOverview } from "./emoji/local/overview";
import RemoteEmoji from "./emoji/remote";
import InstanceSettings from "./settings";
import { InstanceRuleDetail, InstanceRules } from "./settings/rules";
import Media from "./actions/media";
import Keys from "./actions/keys";
/*
EXPORTED COMPONENTS
*/
/**
* Admininistration menu. Admin actions,
* emoji import, instance settings.
*/
export function AdminMenu() {
return (
<MenuItem
name="Administration"
itemUrl="admin"
defaultChild="actions"
permissions={["admin"]}
>
<MenuItem
name="Instance Settings"
itemUrl="instance-settings"
icon="fa-sliders"
/>
<MenuItem
name="Instance Rules"
itemUrl="instance-rules"
icon="fa-dot-circle-o"
/>
<AdminEmojisMenu />
<AdminActionsMenu />
</MenuItem>
);
}
/**
* Admininistration router. Admin actions,
* emoji import, instance settings.
*/
export function AdminRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/admin";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Route path="/instance-settings" component={InstanceSettings}/>
<Route path="/instance-rules" component={InstanceRules} />
<Route path="/instance-rules/:ruleId" component={InstanceRuleDetail} />
<AdminEmojisRouter />
<AdminActionsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/*
INTERNAL COMPONENTS
*/
/*
MENUS
*/
function AdminActionsMenu() {
return (
<MenuItem
name="Actions"
itemUrl="actions"
defaultChild="media"
icon="fa-bolt"
>
<MenuItem
name="Media"
itemUrl="media"
icon="fa-photo"
/>
<MenuItem
name="Keys"
itemUrl="keys"
icon="fa-key-modern"
/>
</MenuItem>
);
}
function AdminEmojisMenu() {
return (
<MenuItem
name="Custom Emoji"
itemUrl="emojis"
defaultChild="local"
icon="fa-smile-o"
>
<MenuItem
name="Local"
itemUrl="local"
icon="fa-home"
/>
<MenuItem
name="Remote"
itemUrl="remote"
icon="fa-cloud"
/>
</MenuItem>
);
}
/*
ROUTERS
*/
function AdminEmojisRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/emojis";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/local/:emojiId" component={EmojiDetail} />
<Route path="/local" component={EmojiOverview} />
<Route path="/remote" component={RemoteEmoji} />
<Route component={EmojiOverview}/>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}
function AdminActionsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/actions";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/media" component={Media} />
<Route path="/keys" component={Keys} />
<Route component={Media}/>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}

View file

@ -0,0 +1,190 @@
/*
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 { useTextInput, useFileInput } from "../../../lib/form";
const useFormSubmit = require("../../../lib/form/submit").default;
import { TextInput, TextArea, FileInput } from "../../../components/form/inputs";
const FormWithData = require("../../../lib/form/form-with-data").default;
import MutationButton from "../../../components/form/mutation-button";
import { useInstanceV1Query } from "../../../lib/query";
import { useUpdateInstanceMutation } from "../../../lib/query/admin";
import { InstanceV1 } from "../../../lib/types/instance";
export default function InstanceSettings() {
return (
<FormWithData
dataQuery={useInstanceV1Query}
DataForm={InstanceSettingsForm}
/>
);
}
interface InstanceSettingsFormProps{
data: InstanceV1;
}
function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
const titleLimit = 40;
const shortDescLimit = 500;
const descLimit = 5000;
const termsLimit = 5000;
const form = {
title: useTextInput("title", {
source: instance,
validator: (val: string) => val.length <= titleLimit ? "" : `Instance title is ${val.length} characters; must be ${titleLimit} characters or less`
}),
thumbnail: useFileInput("thumbnail", { withPreview: true }),
thumbnailDesc: useTextInput("thumbnail_description", { source: instance }),
shortDesc: useTextInput("short_description", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.short_description_text,
validator: (val: string) => val.length <= shortDescLimit ? "" : `Instance short description is ${val.length} characters; must be ${shortDescLimit} characters or less`
}),
description: useTextInput("description", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.description_text,
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
}),
terms: useTextInput("terms", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.terms_text,
validator: (val: string) => val.length <= termsLimit ? "" : `Instance terms and conditions is ${val.length} characters; must be ${termsLimit} characters or less`
}),
contactUser: useTextInput("contact_username", { source: instance, valueSelector: (s) => s.contact_account?.username }),
contactEmail: useTextInput("contact_email", { source: instance, valueSelector: (s) => s.email })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation());
return (
<form onSubmit={submitForm}>
<h1>Instance Settings</h1>
<div className="form-section-docs">
<h3>Appearance</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-appearance"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextInput
field={form.title}
label={`Instance title (max ${titleLimit} characters)`}
placeholder="My GoToSocial instance"
/>
<div className="file-upload" aria-labelledby="avatar">
<strong id="avatar">Instance avatar (1:1 images look best)</strong>
<div className="file-upload-with-preview">
<img
className="preview avatar"
src={form.thumbnail.previewValue ?? instance?.thumbnail}
alt={form.thumbnailDesc.value ?? (instance?.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")}
/>
<div className="file-input-with-image-description">
<FileInput
field={form.thumbnail}
accept="image/png, image/jpeg, image/webp, image/gif"
/>
<TextInput
field={form.thumbnailDesc}
label="Avatar image description"
placeholder="A cute drawing of a smiling sloth."
/>
</div>
</div>
</div>
<div className="form-section-docs">
<h3>Descriptors</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-descriptors"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextArea
field={form.shortDesc}
label={`Short description (markdown accepted, max ${shortDescLimit} characters)`}
placeholder="A small testing instance for the GoToSocial alpha software."
rows={6}
/>
<TextArea
field={form.description}
label={`Full description (markdown accepted, max ${descLimit} characters)`}
placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
rows={6}
/>
<TextArea
field={form.terms}
label={`Terms & Conditions (markdown accepted, max ${termsLimit} characters)`}
placeholder="Terms and conditions of using this instance, data policy, imprint, GDPR stuff, yadda yadda."
rows={6}
/>
<div className="form-section-docs">
<h3>Contact info</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-contact-info"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextInput
field={form.contactUser}
label="Contact user (local account username)"
placeholder="admin"
/>
<TextInput
field={form.contactEmail}
label="Contact email"
placeholder="admin@example.com"
/>
<MutationButton label="Save" result={result} disabled={false} />
</form>
);
}

View file

@ -0,0 +1,151 @@
/*
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, Redirect, useParams } from "wouter";
import { useInstanceRulesQuery, useAddInstanceRuleMutation, useUpdateInstanceRuleMutation, useDeleteInstanceRuleMutation } from "../../../lib/query";
import { useBaseUrl } from "../../../lib/navigation/util";
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 { Error } from "../../../components/error";
import BackButton from "../../../components/back-button";
import { InstanceRule, MappedRules } from "../../../lib/types/rules";
import Loading from "../../../components/loading";
import FormWithData from "../../../lib/form/form-with-data";
export function InstanceRules() {
return (
<>
<h1>Instance Rules</h1>
<FormWithData
dataQuery={useInstanceRulesQuery}
DataForm={InstanceRulesForm}
/>
</>
);
}
function InstanceRulesForm({ data: rules }: { data: MappedRules }) {
const baseUrl = useBaseUrl();
const newRule = useTextInput("text");
const [submitForm, result] = useFormSubmit({ newRule }, useAddInstanceRuleMutation(), {
changedOnly: true,
onFinish: () => newRule.reset()
});
return (
<form onSubmit={submitForm} className="new-rule">
<ol className="instance-rules">
{Object.values(rules).map((rule: InstanceRule) => (
<Link className="rule" to={`~${baseUrl}/instance-rules/${rule.id}`}>
<li>
<h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
</li>
<span>{new Date(rule.created_at).toLocaleString()}</span>
</Link>
))}
</ol>
<TextArea
field={newRule}
label="New instance rule"
/>
<MutationButton
disabled={newRule.value === undefined || newRule.value.length === 0}
label="Add rule"
result={result}
/>
</form>
);
}
export function InstanceRuleDetail() {
const baseUrl = useBaseUrl();
const params: { ruleId: string } = useParams();
const { data: rules, isLoading, isError, error } = useInstanceRulesQuery();
if (isLoading) {
return <Loading />;
} else if (isError) {
return <Error error={error} />;
}
if (rules === undefined) {
throw "undefined rules";
}
return (
<>
<BackButton to={`~${baseUrl}/instance-rules`} />
<EditInstanceRuleForm rule={rules[params.ruleId]} />
</>
);
}
function EditInstanceRuleForm({ rule }) {
const baseUrl = useBaseUrl();
const form = {
id: useValue("id", rule.id),
rule: useTextInput("text", { defaultValue: rule.text })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceRuleMutation());
const [deleteRule, deleteResult] = useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
if (result.isSuccess || deleteResult.isSuccess) {
return (
<Redirect to={`~${baseUrl}/instance-rules`} />
);
}
return (
<div className="rule-detail">
<form onSubmit={submitForm}>
<TextArea
field={form.rule}
/>
<div className="action-buttons row">
<MutationButton
label="Save"
showError={false}
result={result}
disabled={!form.rule.hasChanged()}
/>
<MutationButton
disabled={false}
type="button"
onClick={() => deleteRule(rule.id)}
label="Delete"
className="button danger"
showError={false}
result={deleteResult}
/>
</div>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</form>
</div>
);
}

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

View file

@ -0,0 +1,208 @@
/*
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 FormWithData from "../../lib/form/form-with-data";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useArrayInput, useTextInput } from "../../lib/form";
import { TextInput } from "../../components/form/inputs";
import useFormSubmit from "../../lib/form/submit";
import MutationButton from "../../components/form/mutation-button";
import { useAliasAccountMutation, useMoveAccountMutation } from "../../lib/query/user";
import { FormContext, useWithFormContext } from "../../lib/form/context";
import { store } from "../../redux/store";
export default function UserMigration() {
return (
<FormWithData
dataQuery={useVerifyCredentialsQuery}
DataForm={UserMigrationForm}
/>
);
}
function UserMigrationForm({ data: profile }) {
return (
<>
<h2>Account Migration Settings</h2>
<p>
The following settings allow you to <strong>alias</strong> your account to
another account elsewhere, or to <strong>move</strong> to another account.
</p>
<p>
Account <strong>aliasing</strong> is harmless and reversible; you can
set and unset up to five account aliases as many times as you wish.
</p>
<p>
The account <strong>move</strong> action, on the other
hand, has serious and irreversible consequences.
</p>
<p>
For more information on account migration, please see <a href="https://docs.gotosocial.org/en/latest/user_guide/settings/#migration" target="_blank" className="docslink" rel="noreferrer">the documentation</a>.
</p>
<AliasForm data={profile} />
<MoveForm data={profile} />
</>
);
}
function AliasForm({ data: profile }) {
const form = {
alsoKnownAs: useArrayInput("also_known_as_uris", {
source: profile,
valueSelector: (p) => (
p.source?.also_known_as_uris
? p.source?.also_known_as_uris.map(entry => [entry])
: []
),
length: 5,
}),
};
const [submitForm, result] = useFormSubmit(form, useAliasAccountMutation());
return (
<form className="user-migration-alias" onSubmit={submitForm}>
<div className="form-section-docs">
<h3>Alias Account</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#alias-account"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about account aliasing (opens in a new tab)
</a>
</div>
<AlsoKnownAsURIs
field={form.alsoKnownAs}
/>
<MutationButton
disabled={false}
label="Save account aliases"
result={result}
/>
</form>
);
}
function AlsoKnownAsURIs({ field: formField }) {
return (
<div className="aliases">
<FormContext.Provider value={formField.ctx}>
{formField.value.map((data, i) => (
<AlsoKnownAsURI
key={i}
index={i}
data={data}
/>
))}
</FormContext.Provider>
</div>
);
}
function AlsoKnownAsURI({ index, data }) {
const name = `${index}`;
const form = useWithFormContext(index, {
alsoKnownAsURI: useTextInput(
name,
// Only one field per entry.
{ defaultValue: data[0] ?? "" },
),
});
return (
<TextInput
label={`Alias #${index+1}`}
field={form.alsoKnownAsURI}
placeholder={`https://example.org/users/my_other_account_${index+1}`}
/>
);
}
function MoveForm({ data: profile }) {
let urlStr = store.getState().oauth.instanceUrl ?? "";
let url = new URL(urlStr);
const form = {
movedToURI: useTextInput("moved_to_uri", {
source: profile,
valueSelector: (p) => p.moved?.url },
),
password: useTextInput("password"),
};
const [submitForm, result] = useFormSubmit(form, useMoveAccountMutation(), {
changedOnly: false,
});
return (
<form className="user-migration-move" onSubmit={submitForm}>
<div className="form-section-docs">
<h3>Move Account</h3>
<p>
For a move to be successful, you must have already set an alias from the
target account back to the account you're moving from (ie., this account),
using the settings panel of the instance on which the target account resides.
To do this, provide the following details to the other instance:
</p>
<dl className="migration-details">
<div>
<dt>Account handle/username:</dt>
<dd>@{profile.acct}@{url.host}</dd>
</div>
<div>
<dt>Account URI:</dt>
<dd>{urlStr}/users/{profile.username}</dd>
</div>
</dl>
<br/>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#move-account"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about moving your account (opens in a new tab)
</a>
</div>
<TextInput
disabled={false}
field={form.movedToURI}
label="Move target URI"
placeholder="https://example.org/users/my_new_account"
/>
<TextInput
disabled={false}
type="password"
name="password"
field={form.password}
label="Current account password"
/>
<MutationButton
disabled={false}
label="Confirm account move"
result={result}
/>
</form>
);
}

View file

@ -0,0 +1,279 @@
/*
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 {
useTextInput,
useFileInput,
useBoolInput,
useFieldArrayInput,
useRadioInput
} from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { useWithFormContext, FormContext } from "../../lib/form/context";
import {
TextInput,
TextArea,
FileInput,
Checkbox,
RadioGroup
} from "../../components/form/inputs";
import FormWithData from "../../lib/form/form-with-data";
import FakeProfile from "../../components/fake-profile";
import MutationButton from "../../components/form/mutation-button";
import { useAccountThemesQuery, useInstanceV1Query } from "../../lib/query";
import { useUpdateCredentialsMutation } from "../../lib/query/user";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
export default function UserProfile() {
return (
<FormWithData
dataQuery={useVerifyCredentialsQuery}
DataForm={UserProfileForm}
/>
);
}
function UserProfileForm({ data: profile }) {
/*
User profile update form keys
- bool bot
- bool locked
- string display_name
- string note
- file avatar
- file header
- bool enable_rss
- bool hide_collections
- string custom_css (if enabled)
- string theme
*/
const { data: instance } = useInstanceV1Query();
const instanceConfig = React.useMemo(() => {
return {
allowCustomCSS: instance?.configuration?.accounts?.allow_custom_css === true,
maxPinnedFields: instance?.configuration?.accounts?.max_profile_fields ?? 6
};
}, [instance]);
// Parse out available theme options into nice format.
const { data: themes } = useAccountThemesQuery();
let themeOptions = { "": "Default" };
themes?.forEach((theme) => {
let key = theme.file_name;
let value = theme.title;
if (theme.description) {
value += " - " + theme.description;
}
themeOptions[key] = value;
});
const form = {
avatar: useFileInput("avatar", { withPreview: true }),
header: useFileInput("header", { withPreview: true }),
displayName: useTextInput("display_name", { source: profile }),
note: useTextInput("note", { source: profile, valueSelector: (p) => p.source?.note }),
bot: useBoolInput("bot", { source: profile }),
locked: useBoolInput("locked", { source: profile }),
discoverable: useBoolInput("discoverable", { source: profile}),
enableRSS: useBoolInput("enable_rss", { source: profile }),
hideCollections: useBoolInput("hide_collections", { source: profile }),
fields: useFieldArrayInput("fields_attributes", {
defaultValue: profile?.source?.fields,
length: instanceConfig.maxPinnedFields
}),
customCSS: useTextInput("custom_css", { source: profile, nosubmit: !instanceConfig.allowCustomCSS }),
theme: useRadioInput("theme", {
source: profile,
options: themeOptions,
}),
};
const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation(), {
changedOnly: true,
onFinish: () => {
form.avatar.reset();
form.header.reset();
}
});
return (
<form className="user-profile" onSubmit={submitForm}>
<h1>Profile</h1>
<div className="overview">
<FakeProfile
avatar={form.avatar.previewValue ?? profile.avatar}
header={form.header.previewValue ?? profile.header}
display_name={form.displayName.value ?? profile.username}
username={profile.username}
role={profile.role}
/>
<div className="files">
<div>
<FileInput
label="Header"
field={form.header}
accept="image/*"
/>
</div>
<div>
<FileInput
label="Avatar"
field={form.avatar}
accept="image/*"
/>
</div>
</div>
<div className="theme">
<div>
<b id="theme-label">Theme</b>
<br/>
<span>After choosing theme and saving, <a href={profile.url} target="_blank">open your profile</a> and refresh to see changes.</span>
</div>
<RadioGroup
aria-labelledby="theme-label"
field={form.theme}
/>
</div>
</div>
<div className="form-section-docs">
<h3>Basic Information</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#basic-information"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextInput
field={form.displayName}
label="Display name"
placeholder="A GoToSocial user"
/>
<TextArea
field={form.note}
label="Bio"
placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
rows={8}
/>
<b>Profile fields</b>
<ProfileFields
field={form.fields}
/>
<div className="form-section-docs">
<h3>Visibility and privacy</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#visibility-and-privacy"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<Checkbox
field={form.locked}
label="Manually approve follow requests"
/>
<Checkbox
field={form.discoverable}
label="Mark account as discoverable by search engines and directories"
/>
<Checkbox
field={form.enableRSS}
label="Enable RSS feed of Public posts"
/>
<Checkbox
field={form.hideCollections}
label="Hide who you follow / are followed by"
/>
<div className="form-section-docs">
<h3>Advanced</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#advanced"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextArea
field={form.customCSS}
label={`Custom CSS` + (!instanceConfig.allowCustomCSS ? ` (not enabled on this instance)` : ``)}
className="monospace"
rows={8}
disabled={!instanceConfig.allowCustomCSS}
/>
<MutationButton
disabled={false}
label="Save profile info"
result={result}
/>
</form>
);
}
function ProfileFields({ field: formField }) {
return (
<div className="fields">
<FormContext.Provider value={formField.ctx}>
{formField.value.map((data, i) => (
<Field
key={i}
index={i}
data={data}
/>
))}
</FormContext.Provider>
</div>
);
}
function Field({ index, data }) {
const form = useWithFormContext(index, {
name: useTextInput("name", { defaultValue: data.name }),
value: useTextInput("value", { defaultValue: data.value })
});
return (
<div className="entry">
<TextInput
field={form.name}
placeholder="Name"
/>
<TextInput
field={form.value}
placeholder="Value"
/>
</div>
);
}

View file

@ -0,0 +1,80 @@
/*
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 UserProfile from "./profile";
import UserSettings from "./settings";
import UserMigration from "./migration";
import { Redirect, Route, Router, Switch } from "wouter";
/**
*
* Basic user menu. Profile + accounts
* settings, post settings, migration.
*/
export function UserMenu() {
return (
<MenuItem
name="User"
itemUrl="user"
defaultChild="profile"
>
{/* Profile */}
<MenuItem
name="Profile"
itemUrl="profile"
icon="fa-user"
/>
{/* Settings */}
<MenuItem
name="Settings"
itemUrl="settings"
icon="fa-cogs"
/>
{/* Migration */}
<MenuItem
name="Migration"
itemUrl="migration"
icon="fa-exchange"
/>
</MenuItem>
);
}
export function UserRouter() {
const baseUrl = useBaseUrl();
const thisBase = "/user";
const absBase = baseUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/settings" component={UserSettings} />
<Route path="/migration" component={UserMigration} />
{/* Fallback component */}
<Route><Redirect to="/profile" /></Route>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}

View file

@ -0,0 +1,169 @@
/*
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 query from "../../lib/query";
import { useTextInput, useBoolInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { Select, TextInput, Checkbox } from "../../components/form/inputs";
import FormWithData from "../../lib/form/form-with-data";
import Languages from "../../components/languages";
import MutationButton from "../../components/form/mutation-button";
export default function UserSettings() {
return (
<FormWithData
dataQuery={query.useVerifyCredentialsQuery}
DataForm={UserSettingsForm}
/>
);
}
function UserSettingsForm({ data }) {
/* form keys
- string source[privacy]
- bool source[sensitive]
- string source[language]
- string source[status_content_type]
*/
const form = {
defaultPrivacy: useTextInput("source[privacy]", { source: data, defaultValue: "unlisted" }),
isSensitive: useBoolInput("source[sensitive]", { source: data }),
language: useTextInput("source[language]", { source: data, valueSelector: (s) => s.source.language?.toUpperCase() ?? "EN" }),
statusContentType: useTextInput("source[status_content_type]", { source: data, defaultValue: "text/plain" }),
};
const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation());
return (
<>
<h1>Account Settings</h1>
<form className="user-settings" onSubmit={submitForm}>
<div className="form-section-docs">
<h3>Post Settings</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/posts"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<Select field={form.language} label="Default post language" options={
<Languages />
}>
</Select>
<Select field={form.defaultPrivacy} label="Default post privacy" options={
<>
<option value="private">Private / followers-only</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</>
}>
</Select>
<Select field={form.statusContentType} label="Default post (and bio) format" options={
<>
<option value="text/plain">Plain (default)</option>
<option value="text/markdown">Markdown</option>
</>
}>
</Select>
<Checkbox
field={form.isSensitive}
label="Mark my posts as sensitive by default"
/>
<MutationButton
disabled={false}
label="Save settings"
result={result}
/>
</form>
<PasswordChange />
</>
);
}
function PasswordChange() {
const form = {
oldPassword: useTextInput("old_password"),
newPassword: useTextInput("new_password", {
validator(val) {
if (val != "" && val == form.oldPassword.value) {
return "New password same as old password";
}
return "";
}
})
};
const verifyNewPassword = useTextInput("verifyNewPassword", {
validator(val) {
if (val != "" && val != form.newPassword.value) {
return "Passwords do not match";
}
return "";
}
});
const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation());
return (
<form className="change-password" onSubmit={submitForm}>
<div className="form-section-docs">
<h3>Change Password</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#password-change"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about this (opens in a new tab)
</a>
</div>
<TextInput
type="password"
name="password"
field={form.oldPassword}
label="Current password"
autoComplete="current-password"
/>
<TextInput
type="password"
name="newPassword"
field={form.newPassword}
label="New password"
autoComplete="new-password"
/>
<TextInput
type="password"
name="confirmNewPassword"
field={verifyNewPassword}
label="Confirm new password"
autoComplete="new-password"
/>
<MutationButton
disabled={false}
label="Change password"
result={result}
/>
</form>
);
}