mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-11-25 13:51:13 +00:00
feat: Add modal for totp settings
This commit is contained in:
parent
8a2cd127ee
commit
b5fee58892
5 changed files with 239 additions and 66 deletions
|
@ -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",
|
||||
|
|
133
src/shared/components/common/totp-modal.tsx
Normal file
133
src/shared/components/common/totp-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<LoginResponse>;
|
||||
deleteAccountRes: RequestState<DeleteAccountResponse>;
|
||||
instancesRes: RequestState<GetFederatedInstancesResponse>;
|
||||
generateTotpRes: RequestState<GenerateTotpSecretResponse>;
|
||||
updateTotpRes: RequestState<UpdateTotpResponse>;
|
||||
// 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 = ({
|
|||
</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> {
|
||||
private isoData = setIsoData<SettingsData>(this.context);
|
||||
state: SettingsState = {
|
||||
|
@ -170,6 +194,8 @@ export class Settings extends Component<any, SettingsState> {
|
|||
searchPersonOptions: [],
|
||||
searchInstanceOptions: [],
|
||||
isIsomorphic: false,
|
||||
generateTotpRes: EMPTY_REQUEST,
|
||||
updateTotpRes: EMPTY_REQUEST,
|
||||
};
|
||||
|
||||
constructor(props: any, context: any) {
|
||||
|
@ -193,6 +219,10 @@ export class Settings extends Component<any, SettingsState> {
|
|||
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<any, SettingsState> {
|
|||
}
|
||||
|
||||
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 && (
|
||||
<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>
|
||||
{totpEnabled ? (
|
||||
<div>
|
||||
<TotpModal type="remove" onSubmit={this.handleDisable2fa} />
|
||||
<button type="button" className="btn btn-secondary">
|
||||
Disable 2 factor authentication
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div>
|
||||
<TotpModal
|
||||
type="generate"
|
||||
secretUrl={
|
||||
generateTotpRes.state === "success"
|
||||
? generateTotpRes.data.totp_secret_url
|
||||
: undefined
|
||||
}
|
||||
onSubmit={this.handleEnable2fa}
|
||||
/>
|
||||
|
||||
{totpUrl && (
|
||||
<>
|
||||
<div>
|
||||
<a className="btn btn-secondary mb-2" href={totpUrl}>
|
||||
{I18NextService.i18n.t("two_factor_link")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="input-group mb-3">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
id="user-remove-totp"
|
||||
type="checkbox"
|
||||
checked={
|
||||
this.state.saveUserSettingsForm.generate_totp_2fa === false
|
||||
}
|
||||
onChange={linkEvent(this, this.handleRemoveTotp)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="user-remove-totp">
|
||||
{I18NextService.i18n.t("remove_two_factor")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={linkEvent(this, handleGenerateTotp)}
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#totpModal"
|
||||
>
|
||||
Enable 2 factor authentication
|
||||
</button>
|
||||
</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) => {
|
||||
this.setState({ searchPersonLoading: true });
|
||||
|
||||
|
@ -1248,19 +1291,12 @@ export class Settings extends Component<any, SettingsState> {
|
|||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue