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"} +

+
+
+ +
+ {Array(6).map((_, i) => ( + + ))} +
+
+ +
+
+
+ ); + } +} 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"