From 486585890d674ce3e160d2a8f1e6835e181b0b08 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Tue, 16 Jan 2024 18:28:56 +0100 Subject: [PATCH] [feature] Move + alias account via settings panel (#2519) * [feature] Move + alias account via settings panel * lint * type a bit more diligently --- web/source/settings/index.js | 9 +- web/source/settings/lib/form/array.ts | 92 ++++++++ web/source/settings/lib/form/field-array.tsx | 2 +- .../settings/lib/form/get-form-mutations.ts | 9 +- web/source/settings/lib/form/index.ts | 5 +- web/source/settings/lib/form/types.ts | 13 +- web/source/settings/lib/query/user/index.ts | 30 +++ web/source/settings/lib/types/migration.ts | 27 +++ web/source/settings/style.css | 60 ++++- web/source/settings/user/migration.tsx | 206 ++++++++++++++++++ .../settings/user/{profile.js => profile.tsx} | 37 ++-- .../user/{settings.js => settings.tsx} | 43 ++-- 12 files changed, 476 insertions(+), 57 deletions(-) create mode 100644 web/source/settings/lib/form/array.ts create mode 100644 web/source/settings/lib/types/migration.ts create mode 100644 web/source/settings/user/migration.tsx rename web/source/settings/user/{profile.js => profile.tsx} (88%) rename web/source/settings/user/{settings.js => settings.tsx} (85%) diff --git a/web/source/settings/index.js b/web/source/settings/index.js index 2ca396ed5..0a99c44e7 100644 --- a/web/source/settings/index.js +++ b/web/source/settings/index.js @@ -30,6 +30,10 @@ const Loading = require("./components/loading"); const UserLogoutCard = require("./components/user-logout-card"); const { RoleContext } = require("./lib/navigation/util"); +const UserProfile = require("./user/profile").default; +const UserSettings = require("./user/settings").default; +const UserMigration = require("./user/migration").default; + const DomainPerms = require("./admin/domain-permissions").default; const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default; @@ -39,8 +43,9 @@ require("./style.css"); const { Sidebar, ViewRouter } = createNavigation("/settings", [ Menu("User", [ - Item("Profile", { icon: "fa-user" }, require("./user/profile")), - Item("Settings", { icon: "fa-cogs" }, require("./user/settings")), + Item("Profile", { icon: "fa-user" }, UserProfile), + Item("Settings", { icon: "fa-cogs" }, UserSettings), + Item("Migration", { icon: "fa-exchange" }, UserMigration), ]), Menu("Moderation", { url: "admin", diff --git a/web/source/settings/lib/form/array.ts b/web/source/settings/lib/form/array.ts new file mode 100644 index 000000000..7ddf9499c --- /dev/null +++ b/web/source/settings/lib/form/array.ts @@ -0,0 +1,92 @@ +/* + 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 . +*/ + +import { useRef, useMemo } from "react"; + +import type { + CreateHookNames, + HookOpts, + ArrayInputHook, + HookedForm, +} from "./types"; +import getFormMutations from "./get-form-mutations"; + +function parseFields(entries: HookedForm[], length: number): HookedForm[] { + const fields: HookedForm[] = []; + + for (let i = 0; i < length; i++) { + if (entries[i] != undefined) { + fields[i] = Object.assign({}, entries[i]); + } else { + fields[i] = {}; + } + } + + return fields; +} + +export default function useArrayInput( + { name }: CreateHookNames, + { + initialValue, + length = 0, + }: HookOpts, +): ArrayInputHook { + const _default: HookedForm[] = Array(length); + const fields = useRef(_default); + + const value = useMemo( + () => parseFields(initialValue, length), + [initialValue, length], + ); + + function hasUpdate() { + return Object.values(fields.current).some((fieldSet) => { + const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true }); + return updatedFields.length > 0; + }); + } + + return { + _default, + name, + Name: "", + value, + ctx: fields.current, + maxLength: length, + hasChanged: hasUpdate, + selectedValues() { + if (hasUpdate()) { + return Object.values(fields.current) + // Extract all form fields. + .flatMap((fieldSet) => { + return getFormMutations( + fieldSet, + { changedOnly: false }, + ).updatedFields; + }) + // Get just value from each + // field, discarding name. + .map((field) => field.value); + } else { + return []; + } + } + }; +} diff --git a/web/source/settings/lib/form/field-array.tsx b/web/source/settings/lib/form/field-array.tsx index 275bf2b1b..1239f033e 100644 --- a/web/source/settings/lib/form/field-array.tsx +++ b/web/source/settings/lib/form/field-array.tsx @@ -42,7 +42,7 @@ function parseFields(entries: HookedForm[], length: number): HookedForm[] { return fields; } -export default function useArrayInput( +export default function useFieldArrayInput( { name }: CreateHookNames, { initialValue, diff --git a/web/source/settings/lib/form/get-form-mutations.ts b/web/source/settings/lib/form/get-form-mutations.ts index a3dc36601..0959fcf95 100644 --- a/web/source/settings/lib/form/get-form-mutations.ts +++ b/web/source/settings/lib/form/get-form-mutations.ts @@ -22,7 +22,12 @@ import { FormInputHook, HookedForm } from "./types"; export default function getFormMutations( form: HookedForm, { changedOnly }: { changedOnly: boolean }, -) { +): { + updatedFields: FormInputHook[]; + mutationData: { + [k: string]: any; + }; +} { const updatedFields: FormInputHook[] = []; const mutationData: Array<[string, any]> = []; @@ -34,7 +39,7 @@ export default function getFormMutations( } if ("selectedValues" in field) { - // FieldArrayInputHook. + // (Field)ArrayInputHook. const selected = field.selectedValues(); if (!changedOnly || selected.length > 0) { updatedFields.push(field); diff --git a/web/source/settings/lib/form/index.ts b/web/source/settings/lib/form/index.ts index 20de33eda..409ef0328 100644 --- a/web/source/settings/lib/form/index.ts +++ b/web/source/settings/lib/form/index.ts @@ -26,6 +26,7 @@ import bool from "./bool"; import radio from "./radio"; import combobox from "./combo-box"; import checklist from "./check-list"; +import array from "./array"; import fieldarray from "./field-array"; import type { @@ -37,8 +38,9 @@ import type { FileFormInputHook, BoolFormInputHook, ComboboxFormInputHook, - FieldArrayInputHook, ChecklistInputHook, + FieldArrayInputHook, + ArrayInputHook, } from "./types"; function capitalizeFirst(str: string) { @@ -110,5 +112,6 @@ export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts< export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts) => RadioFormInputHook; export const useComboBoxInput = inputHook(combobox) as (_name: string, _opts?: HookOpts) => ComboboxFormInputHook; export const useCheckListInput = inputHook(checklist) as (_name: string, _opts?: HookOpts) => ChecklistInputHook; +export const useArrayInput = inputHook(array) as (_name: string, _opts?: HookOpts) => ArrayInputHook; export const useFieldArrayInput = inputHook(fieldarray) as (_name: string, _opts?: HookOpts) => FieldArrayInputHook; export const useValue = value as (_name: string, _initialValue: T) => FormInputHook; diff --git a/web/source/settings/lib/form/types.ts b/web/source/settings/lib/form/types.ts index 8ea194df7..17fbec53a 100644 --- a/web/source/settings/lib/form/types.ts +++ b/web/source/settings/lib/form/types.ts @@ -141,6 +141,10 @@ interface _withNew { } interface _withSelectedValues { + selectedValues: () => string[]; +} + +interface _withSelectedFieldValues { selectedValues: () => { [_: string]: any; }[] @@ -200,11 +204,16 @@ export interface ComboboxFormInputHook extends FormInputHook, _withNew, _withReset {} -export interface FieldArrayInputHook extends FormInputHook, +export interface ArrayInputHook extends FormInputHook, _withSelectedValues, _withMaxLength, _withCtx {} +export interface FieldArrayInputHook extends FormInputHook, + _withSelectedFieldValues, + _withMaxLength, + _withCtx {} + export interface Checkable { key: string; checked?: boolean; @@ -213,7 +222,7 @@ export interface Checkable { export interface ChecklistInputHook extends FormInputHook<{[k: string]: T}>, _withReset, _withToggleAll, - _withSelectedValues, + _withSelectedFieldValues, _withSomeSelected, _withUpdateMultiple { // Uses its own funky onChange handler. diff --git a/web/source/settings/lib/query/user/index.ts b/web/source/settings/lib/query/user/index.ts index a7cdad2fd..8cf64197b 100644 --- a/web/source/settings/lib/query/user/index.ts +++ b/web/source/settings/lib/query/user/index.ts @@ -19,6 +19,10 @@ import { replaceCacheOnMutation } from "../query-modifiers"; import { gtsApi } from "../gts-api"; +import type { + MoveAccountFormData, + UpdateAliasesFormData +} from "../../types/migration"; const extended = gtsApi.injectEndpoints({ endpoints: (build) => ({ @@ -38,6 +42,30 @@ const extended = gtsApi.injectEndpoints({ url: `/api/v1/user/password_change`, body: data }) + }), + aliasAccount: build.mutation({ + async queryFn(formData, _api, _extraOpts, fetchWithBQ) { + // Pull entries out from the hooked form. + const entries: String[] = []; + formData.also_known_as_uris.forEach(entry => { + if (entry) { + entries.push(entry); + } + }); + + return fetchWithBQ({ + method: "POST", + url: `/api/v1/accounts/alias`, + body: { also_known_as_uris: entries }, + }); + } + }), + moveAccount: build.mutation({ + query: (data) => ({ + method: "POST", + url: `/api/v1/accounts/move`, + body: data + }) }) }) }); @@ -45,4 +73,6 @@ const extended = gtsApi.injectEndpoints({ export const { useUpdateCredentialsMutation, usePasswordChangeMutation, + useAliasAccountMutation, + useMoveAccountMutation, } = extended; diff --git a/web/source/settings/lib/types/migration.ts b/web/source/settings/lib/types/migration.ts new file mode 100644 index 000000000..e66887a83 --- /dev/null +++ b/web/source/settings/lib/types/migration.ts @@ -0,0 +1,27 @@ +/* + 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 . +*/ + +export interface UpdateAliasesFormData { + also_known_as_uris: string[]; +} + +export interface MoveAccountFormData { + moved_to_uri: string; + password: string; +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 13642dd0c..6e19acdd4 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -51,7 +51,8 @@ ul li::before { border-radius: $br; max-width: 100%; - & > div, & > form { + & > div, + & > form { border-left: 0.2rem solid $border-accent; padding-left: 0.4rem; display: flex; @@ -59,13 +60,14 @@ ul li::before { gap: 0.5rem; margin: 1rem 0; - h2 { + h1, h2 { margin: 0; margin-top: 0.1rem; } &:only-child { border-left: none; + padding-left: none; } &:first-child { @@ -76,7 +78,8 @@ ul li::before { margin-bottom: 0; } - &.without-border { + &.without-border, + .without-border { border-left: 0; padding-left: 0; } @@ -410,6 +413,19 @@ section.with-sidebar > div, section.with-sidebar > form { } } +/* + Normalize mock profile and make profile + header preview pop a bit nicer. +*/ +.profile { + padding: 0; + + & > .profile-header { + margin-bottom: 0; + border: 0.1rem solid $gray1; + } +} + .user-profile { .overview { display: grid; @@ -418,14 +434,6 @@ section.with-sidebar > div, section.with-sidebar > form { grid-template-rows: 100%; gap: 1rem; - .profile { - padding: 0; - - .header { - border: 0.1rem solid $gray1; - } - } - .files { width: 100%; display: flex; @@ -451,6 +459,36 @@ section.with-sidebar > div, section.with-sidebar > form { } } +.migration-details { + display: flex; + flex-direction: column; + gap: 1rem; + + background-color: $gray2; + padding: 1rem; + max-width: fit-content; + border-radius: $br; + + & > div { + display: flex; + flex-direction: column; + gap: 0.25rem; + + & > dd { + font-weight: bold; + word-wrap: anywhere; + } + } +} + +.user-migration-alias { + .aliases { + display: flex; + flex-direction: column; + gap: 0.5rem; + } +} + form { display: flex; flex-direction: column; diff --git a/web/source/settings/user/migration.tsx b/web/source/settings/user/migration.tsx new file mode 100644 index 000000000..7a8b934ac --- /dev/null +++ b/web/source/settings/user/migration.tsx @@ -0,0 +1,206 @@ +/* + 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 . +*/ + +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 ( + + ); +} + +function UserMigrationForm({ data: profile }) { + let urlStr = store.getState().oauth.instanceUrl ?? ""; + let url = new URL(urlStr); + + return ( + <> +

Account Migration Settings

+

+ The following settings allow you to alias your account to another account + elsewhere, and to move your followers and following lists to another account. +

+

+ Account aliasing is harmless and reversible; you can + set and unset up to five account aliases as many times as you wish. +

+

+ The account move action, on the other hand, has serious and irreversible consequences. +

+

+ To move, you must set an alias from your account to the target account, using this settings panel. +

+

+ You must also set an alias from the target account back to your account, using + the settings panel of the instance on which the target account resides. +

+

+ Provide the following details to the other instance: +

+
+
+
Account handle/username:
+
@{profile.acct}@{url.host}
+
+
+
Account URI:
+
{urlStr}/users/{profile.username}
+
+
+

+ For more information on account migration, please see the documentation. +

+ + + + ); +} + +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 ( +
+ + + + + ); +} + +function AlsoKnownAsURIs({ field: formField }) { + return ( +
+ + {formField.value.map((data, i) => ( + + ))} + +
+ ); +} + +function AlsoKnownAsURI({ index, data }) { + const name = `${index}`; + const form = useWithFormContext(index, { + alsoKnownAsURI: useTextInput( + name, + // Only one field per entry. + { defaultValue: data[0] ?? "" }, + ), + }); + + return ( + + ); +} + +function MoveForm({ data: profile }) { + const form = { + movedToURI: useTextInput("moved_to_uri", { + source: profile, + valueSelector: (p) => p.moved?.uri }, + ), + password: useTextInput("password"), + }; + + const [submitForm, result] = useFormSubmit(form, useMoveAccountMutation()); + + return ( +
+ + + + + + ); +} diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.tsx similarity index 88% rename from web/source/settings/user/profile.js rename to web/source/settings/user/profile.tsx index 125f88e70..a03d4d247 100644 --- a/web/source/settings/user/profile.js +++ b/web/source/settings/user/profile.tsx @@ -17,41 +17,41 @@ along with this program. If not, see . */ -const React = require("react"); +import React from "react"; -const { +import { useTextInput, useFileInput, useBoolInput, useFieldArrayInput -} = require("../lib/form"); +} from "../lib/form"; -const useFormSubmit = require("../lib/form/submit").default; -const { useWithFormContext, FormContext } = require("../lib/form/context"); +import useFormSubmit from "../lib/form/submit"; +import { useWithFormContext, FormContext } from "../lib/form/context"; -const { +import { TextInput, TextArea, FileInput, Checkbox -} = require("../components/form/inputs"); +} from "../components/form/inputs"; -const FormWithData = require("../lib/form/form-with-data").default; -const FakeProfile = require("../components/fake-profile"); -const MutationButton = require("../components/form/mutation-button"); +import FormWithData from "../lib/form/form-with-data"; +import FakeProfile from "../components/fake-profile"; +import MutationButton from "../components/form/mutation-button"; -const { useInstanceV1Query } = require("../lib/query"); -const { useUpdateCredentialsMutation } = require("../lib/query/user"); -const { useVerifyCredentialsQuery } = require("../lib/query/oauth"); +import { useInstanceV1Query } from "../lib/query"; +import { useUpdateCredentialsMutation } from "../lib/query/user"; +import { useVerifyCredentialsQuery } from "../lib/query/oauth"; -module.exports = function UserProfile() { +export default function UserProfile() { return ( ); -}; +} function UserProfileForm({ data: profile }) { /* @@ -91,6 +91,7 @@ function UserProfileForm({ data: profile }) { }; const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation(), { + changedOnly: true, onFinish: () => { form.avatar.reset(); form.header.reset(); @@ -195,7 +196,11 @@ function UserProfileForm({ data: profile }) { rows={8} disabled={!instanceConfig.allowCustomCSS} /> - + ); } diff --git a/web/source/settings/user/settings.js b/web/source/settings/user/settings.tsx similarity index 85% rename from web/source/settings/user/settings.js rename to web/source/settings/user/settings.tsx index 31ea8c39a..645ef5fd4 100644 --- a/web/source/settings/user/settings.js +++ b/web/source/settings/user/settings.tsx @@ -17,35 +17,28 @@ along with this program. If not, see . */ -const React = require("react"); +import React from "react"; -const query = require("../lib/query"); +import query from "../lib/query"; -const { - useTextInput, - useBoolInput -} = require("../lib/form"); +import { useTextInput, useBoolInput } from "../lib/form"; -const useFormSubmit = require("../lib/form/submit").default; +import useFormSubmit from "../lib/form/submit"; -const { - Select, - TextInput, - Checkbox -} = require("../components/form/inputs"); +import { Select, TextInput, Checkbox } from "../components/form/inputs"; -const FormWithData = require("../lib/form/form-with-data").default; -const Languages = require("../components/languages"); -const MutationButton = require("../components/form/mutation-button"); +import FormWithData from "../lib/form/form-with-data"; +import Languages from "../components/languages"; +import MutationButton from "../components/form/mutation-button"; -module.exports = function UserSettings() { +export default function UserSettings() { return ( ); -}; +} function UserSettingsForm({ data }) { /* form keys @@ -94,11 +87,13 @@ function UserSettingsForm({ data }) { label="Mark my posts as sensitive by default" /> - + -
- -
+ ); } @@ -148,7 +143,11 @@ function PasswordChange() { field={verifyNewPassword} label="Confirm new password" /> - + ); } \ No newline at end of file