diff options
Diffstat (limited to 'web/source')
| -rw-r--r-- | web/source/settings/index.js | 9 | ||||
| -rw-r--r-- | web/source/settings/lib/form/array.ts | 92 | ||||
| -rw-r--r-- | web/source/settings/lib/form/field-array.tsx | 2 | ||||
| -rw-r--r-- | web/source/settings/lib/form/get-form-mutations.ts | 9 | ||||
| -rw-r--r-- | web/source/settings/lib/form/index.ts | 5 | ||||
| -rw-r--r-- | web/source/settings/lib/form/types.ts | 13 | ||||
| -rw-r--r-- | web/source/settings/lib/query/user/index.ts | 30 | ||||
| -rw-r--r-- | web/source/settings/lib/types/migration.ts | 27 | ||||
| -rw-r--r-- | web/source/settings/style.css | 60 | ||||
| -rw-r--r-- | web/source/settings/user/migration.tsx | 206 | ||||
| -rw-r--r-- | web/source/settings/user/profile.tsx (renamed from web/source/settings/user/profile.js) | 37 | ||||
| -rw-r--r-- | web/source/settings/user/settings.tsx (renamed from web/source/settings/user/settings.js) | 43 | 
12 files changed, 476 insertions, 57 deletions
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 <http://www.gnu.org/licenses/>. +*/ + +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<HookedForm[]>(_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<any>[]; +	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<string>) => RadioFormInputHook;  export const useComboBoxInput = inputHook(combobox) as (_name: string, _opts?: HookOpts<string>) => ComboboxFormInputHook;  export const useCheckListInput = inputHook(checklist) as (_name: string, _opts?: HookOpts<boolean>) => ChecklistInputHook; +export const useArrayInput = inputHook(array) as (_name: string, _opts?: HookOpts<string[]>) => ArrayInputHook;  export const useFieldArrayInput = inputHook(fieldarray) as (_name: string, _opts?: HookOpts<string>) => FieldArrayInputHook;  export const useValue = value as <T>(_name: string, _initialValue: T) => FormInputHook<T>; 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<string>,  	_withNew,  	_withReset {} -export interface FieldArrayInputHook extends FormInputHook<HookedForm[]>, +export interface ArrayInputHook extends FormInputHook<HookedForm[]>,  	_withSelectedValues,  	_withMaxLength,  	_withCtx {} +export interface FieldArrayInputHook extends FormInputHook<HookedForm[]>, +	_withSelectedFieldValues, +	_withMaxLength, +	_withCtx {} +  export interface Checkable {  	key: string;  	checked?: boolean; @@ -213,7 +222,7 @@ export interface Checkable {  export interface ChecklistInputHook<T = Checkable> 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<any, UpdateAliasesFormData>({ +			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<any, MoveAccountFormData>({ +			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 <http://www.gnu.org/licenses/>. +*/ + +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 <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 }) { +	let urlStr = store.getState().oauth.instanceUrl ?? ""; +	let url = new URL(urlStr); + +	return ( +		<> +			<h2>Account Migration Settings</h2> +			<p> +				The following settings allow you to <strong>alias</strong> your account to another account +				elsewhere, and to <strong>move</strong> your followers and following lists 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> +				To move, you must set an alias from your account to the target account, using this settings panel. +			</p> +			<p> +				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. +			</p> +			<p> +				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> +			<p> +				For more information on account migration, please see <a href="https://docs.gotosocial.org/en/latest/user_guide/settings/#alias-account" 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 without-border"> +				<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 }) { +	const form = { +		movedToURI: useTextInput("moved_to_uri", { +			source: profile, +			valueSelector: (p) => p.moved?.uri }, +		), +		password: useTextInput("password"), +	}; + +	const [submitForm, result] = useFormSubmit(form, useMoveAccountMutation()); +	 +	return ( +		<form className="user-migration-move" onSubmit={submitForm}> +			<div className="form-section-docs without-border"> +				<h3>Move Account</h3> +				<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 +				field={form.movedToURI} +				label="Move target URI" +				placeholder="https://example.org/users/my_new_account" +			/> +			<TextInput +				type="password" +				name="password" +				field={form.password} +				label="Confirm account password" +			/> +			<MutationButton +				disabled={false} +				label="Confirm account move" +				result={result} +			/> +		</form> +	); +} diff --git a/web/source/settings/user/profile.js b/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 <http://www.gnu.org/licenses/>.  */ -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 (  		<FormWithData  			dataQuery={useVerifyCredentialsQuery}  			DataForm={UserProfileForm}  		/>  	); -}; +}  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}  			/> -			<MutationButton label="Save profile info" result={result} /> +			<MutationButton +				disabled={false} +				label="Save profile info" +				result={result} +			/>  		</form>  	);  } diff --git a/web/source/settings/user/settings.js b/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 <http://www.gnu.org/licenses/>.  */ -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 (  		<FormWithData  			dataQuery={query.useVerifyCredentialsQuery}  			DataForm={UserSettingsForm}  		/>  	); -}; +}  function UserSettingsForm({ data }) {  	/* form keys @@ -94,11 +87,13 @@ function UserSettingsForm({ data }) {  					label="Mark my posts as sensitive by default"  				/> -				<MutationButton label="Save settings" result={result} /> +				<MutationButton +					disabled={false} +					label="Save settings" +					result={result} +				/>  			</form> -			<div> -				<PasswordChange /> -			</div> +			<PasswordChange />  		</>  	);  } @@ -148,7 +143,11 @@ function PasswordChange() {  				field={verifyNewPassword}  				label="Confirm new password"  			/> -			<MutationButton label="Change password" result={result} /> +			<MutationButton +				disabled={false} +				label="Change password" +				result={result} +			/>  		</form>  	);  }
\ No newline at end of file  | 
