Skip to content

Commit

Permalink
feat: Accomodate 2FA changes in UI
Browse files Browse the repository at this point in the history
* feat: Add modal for totp settings

* Make inputs show up on totp modal

* Make modal work when enabling and disabling 2FA

* Give user better feedback when en/disabling totp

* Use new 2FA flow for login

* Refactor 2fa modal to prevent implementation details from leaking

* chore: Use constant objects where appropriate

* Incorporate translations
  • Loading branch information
SleeplessOne1917 authored Oct 5, 2023
1 parent 0c18224 commit c6d6107
Show file tree
Hide file tree
Showing 29 changed files with 773 additions and 278 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -69,7 +70,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",
Expand Down
4 changes: 4 additions & 0 deletions src/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,7 @@ br.big {
.skip-link:focus {
top: 0;
}

.totp-link {
width: fit-content;
}
5 changes: 3 additions & 2 deletions src/shared/components/comment/comment-report.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { I18NextService } from "../../services";
import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { CommentNode } from "./comment-node";
import { EMPTY_REQUEST } from "../../services/HttpService";

interface CommentReportProps {
report: CommentReportView;
Expand Down Expand Up @@ -97,8 +98,8 @@ export class CommentReport extends Component<
onPersonMentionRead={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onCreateComment={() => Promise.resolve({ state: "empty" })}
onEditComment={() => Promise.resolve({ state: "empty" })}
onCreateComment={() => Promise.resolve(EMPTY_REQUEST)}
onEditComment={() => Promise.resolve(EMPTY_REQUEST)}
/>
<div>
{I18NextService.i18n.t("reporter")}:{" "}
Expand Down
268 changes: 268 additions & 0 deletions src/shared/components/common/totp-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import {
Component,
MouseEventHandler,
RefObject,
createRef,
linkEvent,
} from "inferno";
import { I18NextService } from "../../services";
import { toast } from "../../toast";
import type { Modal } from "bootstrap";

interface TotpModalProps {
/**Takes totp as param, returns whether submit was successful*/
onSubmit: (totp: string) => Promise<boolean>;
onClose: MouseEventHandler;
type: "login" | "remove" | "generate";
secretUrl?: string;
show?: boolean;
}

interface TotpModalState {
totp: string;
qrCode?: string;
}

const TOTP_LENGTH = 6;

async function handleSubmit(modal: TotpModal, totp: string) {
const successful = await modal.props.onSubmit(totp);

if (!successful) {
modal.setState({ totp: "" });
modal.inputRefs[0]?.focus();
}
}

function handleInput(
{ modal, i }: { modal: TotpModal; i: number },
event: any,
) {
if (isNaN(event.target.value)) {
event.preventDefault();
return;
}

modal.setState(prev => ({ ...prev, totp: prev.totp + event.target.value }));
modal.inputRefs[i + 1]?.focus();

const { totp } = modal.state;
if (totp.length >= TOTP_LENGTH) {
handleSubmit(modal, totp);
}
}

function handleKeyUp(
{ modal, i }: { modal: TotpModal; i: number },
event: any,
) {
if (event.key === "Backspace" && i > 0) {
event.preventDefault();

modal.setState(prev => ({
...prev,
totp: prev.totp.slice(0, prev.totp.length - 1),
}));
modal.inputRefs[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(I18NextService.i18n.t("invalid_totp_code"), "danger");
modal.setState({ totp: "" });
} else {
modal.setState({ totp: text });
handleSubmit(modal, text);
}
}

export default class TotpModal extends Component<
TotpModalProps,
TotpModalState
> {
private readonly modalDivRef: RefObject<HTMLDivElement>;
inputRefs: (HTMLInputElement | null)[] = [];
modal: Modal;
state: TotpModalState = {
totp: "",
};

constructor(props: TotpModalProps, context: any) {
super(props, context);

this.modalDivRef = createRef();

this.clearTotp = this.clearTotp.bind(this);
this.handleShow = this.handleShow.bind(this);
}

async componentDidMount() {
this.modalDivRef.current?.addEventListener(
"shown.bs.modal",
this.handleShow,
);

this.modalDivRef.current?.addEventListener(
"hidden.bs.modal",
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() {
this.modalDivRef.current?.removeEventListener(
"shown.bs.modal",
this.handleShow,
);

this.modalDivRef.current?.removeEventListener(
"hidden.bs.modal",
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() {
const { type, secretUrl, onClose } = this.props;
const { totp } = this.state;

return (
<div
className="modal fade"
id="totpModal"
tabIndex={-1}
aria-hidden
aria-labelledby="#totpModalTitle"
data-bs-backdrop="static"
ref={this.modalDivRef}
>
<div className="modal-dialog modal-fullscreen-sm-down">
<div className="modal-content">
<header className="modal-header">
<h3 className="modal-title" id="totpModalTitle">
{I18NextService.i18n.t(
type === "generate"
? "enable_totp"
: type === "remove"
? "disable_totp"
: "enter_totp_code",
)}
</h3>
<button
type="button"
className="btn-close"
aria-label="Close"
onClick={onClose}
/>
</header>
<div className="modal-body d-flex flex-column align-items-center justify-content-center">
{type === "generate" && (
<div>
<a
className="btn btn-secondary mx-auto d-block totp-link"
href={secretUrl}
>
{I18NextService.i18n.t("totp_link")}
</a>
<div className="mx-auto mt-3 w-50 h-50 text-center">
<strong className="fw-semibold">
{I18NextService.i18n.t("totp_qr_segue")}
</strong>
<img
src={this.state.qrCode}
className="d-block mt-1 mx-auto"
alt={I18NextService.i18n.t("totp_qr")}
/>
</div>
</div>
)}
<form id="totp-form">
<label
className="form-label ms-2 mt-4 fw-bold"
id="totp-input-label"
htmlFor="totp-input-0"
>
{I18NextService.i18n.t("enter_totp_code")}
</label>
<div className="d-flex justify-content-between align-items-center p-2">
{Array.from(Array(TOTP_LENGTH).keys()).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 mx-2 p-1 p-md-2 text-center"
onInput={linkEvent({ modal: this, i }, handleInput)}
onKeyUp={linkEvent({ modal: this, i }, handleKeyUp)}
onPaste={linkEvent(this, handlePaste)}
ref={element => {
this.inputRefs[i] = element;
}}
/>
))}
</div>
</form>
</div>
<footer className="modal-footer">
<button
type="button"
className="btn btn-danger"
onClick={onClose}
>
{I18NextService.i18n.t("cancel")}
</button>
</footer>
</div>
</div>
</div>
);
}

clearTotp() {
this.setState({ totp: "" });
}

async handleShow() {
this.inputRefs[0]?.focus();

if (this.props.type === "generate") {
const { getSVG } = await import("@shortcm/qr-image/lib/svg");

this.setState({
qrCode: URL.createObjectURL(
new Blob([(await getSVG(this.props.secretUrl!)).buffer], {
type: "image/svg+xml",
}),
),
});
}
}
}
11 changes: 8 additions & 3 deletions src/shared/components/community/communities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
} from "lemmy-js-client";
import { InitialFetchRequest } from "../../interfaces";
import { FirstLoadService, I18NextService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import {
EMPTY_REQUEST,
HttpService,
LOADING_REQUEST,
RequestState,
} from "../../services/HttpService";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { ListingTypeSelect } from "../common/listing-type-select";
Expand Down Expand Up @@ -64,7 +69,7 @@ function getCommunitiesQueryParams() {
export class Communities extends Component<any, CommunitiesState> {
private isoData = setIsoData<CommunitiesData>(this.context);
state: CommunitiesState = {
listCommunitiesResponse: { state: "empty" },
listCommunitiesResponse: EMPTY_REQUEST,
siteRes: this.isoData.site_res,
searchText: "",
isIsomorphic: false,
Expand Down Expand Up @@ -333,7 +338,7 @@ export class Communities extends Component<any, CommunitiesState> {
}

async refetch() {
this.setState({ listCommunitiesResponse: { state: "loading" } });
this.setState({ listCommunitiesResponse: LOADING_REQUEST });

const { listingType, sort, page } = getCommunitiesQueryParams();

Expand Down
Loading

0 comments on commit c6d6107

Please sign in to comment.