[feature] add TOTP two-factor authentication (2FA) (#3960)

* [feature] add TOTP two-factor authentication (2FA)

* use byteutil.S2B to avoid allocations when comparing + generating password hashes

* don't bother with string conversion  for consts

* use io.ReadFull

* use MustGenerateSecret for backup codes

* rename util functions
This commit is contained in:
tobi
2025-04-07 16:14:41 +02:00
committed by GitHub
parent 6f24205a26
commit 365b575341
78 changed files with 5593 additions and 825 deletions

View File

@ -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>({

View File

@ -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({

View File

@ -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;

View File

@ -31,4 +31,5 @@ export interface User {
disabled: boolean;
approved: boolean;
reset_password_sent_at?: string;
two_factor_enabled_at?: string;
}

View File

@ -71,7 +71,8 @@ export const store = configureStore({
PERSIST,
PURGE,
REGISTER,
]
],
ignoredPaths: ['api.queries.twoFactorQRCodePng(undefined).data.data'],
}
}).concat(gtsApi.middleware);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,11 @@ export default function UserMenu() {
itemUrl="profile"
icon="fa-user"
/>
<MenuItem
name="Account"
itemUrl="account"
icon="fa-user-secret"
/>
<MenuItem
name="Posts"
itemUrl="posts"
@ -48,11 +53,6 @@ export default function UserMenu() {
itemUrl="interaction_requests"
icon="fa-commenting-o"
/>
<MenuItem
name="Email & Password"
itemUrl="emailpassword"
icon="fa-user-secret"
/>
<MenuItem
name="Migration"
itemUrl="migration"

View File

@ -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>

View File

@ -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 {

View File

@ -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>