diff --git a/package.json b/package.json
index 8d0a7d17..b43285ab 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"inferno-router": "^8.2.2",
"inferno-server": "^8.2.2",
"jwt-decode": "^3.1.2",
- "lemmy-js-client": "^0.19.0-rc.12",
+ "lemmy-js-client": "^0.19.0-rc.13",
"lodash.isequal": "^4.5.0",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
diff --git a/src/shared/components/common/totp-modal.tsx b/src/shared/components/common/totp-modal.tsx
new file mode 100644
index 00000000..a2030f84
--- /dev/null
+++ b/src/shared/components/common/totp-modal.tsx
@@ -0,0 +1,133 @@
+import { Component, linkEvent } from "inferno";
+import { I18NextService } from "../../services";
+
+interface TotpModalProps {
+ onSubmit: (totp: string) => void;
+ type: "login" | "remove" | "generate";
+ secretUrl?: string;
+}
+interface TotpModalState {
+ totp: string;
+}
+
+const TOTP_LENGTH = 6;
+
+function focusInput() {
+ document.getElementById("totp-input-0")?.focus();
+}
+
+function handleInput(
+ { modal, i }: { modal: TotpModal; i: number },
+ event: any,
+) {
+ const { totp } = modal.state;
+
+ if (totp.length >= TOTP_LENGTH) {
+ modal.props.onSubmit(totp);
+ } else {
+ modal.setState(prev => ({
+ totp: prev.totp + event.target.value,
+ }));
+ document.getElementById(`totp-input-${i + 1}`)?.focus();
+ }
+}
+
+export default class TotpModal extends Component<
+ TotpModalProps,
+ TotpModalState
+> {
+ state: TotpModalState = {
+ totp: "",
+ };
+
+ constructor(props: TotpModalProps, context: any) {
+ super(props, context);
+ }
+
+ componentDidMount() {
+ document
+ .getElementById("totpModal")
+ ?.addEventListener("shown.bs.modal", focusInput);
+ }
+
+ componentWillUnmount() {
+ document
+ .getElementById("totpModal")
+ ?.removeEventListener("shown.bs.modal", focusInput);
+ }
+
+ render() {
+ const { type } = this.props;
+ const { totp } = this.state;
+
+ return (
+
+
+
+
+
+ {type === "generate"
+ ? "Generate TOTP"
+ : type === "remove"
+ ? "Remove TOTP"
+ : "Enter TOTP"}
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx
index 7fab421e..7c3fe7cf 100644
--- a/src/shared/components/person/settings.tsx
+++ b/src/shared/components/person/settings.tsx
@@ -24,6 +24,7 @@ import {
BlockPersonResponse,
CommunityBlockView,
DeleteAccountResponse,
+ GenerateTotpSecretResponse,
GetFederatedInstancesResponse,
GetSiteResponse,
Instance,
@@ -32,10 +33,16 @@ import {
LoginResponse,
PersonBlockView,
SortType,
+ UpdateTotpResponse,
} from "lemmy-js-client";
import { elementUrl, emDash, relTags } from "../../config";
import { FirstLoadService, UserService } from "../../services";
-import { HttpService, RequestState } from "../../services/HttpService";
+import {
+ EMPTY_REQUEST,
+ HttpService,
+ LOADING_REQUEST,
+ RequestState,
+} from "../../services/HttpService";
import { I18NextService, languages } from "../../services/I18NextService";
import { setupTippy } from "../../tippy";
import { toast } from "../../toast";
@@ -52,6 +59,7 @@ import Tabs from "../common/tabs";
import { CommunityLink } from "../community/community-link";
import { PersonListing } from "./person-listing";
import { InitialFetchRequest } from "../../interfaces";
+import TotpModal from "../common/totp-modal";
type SettingsData = RouteDataResponse<{
instancesRes: GetFederatedInstancesResponse;
@@ -62,6 +70,8 @@ interface SettingsState {
changePasswordRes: RequestState;
deleteAccountRes: RequestState;
instancesRes: RequestState;
+ generateTotpRes: RequestState;
+ updateTotpRes: RequestState;
// TODO redo these forms
saveUserSettingsForm: {
show_nsfw?: boolean;
@@ -85,7 +95,6 @@ interface SettingsState {
show_read_posts?: boolean;
show_new_post_notifs?: boolean;
discussion_languages?: number[];
- generate_totp_2fa?: boolean;
open_links_in_new_tab?: boolean;
};
changePasswordForm: {
@@ -147,6 +156,21 @@ const Filter = ({
);
+async function handleGenerateTotp(i: Settings, event: any) {
+ i.setState({ generateTotpRes: LOADING_REQUEST });
+
+ const generateTotpRes = await HttpService.client.generateTotpSecret();
+
+ if (generateTotpRes.state === "failed") {
+ event.stopPropagation();
+ toast(generateTotpRes.msg, "danger");
+ }
+
+ i.setState({
+ generateTotpRes,
+ });
+}
+
export class Settings extends Component {
private isoData = setIsoData(this.context);
state: SettingsState = {
@@ -170,6 +194,8 @@ export class Settings extends Component {
searchPersonOptions: [],
searchInstanceOptions: [],
isIsomorphic: false,
+ generateTotpRes: EMPTY_REQUEST,
+ updateTotpRes: EMPTY_REQUEST,
};
constructor(props: any, context: any) {
@@ -193,6 +219,10 @@ export class Settings extends Component {
this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
this.handleBlockInstance = this.handleBlockInstance.bind(this);
+ this.handleToggle2fa = this.handleToggle2fa.bind(this);
+ this.handleEnable2fa = this.handleEnable2fa.bind(this);
+ this.handleDisable2fa = this.handleDisable2fa.bind(this);
+
const mui = UserService.Instance.myUserInfo;
if (mui) {
const {
@@ -1001,57 +1031,70 @@ export class Settings extends Component {
}
totpSection() {
- const totpUrl =
- UserService.Instance.myUserInfo?.local_user_view.local_user.totp_2fa_url;
+ const totpEnabled =
+ !!UserService.Instance.myUserInfo?.local_user_view.local_user
+ .totp_2fa_enabled;
+ const { generateTotpRes } = this.state;
return (
<>
- {!totpUrl && (
-
-
-
-
-
+ {totpEnabled ? (
+
+
+
- )}
+ ) : (
+
+
- {totpUrl && (
- <>
-
-
-
-
-
-
-
- >
+
+
)}
>
);
}
+ async handleToggle2fa(totp: string, enabled: boolean) {
+ this.setState({ updateTotpRes: LOADING_REQUEST });
+
+ const updateTotpRes = await HttpService.client.updateTotp({
+ enabled,
+ totp_token: totp,
+ });
+
+ if (updateTotpRes.state === "failed") {
+ toast(updateTotpRes.msg, "danger");
+ }
+
+ this.setState({ updateTotpRes });
+ }
+
+ handleEnable2fa(totp: string) {
+ this.handleToggle2fa(totp, true);
+ }
+
+ handleDisable2fa(totp: string) {
+ this.handleToggle2fa(totp, false);
+ }
+
handlePersonSearch = debounce(async (text: string) => {
this.setState({ searchPersonLoading: true });
@@ -1248,19 +1291,12 @@ export class Settings extends Component
{
);
}
- handleGenerateTotp(i: Settings, event: any) {
- // Coerce false to undefined here, so it won't generate it.
- const checked: boolean | undefined = event.target.checked || undefined;
- if (checked) {
- toast(I18NextService.i18n.t("two_factor_setup_instructions"));
- }
- i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
- }
+ async handleGenerateTotp(i: Settings) {
+ i.setState({ generateTotpRes: LOADING_REQUEST });
- handleRemoveTotp(i: Settings, event: any) {
- // Coerce true to undefined here, so it won't generate it.
- const checked: boolean | undefined = !event.target.checked && undefined;
- i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
+ i.setState({
+ generateTotpRes: await HttpService.client.generateTotpSecret(),
+ });
}
handleSendNotificationsToEmailChange(i: Settings, event: any) {
diff --git a/src/shared/services/HttpService.ts b/src/shared/services/HttpService.ts
index 8ba9ac6e..6e0313e9 100644
--- a/src/shared/services/HttpService.ts
+++ b/src/shared/services/HttpService.ts
@@ -3,13 +3,17 @@ import { LemmyHttp } from "lemmy-js-client";
import { toast } from "../toast";
import { I18NextService } from "./I18NextService";
-export type EmptyRequestState = {
- state: "empty";
-};
+export const EMPTY_REQUEST = {
+ state: "empty",
+} as const;
-type LoadingRequestState = {
- state: "loading";
-};
+export type EmptyRequestState = typeof EMPTY_REQUEST;
+
+export const LOADING_REQUEST = {
+ state: "loading",
+} as const;
+
+type LoadingRequestState = typeof LOADING_REQUEST;
export type FailedRequestState = {
state: "failed";
diff --git a/yarn.lock b/yarn.lock
index 5df2a0da..eb1988b5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6035,10 +6035,10 @@ leac@^0.6.0:
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
-lemmy-js-client@^0.19.0-rc.12:
- version "0.19.0-rc.12"
- resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.19.0-rc.12.tgz#e3bd4e21b1966d583ab790ef70ece8394b012b48"
- integrity sha512-1iu2fW9vlb3TrI+QR/ODP3+5pWZB0rUqL1wH09IzomDXohCqoQvfmXpwArmgF4Eq8GZgjkcfeMDC2gMrfw/i7Q==
+lemmy-js-client@^0.19.0-rc.13:
+ version "0.19.0-rc.13"
+ resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.19.0-rc.13.tgz#e0e15ba6fe3a08cb85130eea7eec4bd2773999f9"
+ integrity sha512-JP9oEh1+Wfttqx5O5EMAVIR/hFVS66iVKmEo8/Uxw8fJfyUeQo7BhKvG8LTYegBE39Womgyu3KxXb7Jy9DRI5A==
dependencies:
cross-fetch "^3.1.5"
form-data "^4.0.0"