mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-11-25 13:51:13 +00:00
Refactor 2fa modal to prevent implementation details from leaking
This commit is contained in:
parent
98961d2ada
commit
62e5cba5cc
3 changed files with 113 additions and 48 deletions
|
@ -1,12 +1,21 @@
|
||||||
import { Component, linkEvent } from "inferno";
|
import {
|
||||||
|
Component,
|
||||||
|
MouseEventHandler,
|
||||||
|
RefObject,
|
||||||
|
createRef,
|
||||||
|
linkEvent,
|
||||||
|
} from "inferno";
|
||||||
import { I18NextService } from "../../services";
|
import { I18NextService } from "../../services";
|
||||||
import { toast } from "../../toast";
|
import { toast } from "../../toast";
|
||||||
|
import type { Modal } from "bootstrap";
|
||||||
|
|
||||||
interface TotpModalProps {
|
interface TotpModalProps {
|
||||||
/**Takes totp as param, returns whether submit was successful*/
|
/**Takes totp as param, returns whether submit was successful*/
|
||||||
onSubmit: (totp: string) => Promise<boolean>;
|
onSubmit: (totp: string) => Promise<boolean>;
|
||||||
|
onClose: MouseEventHandler;
|
||||||
type: "login" | "remove" | "generate";
|
type: "login" | "remove" | "generate";
|
||||||
secretUrl?: string;
|
secretUrl?: string;
|
||||||
|
show?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TotpModalState {
|
interface TotpModalState {
|
||||||
|
@ -17,13 +26,11 @@ interface TotpModalState {
|
||||||
const TOTP_LENGTH = 6;
|
const TOTP_LENGTH = 6;
|
||||||
|
|
||||||
async function handleSubmit(modal: TotpModal, totp: string) {
|
async function handleSubmit(modal: TotpModal, totp: string) {
|
||||||
const succeeded = await modal.props.onSubmit(totp);
|
const successful = await modal.props.onSubmit(totp);
|
||||||
|
|
||||||
|
if (!successful) {
|
||||||
modal.setState({ totp: "" });
|
modal.setState({ totp: "" });
|
||||||
if (succeeded) {
|
modal.inputRefs[0]?.focus();
|
||||||
document.getElementById("totp-close-button")?.click();
|
|
||||||
} else {
|
|
||||||
document.getElementById(`totp-input-0`)?.focus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,7 +44,7 @@ function handleInput(
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.setState(prev => ({ ...prev, totp: prev.totp + event.target.value }));
|
modal.setState(prev => ({ ...prev, totp: prev.totp + event.target.value }));
|
||||||
document.getElementById(`totp-input-${i + 1}`)?.focus();
|
modal.inputRefs[i + 1]?.focus();
|
||||||
|
|
||||||
const { totp } = modal.state;
|
const { totp } = modal.state;
|
||||||
if (totp.length >= TOTP_LENGTH) {
|
if (totp.length >= TOTP_LENGTH) {
|
||||||
|
@ -56,7 +63,7 @@ function handleKeyUp(
|
||||||
...prev,
|
...prev,
|
||||||
totp: prev.totp.slice(0, prev.totp.length - 1),
|
totp: prev.totp.slice(0, prev.totp.length - 1),
|
||||||
}));
|
}));
|
||||||
document.getElementById(`totp-input-${i - 1}`)?.focus();
|
modal.inputRefs[i - 1]?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,6 +84,9 @@ export default class TotpModal extends Component<
|
||||||
TotpModalProps,
|
TotpModalProps,
|
||||||
TotpModalState
|
TotpModalState
|
||||||
> {
|
> {
|
||||||
|
private readonly modalDivRef: RefObject<HTMLDivElement>;
|
||||||
|
inputRefs: (HTMLInputElement | null)[] = [];
|
||||||
|
modal: Modal;
|
||||||
state: TotpModalState = {
|
state: TotpModalState = {
|
||||||
totp: "",
|
totp: "",
|
||||||
};
|
};
|
||||||
|
@ -84,32 +94,57 @@ export default class TotpModal extends Component<
|
||||||
constructor(props: TotpModalProps, context: any) {
|
constructor(props: TotpModalProps, context: any) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
|
|
||||||
|
this.modalDivRef = createRef();
|
||||||
|
|
||||||
this.clearTotp = this.clearTotp.bind(this);
|
this.clearTotp = this.clearTotp.bind(this);
|
||||||
this.handleShow = this.handleShow.bind(this);
|
this.handleShow = this.handleShow.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
document
|
this.modalDivRef.current?.addEventListener(
|
||||||
.getElementById("totpModal")
|
"shown.bs.modal",
|
||||||
?.addEventListener("shown.bs.modal", this.handleShow);
|
this.handleShow,
|
||||||
|
);
|
||||||
|
|
||||||
document
|
this.modalDivRef.current?.addEventListener(
|
||||||
.getElementById("totpModal")
|
"hidden.bs.modal",
|
||||||
?.addEventListener("hidden.bs.modal", this.clearTotp);
|
this.clearTotp,
|
||||||
|
);
|
||||||
|
|
||||||
|
const Modal = (await import("bootstrap/js/dist/modal")).default;
|
||||||
|
this.modal = new Modal(this.modalDivRef.current!);
|
||||||
|
|
||||||
|
if (this.props.show) {
|
||||||
|
this.modal.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document
|
this.modalDivRef.current?.removeEventListener(
|
||||||
.getElementById("totpModal")
|
"shown.bs.modal",
|
||||||
?.removeEventListener("shown.bs.modal", this.handleShow);
|
this.handleShow,
|
||||||
|
);
|
||||||
|
|
||||||
document
|
this.modalDivRef.current?.removeEventListener(
|
||||||
.getElementById("totpModal")
|
"hidden.bs.modal",
|
||||||
?.removeEventListener("hidden.bs.modal", this.clearTotp);
|
this.clearTotp,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.modal.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate({ show: prevShow }: TotpModalProps) {
|
||||||
|
if (!!prevShow !== !!this.props.show) {
|
||||||
|
if (this.props.show) {
|
||||||
|
this.modal.show();
|
||||||
|
} else {
|
||||||
|
this.modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { type, secretUrl } = this.props;
|
const { type, secretUrl, onClose } = this.props;
|
||||||
const { totp } = this.state;
|
const { totp } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -120,6 +155,7 @@ export default class TotpModal extends Component<
|
||||||
aria-hidden
|
aria-hidden
|
||||||
aria-labelledby="#totpModalTitle"
|
aria-labelledby="#totpModalTitle"
|
||||||
data-bs-backdrop="static"
|
data-bs-backdrop="static"
|
||||||
|
ref={this.modalDivRef}
|
||||||
>
|
>
|
||||||
<div className="modal-dialog modal-fullscreen-sm-down">
|
<div className="modal-dialog modal-fullscreen-sm-down">
|
||||||
<div className="modal-content">
|
<div className="modal-content">
|
||||||
|
@ -134,9 +170,8 @@ export default class TotpModal extends Component<
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-close"
|
className="btn-close"
|
||||||
data-bs-dismiss="modal"
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
id="totp-close-button"
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<div className="modal-body d-flex flex-column align-items-center justify-content-center">
|
<div className="modal-body d-flex flex-column align-items-center justify-content-center">
|
||||||
|
@ -186,6 +221,9 @@ export default class TotpModal extends Component<
|
||||||
onInput={linkEvent({ modal: this, i }, handleInput)}
|
onInput={linkEvent({ modal: this, i }, handleInput)}
|
||||||
onKeyUp={linkEvent({ modal: this, i }, handleKeyUp)}
|
onKeyUp={linkEvent({ modal: this, i }, handleKeyUp)}
|
||||||
onPaste={linkEvent(this, handlePaste)}
|
onPaste={linkEvent(this, handlePaste)}
|
||||||
|
ref={element => {
|
||||||
|
this.inputRefs[i] = element;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -195,7 +233,7 @@ export default class TotpModal extends Component<
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
data-bs-dismiss="modal"
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{I18NextService.i18n.t("cancel")}
|
{I18NextService.i18n.t("cancel")}
|
||||||
</button>
|
</button>
|
||||||
|
@ -211,7 +249,7 @@ export default class TotpModal extends Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleShow() {
|
async handleShow() {
|
||||||
document.getElementById("totp-input-0")?.focus();
|
this.inputRefs[0]?.focus();
|
||||||
|
|
||||||
if (this.props.type === "generate") {
|
if (this.props.type === "generate") {
|
||||||
const { getSVG } = await import("@shortcm/qr-image/lib/svg");
|
const { getSVG } = await import("@shortcm/qr-image/lib/svg");
|
||||||
|
|
|
@ -30,6 +30,7 @@ interface State {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
siteRes: GetSiteResponse;
|
siteRes: GetSiteResponse;
|
||||||
|
show2faModal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLoginSuccess(i: Login, loginRes: LoginResponse) {
|
async function handleLoginSuccess(i: Login, loginRes: LoginResponse) {
|
||||||
|
@ -65,9 +66,7 @@ async function handleLoginSubmit(i: Login, event: any) {
|
||||||
switch (loginRes.state) {
|
switch (loginRes.state) {
|
||||||
case "failed": {
|
case "failed": {
|
||||||
if (loginRes.msg === "missing_totp_token") {
|
if (loginRes.msg === "missing_totp_token") {
|
||||||
const Modal = (await import("bootstrap/js/dist/modal")).default;
|
i.setState({ show2faModal: true });
|
||||||
const modal = new Modal(document.getElementById("totpModal")!);
|
|
||||||
modal.show();
|
|
||||||
} else {
|
} else {
|
||||||
toast(I18NextService.i18n.t(loginRes.msg), "danger");
|
toast(I18NextService.i18n.t(loginRes.msg), "danger");
|
||||||
}
|
}
|
||||||
|
@ -94,6 +93,10 @@ function handleLoginPasswordChange(i: Login, event: any) {
|
||||||
i.setState(prevState => (prevState.form.password = event.target.value));
|
i.setState(prevState => (prevState.form.password = event.target.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClose2faModal(i: Login) {
|
||||||
|
i.setState({ show2faModal: false });
|
||||||
|
}
|
||||||
|
|
||||||
export class Login extends Component<
|
export class Login extends Component<
|
||||||
RouteComponentProps<Record<string, never>>,
|
RouteComponentProps<Record<string, never>>,
|
||||||
State
|
State
|
||||||
|
@ -107,6 +110,7 @@ export class Login extends Component<
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
siteRes: this.isoData.site_res,
|
siteRes: this.isoData.site_res,
|
||||||
|
show2faModal: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props: any, context: any) {
|
constructor(props: any, context: any) {
|
||||||
|
@ -139,7 +143,12 @@ export class Login extends Component<
|
||||||
title={this.documentTitle}
|
title={this.documentTitle}
|
||||||
path={this.context.router.route.match.url}
|
path={this.context.router.route.match.url}
|
||||||
/>
|
/>
|
||||||
<TotpModal type="login" onSubmit={this.handleSubmitTotp} />
|
<TotpModal
|
||||||
|
type="login"
|
||||||
|
onSubmit={this.handleSubmitTotp}
|
||||||
|
show={this.state.show2faModal}
|
||||||
|
onClose={linkEvent(this, handleClose2faModal)}
|
||||||
|
/>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12 col-lg-6 offset-lg-3">{this.loginForm()}</div>
|
<div className="col-12 col-lg-6 offset-lg-3">{this.loginForm()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -154,14 +163,15 @@ export class Login extends Component<
|
||||||
totp_2fa_token: totp,
|
totp_2fa_token: totp,
|
||||||
});
|
});
|
||||||
|
|
||||||
const succeeded = loginRes.state === "success";
|
const successful = loginRes.state === "success";
|
||||||
if (succeeded) {
|
if (successful) {
|
||||||
|
this.setState({ show2faModal: false });
|
||||||
handleLoginSuccess(this, loginRes.data);
|
handleLoginSuccess(this, loginRes.data);
|
||||||
} else {
|
} else {
|
||||||
toast("Invalid 2FA Token", "danger");
|
toast("Invalid 2FA Token", "danger");
|
||||||
}
|
}
|
||||||
|
|
||||||
return succeeded;
|
return successful;
|
||||||
}
|
}
|
||||||
|
|
||||||
loginForm() {
|
loginForm() {
|
||||||
|
|
|
@ -118,6 +118,7 @@ interface SettingsState {
|
||||||
searchPersonOptions: Choice[];
|
searchPersonOptions: Choice[];
|
||||||
searchInstanceOptions: Choice[];
|
searchInstanceOptions: Choice[];
|
||||||
isIsomorphic: boolean;
|
isIsomorphic: boolean;
|
||||||
|
show2faModal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterType = "user" | "community" | "instance";
|
type FilterType = "user" | "community" | "instance";
|
||||||
|
@ -156,14 +157,15 @@ const Filter = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleGenerateTotp(i: Settings, event: any) {
|
async function handleGenerateTotp(i: Settings) {
|
||||||
i.setState({ generateTotpRes: LOADING_REQUEST });
|
i.setState({ generateTotpRes: LOADING_REQUEST });
|
||||||
|
|
||||||
const generateTotpRes = await HttpService.client.generateTotpSecret();
|
const generateTotpRes = await HttpService.client.generateTotpSecret();
|
||||||
|
|
||||||
if (generateTotpRes.state === "failed") {
|
if (generateTotpRes.state === "failed") {
|
||||||
event.stopPropagation();
|
|
||||||
toast(generateTotpRes.msg, "danger");
|
toast(generateTotpRes.msg, "danger");
|
||||||
|
} else {
|
||||||
|
i.setState({ show2faModal: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
i.setState({
|
i.setState({
|
||||||
|
@ -171,6 +173,14 @@ async function handleGenerateTotp(i: Settings, event: any) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleShowTotpModal(i: Settings) {
|
||||||
|
i.setState({ show2faModal: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose2faModal(i: Settings) {
|
||||||
|
i.setState({ show2faModal: false });
|
||||||
|
}
|
||||||
|
|
||||||
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 = {
|
||||||
|
@ -196,6 +206,7 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
isIsomorphic: false,
|
isIsomorphic: false,
|
||||||
generateTotpRes: EMPTY_REQUEST,
|
generateTotpRes: EMPTY_REQUEST,
|
||||||
updateTotpRes: EMPTY_REQUEST,
|
updateTotpRes: EMPTY_REQUEST,
|
||||||
|
show2faModal: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props: any, context: any) {
|
constructor(props: any, context: any) {
|
||||||
|
@ -1041,16 +1052,20 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary my-2"
|
className="btn btn-secondary my-2"
|
||||||
onClick={
|
onClick={linkEvent(
|
||||||
!totpEnabled ? linkEvent(this, handleGenerateTotp) : undefined
|
this,
|
||||||
}
|
totpEnabled ? handleShowTotpModal : handleGenerateTotp,
|
||||||
data-bs-toggle="modal"
|
)}
|
||||||
data-bs-target="#totpModal"
|
|
||||||
>{`${
|
>{`${
|
||||||
totpEnabled ? "Disable" : "Enable"
|
totpEnabled ? "Disable" : "Enable"
|
||||||
} 2 factor authentication`}</button>
|
} 2 factor authentication`}</button>
|
||||||
{totpEnabled ? (
|
{totpEnabled ? (
|
||||||
<TotpModal type="remove" onSubmit={this.handleDisable2fa} />
|
<TotpModal
|
||||||
|
type="remove"
|
||||||
|
onSubmit={this.handleDisable2fa}
|
||||||
|
show={this.state.show2faModal}
|
||||||
|
onClose={linkEvent(this, handleClose2faModal)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TotpModal
|
<TotpModal
|
||||||
type="generate"
|
type="generate"
|
||||||
|
@ -1060,6 +1075,8 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
? generateTotpRes.data.totp_secret_url
|
? generateTotpRes.data.totp_secret_url
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
show={this.state.show2faModal}
|
||||||
|
onClose={linkEvent(this, handleClose2faModal)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1074,14 +1091,12 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
totp_token: totp,
|
totp_token: totp,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updateTotpRes.state === "failed") {
|
|
||||||
toast("Invalid TOTP", "danger");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ updateTotpRes });
|
this.setState({ updateTotpRes });
|
||||||
|
|
||||||
const succeeded = updateTotpRes.state === "success";
|
const successful = updateTotpRes.state === "success";
|
||||||
if (succeeded) {
|
if (successful) {
|
||||||
|
this.setState({ show2faModal: false });
|
||||||
|
|
||||||
const siteRes = await HttpService.client.getSite();
|
const siteRes = await HttpService.client.getSite();
|
||||||
UserService.Instance.myUserInfo!.local_user_view.local_user.totp_2fa_enabled =
|
UserService.Instance.myUserInfo!.local_user_view.local_user.totp_2fa_enabled =
|
||||||
enabled;
|
enabled;
|
||||||
|
@ -1096,9 +1111,11 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
} 2 factor authentication`,
|
} 2 factor authentication`,
|
||||||
"success",
|
"success",
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
toast("Invalid TOTP", "danger");
|
||||||
}
|
}
|
||||||
|
|
||||||
return succeeded;
|
return successful;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEnable2fa(totp: string) {
|
handleEnable2fa(totp: string) {
|
||||||
|
|
Loading…
Reference in a new issue