summaryrefslogtreecommitdiff
path: root/web/source
diff options
context:
space:
mode:
Diffstat (limited to 'web/source')
-rw-r--r--web/source/settings/lib/query/gts-api.ts22
-rw-r--r--web/source/settings/lib/query/user/index.ts3
-rw-r--r--web/source/settings/lib/query/user/twofactor.ts82
-rw-r--r--web/source/settings/lib/types/user.ts1
-rw-r--r--web/source/settings/redux/store.ts3
-rw-r--r--web/source/settings/views/user/account/email.tsx123
-rw-r--r--web/source/settings/views/user/account/index.tsx75
-rw-r--r--web/source/settings/views/user/account/password.tsx103
-rw-r--r--web/source/settings/views/user/account/twofactor.tsx308
-rw-r--r--web/source/settings/views/user/emailpassword.tsx264
-rw-r--r--web/source/settings/views/user/menu.tsx10
-rw-r--r--web/source/settings/views/user/migration/index.tsx (renamed from web/source/settings/views/user/migration.tsx)24
-rw-r--r--web/source/settings/views/user/profile/profile.tsx (renamed from web/source/settings/views/user/profile.tsx)36
-rw-r--r--web/source/settings/views/user/router.tsx14
14 files changed, 754 insertions, 314 deletions
diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts
index 540191132..9d38e435d 100644
--- a/web/source/settings/lib/query/gts-api.ts
+++ b/web/source/settings/lib/query/gts-api.ts
@@ -143,15 +143,20 @@ const gtsBaseQuery: BaseQueryFn<
return headers;
},
responseHandler: (response) => {
- // Return just text if caller has
- // set a custom accept content-type.
- if (accept !== "application/json") {
- return response.text();
+ switch (true) {
+ case (accept === "application/json"):
+ // return good old
+ // fashioned JSON baby!
+ return response.json();
+ case (accept.startsWith("image/")):
+ // It's an image,
+ // return the blob.
+ return response.blob();
+ default:
+ // God knows what it
+ // is, just return text.
+ return response.text();
}
-
- // Else return good old
- // fashioned JSON baby!
- return response.json();
},
})(args, api, extraOptions);
};
@@ -174,6 +179,7 @@ export const gtsApi = createApi({
"DomainPermissionExclude",
"DomainPermissionSubscription",
"TokenInfo",
+ "User",
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({
diff --git a/web/source/settings/lib/query/user/index.ts b/web/source/settings/lib/query/user/index.ts
index 80aeea2a4..7b0914cd8 100644
--- a/web/source/settings/lib/query/user/index.ts
+++ b/web/source/settings/lib/query/user/index.ts
@@ -58,7 +58,8 @@ const extended = gtsApi.injectEndpoints({
}),
user: build.query<User, void>({
- query: () => ({url: `/api/v1/user`})
+ query: () => ({url: `/api/v1/user`}),
+ providesTags: ["User"],
}),
passwordChange: build.mutation({
diff --git a/web/source/settings/lib/query/user/twofactor.ts b/web/source/settings/lib/query/user/twofactor.ts
new file mode 100644
index 000000000..ea9d9981b
--- /dev/null
+++ b/web/source/settings/lib/query/user/twofactor.ts
@@ -0,0 +1,82 @@
+/*
+ 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 { gtsApi } from "../gts-api";
+import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
+
+const extended = gtsApi.injectEndpoints({
+ endpoints: (build) => ({
+ twoFactorQRCodeURI: build.mutation<string, void>({
+ query: () => ({
+ url: `/api/v1/user/2fa/qruri`,
+ acceptContentType: "text/plain",
+ })
+ }),
+
+ twoFactorQRCodePng: build.mutation<string, void>({
+ async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
+ const blobRes = await fetchWithBQ({
+ url: `/api/v1/user/2fa/qr.png`,
+ acceptContentType: "image/png",
+ });
+ if (blobRes.error) {
+ return { error: blobRes.error as FetchBaseQueryError };
+ }
+
+ if (blobRes.meta?.response?.status !== 200) {
+ return { error: blobRes.data };
+ }
+
+ const blob = blobRes.data as Blob;
+ const url = URL.createObjectURL(blob);
+
+ return { data: url };
+ },
+ }),
+
+ twoFactorEnable: build.mutation<string[], { password: string }>({
+ query: (formData) => ({
+ method: "POST",
+ url: `/api/v1/user/2fa/enable`,
+ asForm: true,
+ body: formData,
+ discardEmpty: true
+ })
+ }),
+
+ twoFactorDisable: build.mutation<void, { password: string }>({
+ query: (formData) => ({
+ method: "POST",
+ url: `/api/v1/user/2fa/disable`,
+ asForm: true,
+ body: formData,
+ discardEmpty: true,
+ acceptContentType: "*/*",
+ }),
+ invalidatesTags: ["User"]
+ }),
+ })
+});
+
+export const {
+ useTwoFactorQRCodeURIMutation,
+ useTwoFactorQRCodePngMutation,
+ useTwoFactorEnableMutation,
+ useTwoFactorDisableMutation,
+} = extended;
diff --git a/web/source/settings/lib/types/user.ts b/web/source/settings/lib/types/user.ts
index 92210d5d3..34f7a8430 100644
--- a/web/source/settings/lib/types/user.ts
+++ b/web/source/settings/lib/types/user.ts
@@ -31,4 +31,5 @@ export interface User {
disabled: boolean;
approved: boolean;
reset_password_sent_at?: string;
+ two_factor_enabled_at?: string;
}
diff --git a/web/source/settings/redux/store.ts b/web/source/settings/redux/store.ts
index 076f5f88d..e6826d7ad 100644
--- a/web/source/settings/redux/store.ts
+++ b/web/source/settings/redux/store.ts
@@ -71,7 +71,8 @@ export const store = configureStore({
PERSIST,
PURGE,
REGISTER,
- ]
+ ],
+ ignoredPaths: ['api.queries.twoFactorQRCodePng(undefined).data.data'],
}
}).concat(gtsApi.middleware);
}
diff --git a/web/source/settings/views/user/account/email.tsx b/web/source/settings/views/user/account/email.tsx
new file mode 100644
index 000000000..16cdebf66
--- /dev/null
+++ b/web/source/settings/views/user/account/email.tsx
@@ -0,0 +1,123 @@
+/*
+ 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 } from "../../../lib/form";
+import useFormSubmit from "../../../lib/form/submit";
+import { TextInput } from "../../../components/form/inputs";
+import MutationButton from "../../../components/form/mutation-button";
+import { useEmailChangeMutation } from "../../../lib/query/user";
+import { User } from "../../../lib/types/user";
+
+export default function EmailChange({user, oidcEnabled}: { user: User, oidcEnabled?: boolean }) {
+ const form = {
+ currentEmail: useTextInput("current_email", {
+ defaultValue: user.email,
+ nosubmit: true
+ }),
+ newEmail: useTextInput("new_email", {
+ validator: (value: string | undefined) => {
+ if (!value) {
+ return "";
+ }
+
+ if (value.toLowerCase() === user.email?.toLowerCase()) {
+ return "cannot change to your existing address";
+ }
+
+ if (value.toLowerCase() === user.unconfirmed_email?.toLowerCase()) {
+ return "you already have a pending email address change to this address";
+ }
+
+ return "";
+ },
+ }),
+ password: useTextInput("password"),
+ };
+ const [submitForm, result] = useFormSubmit(form, useEmailChangeMutation());
+
+ return (
+ <form className="change-email" onSubmit={submitForm}>
+ <div className="form-section-docs">
+ <h3>Change Email</h3>
+ { oidcEnabled && <p>
+ This instance is running with OIDC as its authorization + identity provider.
+ <br/>
+ You can still change your email address using this settings panel,
+ but it will only affect which address GoToSocial uses to contact you,
+ not the email address you use to log in.
+ <br/>
+ To change the email address you use to log in, contact your OIDC provider.
+ </p> }
+ <a
+ href="https://docs.gotosocial.org/en/latest/user_guide/settings/#email-change"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about this (opens in a new tab)
+ </a>
+ </div>
+
+ { (user.unconfirmed_email && user.unconfirmed_email !== user.email) && <>
+ <div className="info">
+ <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
+ <b>
+ You currently have a pending email address
+ change to the address: {user.unconfirmed_email}
+ <br />
+ To confirm {user.unconfirmed_email} as your new
+ address for this account, please check your email inbox.
+ </b>
+ </div>
+ </> }
+
+ <TextInput
+ type="email"
+ name="current-email"
+ field={form.currentEmail}
+ label="Current email address"
+ autoComplete="none"
+ disabled={true}
+ />
+
+ <TextInput
+ type="password"
+ name="password"
+ field={form.password}
+ label="Current password"
+ autoComplete="current-password"
+ />
+
+ <TextInput
+ type="email"
+ name="new-email"
+ field={form.newEmail}
+ label="New email address"
+ autoComplete="none"
+ />
+
+ <MutationButton
+ disabled={!form.password || !form.newEmail || !form.newEmail.valid}
+ label="Change email address"
+ result={result}
+ />
+ </form>
+ );
+}
diff --git a/web/source/settings/views/user/account/index.tsx b/web/source/settings/views/user/account/index.tsx
new file mode 100644
index 000000000..707181f3d
--- /dev/null
+++ b/web/source/settings/views/user/account/index.tsx
@@ -0,0 +1,75 @@
+/*
+ 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 EmailChange from "./email";
+import PasswordChange from "./password";
+import TwoFactor from "./twofactor";
+import { useInstanceV1Query } from "../../../lib/query/gts-api";
+import Loading from "../../../components/loading";
+import { useUserQuery } from "../../../lib/query/user";
+
+export default function Account() {
+ // Load instance data.
+ const {
+ data: instance,
+ isFetching: isFetchingInstance,
+ isLoading: isLoadingInstance
+ } = useInstanceV1Query();
+
+ // Load user data.
+ const {
+ data: user,
+ isFetching: isFetchingUser,
+ isLoading: isLoadingUser
+ } = useUserQuery();
+
+ if (
+ (isFetchingInstance || isLoadingInstance) ||
+ (isFetchingUser || isLoadingUser)
+ ) {
+ return <Loading />;
+ }
+
+ if (user === undefined) {
+ throw "could not fetch user";
+ }
+
+ if (instance === undefined) {
+ throw "could not fetch instance";
+ }
+
+ return (
+ <>
+ <h1>Account Settings</h1>
+ <EmailChange
+ oidcEnabled={instance.configuration.oidc_enabled}
+ user={user}
+ />
+ <PasswordChange
+ oidcEnabled={instance.configuration.oidc_enabled}
+ />
+ <TwoFactor
+ oidcEnabled={instance.configuration.oidc_enabled}
+ twoFactorEnabledAt={user.two_factor_enabled_at}
+ />
+ </>
+ );
+}
+
diff --git a/web/source/settings/views/user/account/password.tsx b/web/source/settings/views/user/account/password.tsx
new file mode 100644
index 000000000..a2f0eeb3b
--- /dev/null
+++ b/web/source/settings/views/user/account/password.tsx
@@ -0,0 +1,103 @@
+/*
+ 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 } from "../../../lib/form";
+import useFormSubmit from "../../../lib/form/submit";
+import { TextInput } from "../../../components/form/inputs";
+import MutationButton from "../../../components/form/mutation-button";
+import { usePasswordChangeMutation } from "../../../lib/query/user";
+
+export default function PasswordChange({ oidcEnabled }: { oidcEnabled?: boolean }) {
+ 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, usePasswordChangeMutation());
+
+ return (
+ <form className="change-password" onSubmit={submitForm}>
+ <div className="form-section-docs">
+ <h3>Change Password</h3>
+ { oidcEnabled && <p>
+ This instance is running with OIDC as its authorization + identity provider.
+ <br/>
+ This means <strong>you cannot change your password using this settings panel</strong>.
+ <br/>
+ To change your password, you should instead contact your OIDC provider.
+ </p> }
+ <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"
+ disabled={oidcEnabled}
+ />
+ <TextInput
+ type="password"
+ name="newPassword"
+ field={form.newPassword}
+ label="New password"
+ autoComplete="new-password"
+ disabled={oidcEnabled}
+ />
+ <TextInput
+ type="password"
+ name="confirmNewPassword"
+ field={verifyNewPassword}
+ label="Confirm new password"
+ autoComplete="new-password"
+ disabled={oidcEnabled}
+ />
+ <MutationButton
+ label="Change password"
+ result={result}
+ disabled={oidcEnabled ?? false}
+ />
+ </form>
+ );
+}
diff --git a/web/source/settings/views/user/account/twofactor.tsx b/web/source/settings/views/user/account/twofactor.tsx
new file mode 100644
index 000000000..217de6c04
--- /dev/null
+++ b/web/source/settings/views/user/account/twofactor.tsx
@@ -0,0 +1,308 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React, { ReactNode, useEffect, useMemo, useState } from "react";
+import { TextInput } from "../../../components/form/inputs";
+import MutationButton from "../../../components/form/mutation-button";
+import useFormSubmit from "../../../lib/form/submit";
+import {
+ useTwoFactorQRCodeURIMutation,
+ useTwoFactorDisableMutation,
+ useTwoFactorEnableMutation,
+ useTwoFactorQRCodePngMutation,
+} from "../../../lib/query/user/twofactor";
+import { useTextInput } from "../../../lib/form";
+import Loading from "../../../components/loading";
+import { Error } from "../../../components/error";
+import { HighlightedCode } from "../../../components/highlightedcode";
+import { useDispatch } from "react-redux";
+import { gtsApi } from "../../../lib/query/gts-api";
+
+interface TwoFactorProps {
+ twoFactorEnabledAt?: string,
+ oidcEnabled?: boolean,
+}
+
+export default function TwoFactor({ twoFactorEnabledAt, oidcEnabled }: TwoFactorProps) {
+ switch (true) {
+ case oidcEnabled:
+ // Can't enable if OIDC is in place.
+ return <CannotEnable />;
+ case twoFactorEnabledAt !== undefined:
+ // Already enabled. Show the disable form.
+ return <DisableForm twoFactorEnabledAt={twoFactorEnabledAt as string} />;
+ default:
+ // Not enabled. Show the enable form.
+ return <EnableForm />;
+ }
+}
+
+function CannotEnable() {
+ return (
+ <form>
+ <TwoFactorHeader
+ blurb={
+ <p>
+ OIDC is enabled for your instance. To enable 2FA, you must use your
+ instance's OIDC provider instead. Poke your admin for more information.
+ </p>
+ }
+ />
+ </form>
+ );
+}
+
+function EnableForm() {
+ const form = { code: useTextInput("code") };
+ const [ recoveryCodes, setRecoveryCodes ] = useState<string>();
+ const dispatch = useDispatch();
+
+ // Prepare trigger to submit the code and enable 2FA.
+ // If the enable call is a success, set the recovery
+ // codes state to a nice newline-separated text.
+ const [submitForm, result] = useFormSubmit(form, useTwoFactorEnableMutation(), {
+ changedOnly: true,
+ onFinish: (res) => {
+ const codes = res.data as string[];
+ if (!codes) {
+ return;
+ }
+ setRecoveryCodes(codes.join("\n"));
+ },
+ });
+
+ // When the component is unmounted, clear the user
+ // cache if 2FA was just enabled. This will prevent
+ // the recovery codes from being shown again.
+ useEffect(() => {
+ return () => {
+ if (recoveryCodes) {
+ dispatch(gtsApi.util.invalidateTags(["User"]));
+ }
+ };
+ }, [recoveryCodes, dispatch]);
+
+ return (
+ <form className="2fa-enable-form" onSubmit={submitForm}>
+ <TwoFactorHeader
+ blurb={
+ <p>
+ You can use this form to enable 2FA for your account.
+ <br/>
+ In your authenticator app, either scan the QR code, or copy
+ the 2FA secret manually, and then enter a 2FA code to verify.
+ </p>
+ }
+ />
+ {/*
+ If the enable call was successful then recovery
+ codes will now be set. Display these to the user.
+
+ If the call hasn't been made yet, show the
+ form to enable 2FA as normal.
+ */}
+ { recoveryCodes
+ ? <>
+ <p>
+ <b>Two-factor authentication is now enabled for your account!</b>
+ <br/>From now on, you will need to provide a code from your authenticator app whenever you want to sign in.
+ <br/>If you lose access to your authenticator app, you may also sign in by providing one of the below one-time recovery codes instead of a 2FA code.
+ <br/>Once you have used a recovery code once, you will not be able to use it again!
+ <br/><strong>You will not be shown these codes again, so copy them now into a safe place! Treat them like passwords!</strong>
+ </p>
+ <details>
+ <summary>Show / hide codes</summary>
+ <HighlightedCode
+ code={recoveryCodes}
+ lang="text"
+ />
+ </details>
+ </>
+ : <>
+ <CodePng />
+ <Secret />
+ <TextInput
+ name="code"
+ field={form.code}
+ label="2FA code from your authenticator app (6 numbers)"
+ autoComplete="off"
+ disabled={false}
+ maxLength={6}
+ minLength={6}
+ pattern="^\d{6}$"
+ readOnly={false}
+ />
+ <MutationButton
+ label="Enable 2FA"
+ result={result}
+ disabled={false}
+ />
+ </>
+ }
+ </form>
+ );
+}
+
+// Load and show QR code png only when
+// the "Show QR Code" button is clicked.
+function CodePng() {
+ const [
+ getPng, {
+ isUninitialized,
+ isLoading,
+ isSuccess,
+ data,
+ error,
+ reset,
+ }
+ ] = useTwoFactorQRCodePngMutation();
+
+ const [ content, setContent ] = useState<ReactNode>();
+ useEffect(() => {
+ if (isLoading) {
+ setContent(<Loading />);
+ } else if (isSuccess && data) {
+ setContent(<img src={data} height="256" width="256" />);
+ } else {
+ setContent(<Error error={error} />);
+ }
+ }, [isLoading, isSuccess, data, error]);
+
+ return (
+ <>
+ { isUninitialized
+ ? <button
+ disabled={false}
+ onClick={(e) => {
+ e.preventDefault();
+ getPng();
+ }}
+ >Show QR Code</button>
+ : <button
+ disabled={false}
+ onClick={(e) => {
+ e.preventDefault();
+ reset();
+ setContent(null);
+ }}
+ >Hide QR Code</button>
+ }
+ { content }
+ </>
+ );
+}
+
+// Get 2fa secret from server and
+// load it into clipboard on click.
+function Secret() {
+ const [
+ getURI,
+ {
+ isUninitialized,
+ isSuccess,
+ data,
+ error,
+ reset,
+ },
+ ] = useTwoFactorQRCodeURIMutation();
+
+ const [ buttonContents, setButtonContents ] = useState<ReactNode>();
+ useEffect(() => {
+ if (isUninitialized) {
+ setButtonContents("Copy 2FA secret to clipboard");
+ } else if (isSuccess && data) {
+ const url = new URL(data);
+ const secret = url.searchParams.get("secret");
+ if (!secret) {
+ throw "null secret";
+ }
+ navigator.clipboard.writeText(secret);
+ setButtonContents("Copied!");
+ setTimeout(() => { reset(); }, 3000);
+ } else {
+ setButtonContents(<Error error={error} />);
+ }
+ }, [isUninitialized, isSuccess, data, reset, error]);
+
+ return (
+ <button
+ disabled={false}
+ onClick={(e) => {
+ e.preventDefault();
+ getURI();
+ }}
+ >{buttonContents}</button>
+ );
+}
+
+function DisableForm({ twoFactorEnabledAt }: { twoFactorEnabledAt: string }) {
+ const enabledAt = useMemo(() => {
+ const enabledAt = new Date(twoFactorEnabledAt);
+ return <time dateTime={twoFactorEnabledAt}>{enabledAt.toDateString()}</time>;
+ }, [twoFactorEnabledAt]);
+
+ const form = {
+ password: useTextInput("password"),
+ };
+
+ const [submitForm, result] = useFormSubmit(form, useTwoFactorDisableMutation());
+ return (
+ <form className="2fa-disable-form" onSubmit={submitForm}>
+ <TwoFactorHeader
+ blurb={
+ <p>
+ Two-factor auth is enabled for your account, since <b>{enabledAt}</b>.
+ <br/>To disable 2FA, supply your password for verification and click "Disable 2FA".
+ </p>
+ }
+ />
+ <TextInput
+ type="password"
+ name="password"
+ field={form.password}
+ label="Current password"
+ autoComplete="current-password"
+ disabled={false}
+ />
+ <MutationButton
+ label="Disable 2FA"
+ result={result}
+ disabled={false}
+ className="danger"
+ />
+ </form>
+ );
+}
+
+function TwoFactorHeader({ blurb }: { blurb: ReactNode }) {
+ return (
+ <div className="form-section-docs">
+ <h3>Two-Factor Authentication</h3>
+ {blurb}
+ <a
+ href="https://docs.gotosocial.org/en/latest/user_guide/settings/#two-factor"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about this (opens in a new tab)
+ </a>
+ </div>
+ );
+}
diff --git a/web/source/settings/views/user/emailpassword.tsx b/web/source/settings/views/user/emailpassword.tsx
deleted file mode 100644
index 32df0e39d..000000000
--- a/web/source/settings/views/user/emailpassword.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-/*
- GoToSocial
- Copyright (C) GoToSocial Authors admin@gotosocial.org
- SPDX-License-Identifier: AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from "react";
-import { useTextInput } from "../../lib/form";
-import useFormSubmit from "../../lib/form/submit";
-import { TextInput } from "../../components/form/inputs";
-import MutationButton from "../../components/form/mutation-button";
-import { useEmailChangeMutation, usePasswordChangeMutation, useUserQuery } from "../../lib/query/user";
-import Loading from "../../components/loading";
-import { User } from "../../lib/types/user";
-import { useInstanceV1Query } from "../../lib/query/gts-api";
-
-export default function EmailPassword() {
- return (
- <>
- <h1>Email & Password Settings</h1>
- <EmailChange />
- <PasswordChange />
- </>
- );
-}
-
-function PasswordChange() {
- // Load instance data.
- const {
- data: instance,
- isFetching: isFetchingInstance,
- isLoading: isLoadingInstance
- } = useInstanceV1Query();
- if (isFetchingInstance || isLoadingInstance) {
- return <Loading />;
- }
-
- if (instance === undefined) {
- throw "could not fetch instance";
- }
-
- return <PasswordChangeForm oidcEnabled={instance.configuration.oidc_enabled} />;
-}
-
-function PasswordChangeForm({ oidcEnabled }: { oidcEnabled?: boolean }) {
- 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, usePasswordChangeMutation());
-
- return (
- <form className="change-password" onSubmit={submitForm}>
- <div className="form-section-docs">
- <h3>Change Password</h3>
- { oidcEnabled && <p>
- This instance is running with OIDC as its authorization + identity provider.
- <br/>
- This means <strong>you cannot change your password using this settings panel</strong>.
- <br/>
- To change your password, you should instead contact your OIDC provider.
- </p> }
- <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"
- disabled={oidcEnabled}
- />
- <TextInput
- type="password"
- name="newPassword"
- field={form.newPassword}
- label="New password"
- autoComplete="new-password"
- disabled={oidcEnabled}
- />
- <TextInput
- type="password"
- name="confirmNewPassword"
- field={verifyNewPassword}
- label="Confirm new password"
- autoComplete="new-password"
- disabled={oidcEnabled}
- />
- <MutationButton
- label="Change password"
- result={result}
- disabled={oidcEnabled ?? false}
- />
- </form>
- );
-}
-
-function EmailChange() {
- // Load instance data.
- const {
- data: instance,
- isFetching: isFetchingInstance,
- isLoading: isLoadingInstance
- } = useInstanceV1Query();
-
- // Load user data.
- const {
- data: user,
- isFetching: isFetchingUser,
- isLoading: isLoadingUser
- } = useUserQuery();
-
- if (
- (isFetchingInstance || isLoadingInstance) ||
- (isFetchingUser || isLoadingUser)
- ) {
- return <Loading />;
- }
-
- if (user === undefined) {
- throw "could not fetch user";
- }
-
- if (instance === undefined) {
- throw "could not fetch instance";
- }
-
- return <EmailChangeForm user={user} oidcEnabled={instance.configuration.oidc_enabled} />;
-}
-
-function EmailChangeForm({user, oidcEnabled}: { user: User, oidcEnabled?: boolean }) {
- const form = {
- currentEmail: useTextInput("current_email", {
- defaultValue: user.email,
- nosubmit: true
- }),
- newEmail: useTextInput("new_email", {
- validator: (value: string | undefined) => {
- if (!value) {
- return "";
- }
-
- if (value.toLowerCase() === user.email?.toLowerCase()) {
- return "cannot change to your existing address";
- }
-
- if (value.toLowerCase() === user.unconfirmed_email?.toLowerCase()) {
- return "you already have a pending email address change to this address";
- }
-
- return "";
- },
- }),
- password: useTextInput("password"),
- };
- const [submitForm, result] = useFormSubmit(form, useEmailChangeMutation());
-
- return (
- <form className="change-email" onSubmit={submitForm}>
- <div className="form-section-docs">
- <h3>Change Email</h3>
- { oidcEnabled && <p>
- This instance is running with OIDC as its authorization + identity provider.
- <br/>
- You can still change your email address using this settings panel,
- but it will only affect which address GoToSocial uses to contact you,
- not the email address you use to log in.
- <br/>
- To change the email address you use to log in, contact your OIDC provider.
- </p> }
- <a
- href="https://docs.gotosocial.org/en/latest/user_guide/settings/#email-change"
- target="_blank"
- className="docslink"
- rel="noreferrer"
- >
- Learn more about this (opens in a new tab)
- </a>
- </div>
-
- { (user.unconfirmed_email && user.unconfirmed_email !== user.email) && <>
- <div className="info">
- <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
- <b>
- You currently have a pending email address
- change to the address: {user.unconfirmed_email}
- <br />
- To confirm {user.unconfirmed_email} as your new
- address for this account, please check your email inbox.
- </b>
- </div>
- </> }
-
- <TextInput
- type="email"
- name="current-email"
- field={form.currentEmail}
- label="Current email address"
- autoComplete="none"
- disabled={true}
- />
-
- <TextInput
- type="password"
- name="password"
- field={form.password}
- label="Current password"
- autoComplete="current-password"
- />
-
- <TextInput
- type="email"
- name="new-email"
- field={form.newEmail}
- label="New email address"
- autoComplete="none"
- />
-
- <MutationButton
- disabled={!form.password || !form.newEmail || !form.newEmail.valid}
- label="Change email address"
- result={result}
- />
- </form>
- );
-} \ No newline at end of file
diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx
index bf4c2a7ac..4127aa8f0 100644
--- a/web/source/settings/views/user/menu.tsx
+++ b/web/source/settings/views/user/menu.tsx
@@ -39,6 +39,11 @@ export default function UserMenu() {
icon="fa-user"
/>
<MenuItem
+ name="Account"
+ itemUrl="account"
+ icon="fa-user-secret"
+ />
+ <MenuItem
name="Posts"
itemUrl="posts"
icon="fa-paper-plane"
@@ -49,11 +54,6 @@ export default function UserMenu() {
icon="fa-commenting-o"
/>
<MenuItem
- name="Email & Password"
- itemUrl="emailpassword"
- icon="fa-user-secret"
- />
- <MenuItem
name="Migration"
itemUrl="migration"
icon="fa-exchange"
diff --git a/web/source/settings/views/user/migration.tsx b/web/source/settings/views/user/migration/index.tsx
index cf71ecfb0..d2bbbdf12 100644
--- a/web/source/settings/views/user/migration.tsx
+++ b/web/source/settings/views/user/migration/index.tsx
@@ -19,27 +19,27 @@
import React from "react";
-import FormWithData from "../../lib/form/form-with-data";
+import FormWithData from "../../../lib/form/form-with-data";
-import { useVerifyCredentialsQuery } from "../../lib/query/login";
-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";
+import { useVerifyCredentialsQuery } from "../../../lib/query/login";
+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() {
+export default function Migration() {
return (
<FormWithData
dataQuery={useVerifyCredentialsQuery}
- DataForm={UserMigrationForm}
+ DataForm={MigrationForm}
/>
);
}
-function UserMigrationForm({ data: profile }) {
+function MigrationForm({ data: profile }) {
return (
<>
<h2>Account Migration Settings</h2>
diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile/profile.tsx
index d6fcbf56d..6f99a17db 100644
--- a/web/source/settings/views/user/profile.tsx
+++ b/web/source/settings/views/user/profile/profile.tsx
@@ -24,10 +24,10 @@ import {
useFileInput,
useBoolInput,
useFieldArrayInput,
-} from "../../lib/form";
+} from "../../../lib/form";
-import useFormSubmit from "../../lib/form/submit";
-import { useWithFormContext, FormContext } from "../../lib/form/context";
+import useFormSubmit from "../../../lib/form/submit";
+import { useWithFormContext, FormContext } from "../../../lib/form/context";
import {
TextInput,
@@ -35,32 +35,36 @@ import {
FileInput,
Checkbox,
Select
-} from "../../components/form/inputs";
+} from "../../../components/form/inputs";
-import FormWithData from "../../lib/form/form-with-data";
-import FakeProfile from "../../components/profile";
-import MutationButton from "../../components/form/mutation-button";
+import FormWithData from "../../../lib/form/form-with-data";
+import FakeProfile from "../../../components/profile";
+import MutationButton from "../../../components/form/mutation-button";
-import { useAccountThemesQuery, useDeleteAvatarMutation, useDeleteHeaderMutation } from "../../lib/query/user";
-import { useUpdateCredentialsMutation } from "../../lib/query/user";
-import { useVerifyCredentialsQuery } from "../../lib/query/login";
-import { useInstanceV1Query } from "../../lib/query/gts-api";
-import { Account } from "../../lib/types/account";
+import {
+ useAccountThemesQuery,
+ useDeleteAvatarMutation,
+ useDeleteHeaderMutation,
+} from "../../../lib/query/user";
+import { useUpdateCredentialsMutation } from "../../../lib/query/user";
+import { useVerifyCredentialsQuery } from "../../../lib/query/login";
+import { useInstanceV1Query } from "../../../lib/query/gts-api";
+import { Account } from "../../../lib/types/account";
-export default function UserProfile() {
+export default function Profile() {
return (
<FormWithData
dataQuery={useVerifyCredentialsQuery}
- DataForm={UserProfileForm}
+ DataForm={ProfileForm}
/>
);
}
-interface UserProfileFormProps {
+interface ProfileFormProps {
data: Account;
}
-function UserProfileForm({ data: profile }: UserProfileFormProps) {
+function ProfileForm({ data: profile }: ProfileFormProps) {
const { data: instance } = useInstanceV1Query();
const instanceConfig = React.useMemo(() => {
return {
diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx
index 0d34c171f..62eaf0f36 100644
--- a/web/source/settings/views/user/router.tsx
+++ b/web/source/settings/views/user/router.tsx
@@ -21,10 +21,9 @@ import React from "react";
import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
import { Redirect, Route, Router, Switch } from "wouter";
import { ErrorBoundary } from "../../lib/navigation/error";
-import UserProfile from "./profile";
-import UserMigration from "./migration";
+import Profile from "./profile/profile";
import PostSettings from "./posts";
-import EmailPassword from "./emailpassword";
+import Account from "./account";
import ExportImport from "./export-import";
import InteractionRequests from "./interactions";
import InteractionRequestDetail from "./interactions/detail";
@@ -33,11 +32,12 @@ import Applications from "./applications";
import NewApp from "./applications/new";
import AppDetail from "./applications/detail";
import { AppTokenCallback } from "./applications/callback";
+import Migration from "./migration";
/**
* - /settings/user/profile
+ * - /settings/user/account
* - /settings/user/posts
- * - /settings/user/emailpassword
* - /settings/user/migration
* - /settings/user/export-import
* - /settings/user/tokens
@@ -53,10 +53,10 @@ export default function UserRouter() {
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
- <Route path="/profile" component={UserProfile} />
+ <Route path="/profile" component={Profile} />
+ <Route path="/account" component={Account} />
<Route path="/posts" component={PostSettings} />
- <Route path="/emailpassword" component={EmailPassword} />
- <Route path="/migration" component={UserMigration} />
+ <Route path="/migration" component={Migration} />
<Route path="/export-import" component={ExportImport} />
<Route path="/tokens" component={Tokens} />
</Switch>