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-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",
|
||||||
|
|
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,
|
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) {
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue