feat: Add modal for totp settings

This commit is contained in:
SleeplessOne1917 2023-10-02 21:49:53 -04:00
parent 8a2cd127ee
commit b5fee58892
5 changed files with 239 additions and 66 deletions

View file

@ -69,7 +69,7 @@
"inferno-router": "^8.2.2", "inferno-router": "^8.2.2",
"inferno-server": "^8.2.2", "inferno-server": "^8.2.2",
"jwt-decode": "^3.1.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", "lodash.isequal": "^4.5.0",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0", "markdown-it-container": "^3.0.0",

View file

@ -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 (
<div
className="modal fade"
id="totpModal"
tabIndex={-1}
aria-hidden
aria-labelledby="#totpModalTitle"
>
<div className="modal-dialog modal-fullscreen-sm-down">
<div className="modal-content">
<header className="modal-header">
<h3 className="modal-title" id="totpModalTitle">
{type === "generate"
? "Generate TOTP"
: type === "remove"
? "Remove TOTP"
: "Enter TOTP"}
</h3>
<button
type="button"
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
/>
</header>
<form
id="totp-form"
className="modal-body d-flex flex-column justify-content-center"
>
<label
className="form-label"
id="totp-input-label"
htmlFor="totp-input-0"
>
Enter TOTP
</label>
<div className="d-flex justify-content-between align-items-center">
{Array(6).map((_, i) => (
<input
key={
i /*While using indices as keys is usually bad practice, in this case we don't have to worry about the order of the list items changing.*/
}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={1}
value={totp[i] ?? ""}
disabled={totp.length !== i}
aria-labelledby="totp-input-label"
id={`totp-input-${i}`}
className="form-control form-control-lg"
onInput={linkEvent({ modal: this, i }, handleInput)}
/>
))}
</div>
</form>
<footer className="modal-footer">
<button
type="button"
className="btn btn-danger"
data-bs-dismiss="modal"
>
{I18NextService.i18n.t("cancel")}
</button>
</footer>
</div>
</div>
</div>
);
}
}

View file

@ -24,6 +24,7 @@ import {
BlockPersonResponse, BlockPersonResponse,
CommunityBlockView, CommunityBlockView,
DeleteAccountResponse, DeleteAccountResponse,
GenerateTotpSecretResponse,
GetFederatedInstancesResponse, GetFederatedInstancesResponse,
GetSiteResponse, GetSiteResponse,
Instance, Instance,
@ -32,10 +33,16 @@ import {
LoginResponse, LoginResponse,
PersonBlockView, PersonBlockView,
SortType, SortType,
UpdateTotpResponse,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { elementUrl, emDash, relTags } from "../../config"; import { elementUrl, emDash, relTags } from "../../config";
import { FirstLoadService, UserService } from "../../services"; 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 { I18NextService, languages } from "../../services/I18NextService";
import { setupTippy } from "../../tippy"; import { setupTippy } from "../../tippy";
import { toast } from "../../toast"; import { toast } from "../../toast";
@ -52,6 +59,7 @@ import Tabs from "../common/tabs";
import { CommunityLink } from "../community/community-link"; import { CommunityLink } from "../community/community-link";
import { PersonListing } from "./person-listing"; import { PersonListing } from "./person-listing";
import { InitialFetchRequest } from "../../interfaces"; import { InitialFetchRequest } from "../../interfaces";
import TotpModal from "../common/totp-modal";
type SettingsData = RouteDataResponse<{ type SettingsData = RouteDataResponse<{
instancesRes: GetFederatedInstancesResponse; instancesRes: GetFederatedInstancesResponse;
@ -62,6 +70,8 @@ interface SettingsState {
changePasswordRes: RequestState<LoginResponse>; changePasswordRes: RequestState<LoginResponse>;
deleteAccountRes: RequestState<DeleteAccountResponse>; deleteAccountRes: RequestState<DeleteAccountResponse>;
instancesRes: RequestState<GetFederatedInstancesResponse>; instancesRes: RequestState<GetFederatedInstancesResponse>;
generateTotpRes: RequestState<GenerateTotpSecretResponse>;
updateTotpRes: RequestState<UpdateTotpResponse>;
// TODO redo these forms // TODO redo these forms
saveUserSettingsForm: { saveUserSettingsForm: {
show_nsfw?: boolean; show_nsfw?: boolean;
@ -85,7 +95,6 @@ interface SettingsState {
show_read_posts?: boolean; show_read_posts?: boolean;
show_new_post_notifs?: boolean; show_new_post_notifs?: boolean;
discussion_languages?: number[]; discussion_languages?: number[];
generate_totp_2fa?: boolean;
open_links_in_new_tab?: boolean; open_links_in_new_tab?: boolean;
}; };
changePasswordForm: { changePasswordForm: {
@ -147,6 +156,21 @@ const Filter = ({
</div> </div>
); );
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<any, SettingsState> { export class Settings extends Component<any, SettingsState> {
private isoData = setIsoData<SettingsData>(this.context); private isoData = setIsoData<SettingsData>(this.context);
state: SettingsState = { state: SettingsState = {
@ -170,6 +194,8 @@ export class Settings extends Component<any, SettingsState> {
searchPersonOptions: [], searchPersonOptions: [],
searchInstanceOptions: [], searchInstanceOptions: [],
isIsomorphic: false, isIsomorphic: false,
generateTotpRes: EMPTY_REQUEST,
updateTotpRes: EMPTY_REQUEST,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -193,6 +219,10 @@ export class Settings extends Component<any, SettingsState> {
this.handleBlockCommunity = this.handleBlockCommunity.bind(this); this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
this.handleBlockInstance = this.handleBlockInstance.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; const mui = UserService.Instance.myUserInfo;
if (mui) { if (mui) {
const { const {
@ -1001,57 +1031,70 @@ export class Settings extends Component<any, SettingsState> {
} }
totpSection() { totpSection() {
const totpUrl = const totpEnabled =
UserService.Instance.myUserInfo?.local_user_view.local_user.totp_2fa_url; !!UserService.Instance.myUserInfo?.local_user_view.local_user
.totp_2fa_enabled;
const { generateTotpRes } = this.state;
return ( return (
<> <>
{!totpUrl && ( {totpEnabled ? (
<div className="input-group mb-3">
<div className="form-check">
<input
className="form-check-input"
id="user-generate-totp"
type="checkbox"
checked={this.state.saveUserSettingsForm.generate_totp_2fa}
onChange={linkEvent(this, this.handleGenerateTotp)}
/>
<label className="form-check-label" htmlFor="user-generate-totp">
{I18NextService.i18n.t("set_up_two_factor")}
</label>
</div>
</div>
)}
{totpUrl && (
<>
<div> <div>
<a className="btn btn-secondary mb-2" href={totpUrl}> <TotpModal type="remove" onSubmit={this.handleDisable2fa} />
{I18NextService.i18n.t("two_factor_link")} <button type="button" className="btn btn-secondary">
</a> Disable 2 factor authentication
</button>
</div> </div>
<div className="input-group mb-3"> ) : (
<div className="form-check"> <div>
<input <TotpModal
className="form-check-input" type="generate"
id="user-remove-totp" secretUrl={
type="checkbox" generateTotpRes.state === "success"
checked={ ? generateTotpRes.data.totp_secret_url
this.state.saveUserSettingsForm.generate_totp_2fa === false : undefined
} }
onChange={linkEvent(this, this.handleRemoveTotp)} onSubmit={this.handleEnable2fa}
/> />
<label className="form-check-label" htmlFor="user-remove-totp">
{I18NextService.i18n.t("remove_two_factor")} <button
</label> type="button"
className="btn btn-secondary"
onClick={linkEvent(this, handleGenerateTotp)}
data-bs-toggle="modal"
data-bs-target="#totpModal"
>
Enable 2 factor authentication
</button>
</div> </div>
</div>
</>
)} )}
</> </>
); );
} }
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) => { handlePersonSearch = debounce(async (text: string) => {
this.setState({ searchPersonLoading: true }); this.setState({ searchPersonLoading: true });
@ -1248,19 +1291,12 @@ export class Settings extends Component<any, SettingsState> {
); );
} }
handleGenerateTotp(i: Settings, event: any) { async handleGenerateTotp(i: Settings) {
// Coerce false to undefined here, so it won't generate it. i.setState({ generateTotpRes: LOADING_REQUEST });
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));
}
handleRemoveTotp(i: Settings, event: any) { i.setState({
// Coerce true to undefined here, so it won't generate it. generateTotpRes: await HttpService.client.generateTotpSecret(),
const checked: boolean | undefined = !event.target.checked && undefined; });
i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
} }
handleSendNotificationsToEmailChange(i: Settings, event: any) { handleSendNotificationsToEmailChange(i: Settings, event: any) {

View file

@ -3,13 +3,17 @@ import { LemmyHttp } from "lemmy-js-client";
import { toast } from "../toast"; import { toast } from "../toast";
import { I18NextService } from "./I18NextService"; import { I18NextService } from "./I18NextService";
export type EmptyRequestState = { export const EMPTY_REQUEST = {
state: "empty"; state: "empty",
}; } as const;
type LoadingRequestState = { export type EmptyRequestState = typeof EMPTY_REQUEST;
state: "loading";
}; export const LOADING_REQUEST = {
state: "loading",
} as const;
type LoadingRequestState = typeof LOADING_REQUEST;
export type FailedRequestState = { export type FailedRequestState = {
state: "failed"; state: "failed";

View file

@ -6035,10 +6035,10 @@ leac@^0.6.0:
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
lemmy-js-client@^0.19.0-rc.12: lemmy-js-client@^0.19.0-rc.13:
version "0.19.0-rc.12" version "0.19.0-rc.13"
resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.19.0-rc.12.tgz#e3bd4e21b1966d583ab790ef70ece8394b012b48" resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.19.0-rc.13.tgz#e0e15ba6fe3a08cb85130eea7eec4bd2773999f9"
integrity sha512-1iu2fW9vlb3TrI+QR/ODP3+5pWZB0rUqL1wH09IzomDXohCqoQvfmXpwArmgF4Eq8GZgjkcfeMDC2gMrfw/i7Q== integrity sha512-JP9oEh1+Wfttqx5O5EMAVIR/hFVS66iVKmEo8/Uxw8fJfyUeQo7BhKvG8LTYegBE39Womgyu3KxXb7Jy9DRI5A==
dependencies: dependencies:
cross-fetch "^3.1.5" cross-fetch "^3.1.5"
form-data "^4.0.0" form-data "^4.0.0"