diff --git a/package.json b/package.json index b43285ab..573c338c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@babel/preset-typescript": "^7.21.5", "@babel/runtime": "^7.21.5", "@emoji-mart/data": "^1.1.0", + "@shortcm/qr-image": "^9.0.2", "autosize": "^6.0.1", "babel-loader": "^9.1.2", "babel-plugin-inferno": "^6.6.0", diff --git a/src/assets/css/main.css b/src/assets/css/main.css index f1e0c4d6..bad10ec5 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -448,3 +448,7 @@ br.big { .skip-link:focus { top: 0; } + +.totp-link { + width: fit-content; +} diff --git a/src/shared/components/common/totp-modal.tsx b/src/shared/components/common/totp-modal.tsx index 642491c4..72ebbb64 100644 --- a/src/shared/components/common/totp-modal.tsx +++ b/src/shared/components/common/totp-modal.tsx @@ -1,34 +1,70 @@ import { Component, linkEvent } from "inferno"; import { I18NextService } from "../../services"; +import { toast } from "../../toast"; interface TotpModalProps { - onSubmit: (totp: string) => void; + /**Takes totp as param, returns whether submit was successful*/ + onSubmit: (totp: string) => Promise; type: "login" | "remove" | "generate"; secretUrl?: string; } + interface TotpModalState { totp: string; + qrCode?: string; } const TOTP_LENGTH = 6; -function focusInput() { - document.getElementById("totp-input-0")?.focus(); +async function handleSubmit(modal: TotpModal, totp: string) { + const succeeded = await modal.props.onSubmit(totp); + + modal.setState({ totp: "" }); + if (succeeded) { + document.getElementById("totp-close-button")?.click(); + } else { + document.getElementById(`totp-input-0`)?.focus(); + } } function handleInput( { modal, i }: { modal: TotpModal; i: number }, event: any, ) { - const { totp } = modal.state; + modal.setState(prev => ({ ...prev, totp: prev.totp + event.target.value })); + document.getElementById(`totp-input-${i + 1}`)?.focus(); + const { totp } = modal.state; if (totp.length >= TOTP_LENGTH) { - modal.props.onSubmit(totp); - } else { + handleSubmit(modal, totp); + } +} + +function handleKeyUp( + { modal, i }: { modal: TotpModal; i: number }, + event: any, +) { + if (event.key === "Backspace" && i > 0) { + event.preventDefault(); + modal.setState(prev => ({ - totp: prev.totp + event.target.value, + ...prev, + totp: prev.totp.slice(0, prev.totp.length - 1), })); - document.getElementById(`totp-input-${i + 1}`)?.focus(); + document.getElementById(`totp-input-${i - 1}`)?.focus(); + } +} + +function handlePaste(modal: TotpModal, event: any) { + event.preventDefault(); + const text: string = event.clipboardData.getData("text"); + + if (text.length > TOTP_LENGTH || isNaN(Number(text))) { + toast("Invalid TOTP: Must be string of six digits", "danger"); + modal.setState({ totp: "" }); + } else { + modal.setState({ totp: text }); + handleSubmit(modal, text); } } @@ -44,12 +80,13 @@ export default class TotpModal extends Component< super(props, context); this.clearTotp = this.clearTotp.bind(this); + this.handleShow = this.handleShow.bind(this); } - componentDidMount() { + async componentDidMount() { document .getElementById("totpModal") - ?.addEventListener("shown.bs.modal", focusInput); + ?.addEventListener("shown.bs.modal", this.handleShow); document .getElementById("totpModal") @@ -59,7 +96,7 @@ export default class TotpModal extends Component< componentWillUnmount() { document .getElementById("totpModal") - ?.removeEventListener("shown.bs.modal", focusInput); + ?.removeEventListener("shown.bs.modal", this.handleShow); document .getElementById("totpModal") @@ -67,7 +104,7 @@ export default class TotpModal extends Component< } render() { - const { type } = this.props; + const { type, secretUrl } = this.props; const { totp } = this.state; return ( @@ -93,39 +130,61 @@ export default class TotpModal extends Component< className="btn-close" data-bs-dismiss="modal" aria-label="Close" + id="totp-close-button" /> -
- -
- {Array.from(Array(TOTP_LENGTH).keys()).map(i => ( - - ))} -
-
+
+ {type === "generate" && ( +
+ + Click here for your TOTP link + +
+ + or scan this QR code in your authenticator app + + TOTP QR code +
+
+ )} +
+ +
+ {Array.from(Array(TOTP_LENGTH).keys()).map(i => ( + + ))} +
+
+