summaryrefslogtreecommitdiff
path: root/web/source/settings/views/user/account
diff options
context:
space:
mode:
Diffstat (limited to 'web/source/settings/views/user/account')
-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
4 files changed, 609 insertions, 0 deletions
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>
+ );
+}