mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-23 19:49:56 +01:00
feat: add 2fa support (#221)
* feat: add mfa configuration ui * feat: add 2fa login step * feat: improve 2fa enabling ux * feat: finalize 2fa setup * refactor: move useTimer to its own hook * feat: finalize 2fa settings * feat: add 2fa on auth * chore: update packages * chore: replace Google Auth with Aegis & Raivo
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"@rebass/forms": "^4.0.6",
|
||||
"@streetwriters/tinymce-plugins": "^1.5.17",
|
||||
"@tinymce/tinymce-react": "^3.13.0",
|
||||
"@types/rebass": "^4.0.10",
|
||||
"async-mutex": "^0.3.2",
|
||||
"axios": "^0.21.4",
|
||||
"clipboard-polyfill": "^3.0.3",
|
||||
|
||||
7
apps/web/src/assets/fallback2fa.svg
Normal file
7
apps/web/src/assets/fallback2fa.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 717.67 430.74">
|
||||
<path fill="#f2f2f2" d="M120.43 410.02a2.8 2.8 0 0 1-2.04-4.87l.2-.76-.08-.18a7.54 7.54 0 0 0-13.9.05c-2.28 5.48-5.18 10.96-5.89 16.75a22.3 22.3 0 0 0 .4 7.68 89.42 89.42 0 0 1-8.14-37.14 86.3 86.3 0 0 1 .53-9.63q.45-3.93 1.23-7.8a90.46 90.46 0 0 1 17.94-38.35 24.07 24.07 0 0 0 10.01-10.38 18.36 18.36 0 0 0 1.67-5.02c-.48.06-1.83-7.36-1.47-7.82-.68-1.03-1.9-1.54-2.63-2.54-3.7-5-8.78-4.13-11.43 2.66-5.67 2.87-5.72 7.61-2.24 12.17 2.2 2.9 2.51 6.84 4.45 9.94l-.6.76a91.04 91.04 0 0 0-9.5 15.06 37.85 37.85 0 0 0-2.26-17.58c-2.17-5.22-6.22-9.61-9.8-14.12-4.28-5.42-13.07-3.06-13.83 3.81l-.02.2q.8.45 1.56.95a3.8 3.8 0 0 1-1.54 6.93l-.07.01a37.89 37.89 0 0 0 1 5.67c-4.58 17.71 5.3 24.16 19.42 24.45.31.16.61.32.93.47a92.92 92.92 0 0 0-5 23.54 88.14 88.14 0 0 0 .06 14.23l-.03-.17a23.29 23.29 0 0 0-7.95-13.44c-6.11-5.03-14.76-6.88-21.36-10.92a4.37 4.37 0 0 0-6.7 4.25l.03.18a25.58 25.58 0 0 1 2.87 1.38q.8.45 1.56.95a3.8 3.8 0 0 1-1.54 6.93l-.07.01-.16.03A37.92 37.92 0 0 0 63 399.28c2.87 15.46 15.16 16.93 28.32 12.43a92.9 92.9 0 0 0 6.25 18.21h22.3l.21-.75a25.33 25.33 0 0 1-6.16-.36c1.65-2.03 3.3-4.08 4.96-6.1a1.39 1.39 0 0 0 .1-.13l2.53-3.1a37.1 37.1 0 0 0-1.09-9.46Zm465.62 0a2.8 2.8 0 0 0 2.04-4.87l-.2-.76.08-.18a7.54 7.54 0 0 1 13.9.05c2.28 5.48 5.18 10.96 5.89 16.75a22.3 22.3 0 0 1-.4 7.68 89.42 89.42 0 0 0 8.14-37.14 86.3 86.3 0 0 0-.53-9.63q-.45-3.93-1.23-7.8a90.46 90.46 0 0 0-17.94-38.35 24.07 24.07 0 0 1-10.01-10.38 18.36 18.36 0 0 1-1.67-5.02c.48.06 1.84-7.36 1.47-7.82.68-1.03 1.9-1.54 2.63-2.54 3.7-5 8.78-4.13 11.43 2.66 5.67 2.87 5.72 7.61 2.25 12.17-2.22 2.9-2.52 6.84-4.46 9.94l.6.76a91.04 91.04 0 0 1 9.5 15.06 37.85 37.85 0 0 1 2.27-17.58c2.16-5.22 6.21-9.61 9.78-14.12 4.29-5.42 13.08-3.06 13.84 3.81l.02.2q-.8.45-1.56.95a3.8 3.8 0 0 0 1.54 6.93l.08.01a37.89 37.89 0 0 1-1 5.67c4.58 17.71-5.31 24.16-19.43 24.45l-.92.47a92.93 92.93 0 0 1 5 23.54 88.14 88.14 0 0 1-.07 14.23l.03-.17a23.29 23.29 0 0 1 7.95-13.44c6.12-5.03 14.76-6.88 21.36-10.92a4.37 4.37 0 0 1 6.7 4.25l-.03.18a25.58 25.58 0 0 0-2.87 1.38q-.8.45-1.56.95a3.8 3.8 0 0 0 1.54 6.93l.07.01.16.03a37.92 37.92 0 0 1-6.97 10.92c-2.86 15.46-15.16 16.93-28.32 12.43a92.9 92.9 0 0 1-6.25 18.21h-22.29l-.22-.75a25.33 25.33 0 0 0 6.16-.36c-1.65-2.03-3.3-4.08-4.96-6.1a1.39 1.39 0 0 1-.1-.13l-2.53-3.1a37.1 37.1 0 0 1 1.1-9.46Zm-439.98 19.4h35.97c6.16-20.71.04-69.67 8.15-89.68 15.56-38.37 35.43-78.85 72.53-97.59 15.84-8 33.11-11.12 50.75-10.95 24.37.23 49.42 6.77 72.35 15.44a672.45 672.45 0 0 1 27.94 11.4c34.88 14.99 69.39 31.32 106.9 35.73 47.48 5.58 103.29-15.66 116.25-61.4 9.9-35.03-7.92-71.4-26.61-102.68-18.7-31.28-39.58-64.75-36.17-100.96.01-.12.02-.26.05-.38 1.28-13 9.05-22.36 19.9-28.35h-47.24c-.62 33.79 18.92 63.6 36.51 93.05 18.7 31.27 36.51 67.65 26.62 102.67-12.97 45.74-120.33-7.87-167.8-13.47-13.4-1.57 7.89 4.01-4.9 0-20.04-6.28-22.26 51.37-41.77 42.9-12.15-5.27-24.3-10.51-36.63-15.17a305.14 305.14 0 0 0-15.58-5.42l-.02-.01c-35.4-11.21-74.12-15.58-106.82.63l-.68.33c-37.1 18.72-56.97 59.2-72.53 97.57-12.86 31.72-10.83 92.89-17.17 126.34Z"/>
|
||||
<circle cx="346.21" cy="263.14" r="165.22" fill="#fff"/>
|
||||
<path fill="#3f3d56" d="M345.74 429.42c-163.47 3.76-167.06-73.9-167.06-166.28 0-92.37 75.16-167.53 167.53-167.53s167.53 75.16 167.53 167.53c0 92.38-75.62 166.28-168 166.28Zm.47-329.2c-89.83 0-161.4 73.1-162.92 162.92-1.4 83.54 87.35 187.8 162.92 162.92 40.53-44.64 155.11-92.71 162.92-162.92 9.93-89.28-73.08-162.92-162.92-162.92Z"/>
|
||||
<path fill="#f2f2f2" d="M323.67 349.13a18.37 18.37 0 0 1-14.7-7.35l-45.07-60.1a18.38 18.38 0 1 1 29.4-22.06l29.5 39.32 75.73-113.6a18.38 18.38 0 0 1 30.59 20.38l-90.15 135.23a18.39 18.39 0 0 1-14.79 8.18h-.5Z"/>
|
||||
<path fill="var(--primary)" d="M317.67 347.13a18.37 18.37 0 0 1-14.7-7.35l-45.07-60.1a18.38 18.38 0 1 1 29.4-22.06l29.5 39.32 75.73-113.6a18.38 18.38 0 1 1 30.59 20.38l-90.15 135.23a18.39 18.39 0 0 1-14.79 8.18h-.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
6
apps/web/src/assets/mfa.svg
Normal file
6
apps/web/src/assets/mfa.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 382.94 405.93">
|
||||
<path fill="var(--text)" d="M192.58 405.92a75.19 75.19 0 0 1-18.64-2.41l-1.2-.33-1.12-.56c-40.24-20.18-74.19-46.83-100.9-79.21a299.86 299.86 0 0 1-50.95-90.47A348.21 348.21 0 0 1 .07 110.27l.04-2.02c0-20.29 11.26-38.09 28.7-45.35C42.13 57.34 163.24 7.6 172 4c16.48-8.26 34.06-1.36 36.87-.16 6.31 2.58 118.28 48.38 142.47 59.9 24.94 11.87 31.6 33.2 31.6 43.93 0 48.6-8.43 94-25.02 134.97a312.52 312.52 0 0 1-56.16 90.51c-45.85 51.6-91.7 69.89-92.15 70.05a50.11 50.11 0 0 1-17.04 2.72zm-10.79-26.71c3.98.89 13.13 2.22 19.1.05 7.58-2.77 45.96-22.67 81.83-63.03 49.55-55.77 74.7-125.88 74.74-208.38-.1-1.67-1.28-13.59-17.07-21.1-23.72-11.3-140.1-58.89-141.27-59.37l-.32-.14c-2.44-1.02-10.2-3.17-15.55-.37l-1.08.5c-1.3.54-129.86 53.34-143.57 59.05-9.6 4-13 13.9-13 21.83 0 .58-.02 1.43-.05 2.52-1.1 56.44 11.97 195.34 156.24 268.44z"/>
|
||||
<path fill="var(--bgSecondary)" d="M177.33 15.59S47.61 68.87 33.71 74.66c-13.9 5.79-20.85 19.7-20.85 33.6 0 13.9-10.45 195.26 164.47 282.96 0 0 15.88 4.39 27.92 0 12.04-4.39 164.96-78.52 164.96-283.55 0 0 0-20.85-24.33-32.43C321.55 63.66 203.94 15.6 203.94 15.6s-14.44-6.37-26.6 0z"/>
|
||||
<path d="M191.23 57.29v284.25S60.34 278.53 61.51 112.89z" opacity=".2"/>
|
||||
<path fill="var(--icon)" d="m192.94 261.58-41.69-53.61 24.24-18.86 19.75 25.38 66.7-70.4 22.3 21.13z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -607,6 +607,24 @@ export function showImportDialog() {
|
||||
));
|
||||
}
|
||||
|
||||
export function showMultifactorDialog(primaryMethod = undefined) {
|
||||
return showDialog((Dialogs, perform) => (
|
||||
<Dialogs.MultifactorDialog
|
||||
onClose={(res) => perform(res)}
|
||||
primaryMethod={primaryMethod}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function show2FARecoveryCodesDialog(primaryMethod) {
|
||||
return showDialog((Dialogs, perform) => (
|
||||
<Dialogs.RecoveryCodesDialog
|
||||
onClose={(res) => perform(res)}
|
||||
primaryMethod={primaryMethod}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function showAttachmentsDialog() {
|
||||
return showDialog((Dialogs, perform) => (
|
||||
<Dialogs.AttachmentsDialog onClose={(res) => perform(res)} />
|
||||
|
||||
@@ -3,13 +3,18 @@ import { TaskManager } from "./task-manager";
|
||||
import { zip } from "../utils/zip";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
async function exportToPDF(content: string): Promise<boolean> {
|
||||
export async function exportToPDF(
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<boolean> {
|
||||
if (!content) return false;
|
||||
const { default: printjs } = await import("print-js");
|
||||
return new Promise(async (resolve) => {
|
||||
printjs({
|
||||
printable: content,
|
||||
type: "raw-html",
|
||||
documentTitle: title,
|
||||
header: '<h3 class="custom-h3">My custom header</h3>',
|
||||
onPrintDialogClose: () => {
|
||||
resolve(false);
|
||||
},
|
||||
@@ -30,7 +35,7 @@ export async function exportNotes(
|
||||
action: async (report) => {
|
||||
if (format === "pdf") {
|
||||
const note = db.notes!.note(noteIds[0]);
|
||||
return await exportToPDF(await note.export("html", null));
|
||||
return await exportToPDF(note.title, await note.export("html", null));
|
||||
}
|
||||
|
||||
var files = [];
|
||||
|
||||
@@ -14,6 +14,7 @@ import TrackingDetailsDialog from "./trackingdetailsdialog";
|
||||
import ReminderDialog from "./reminderdialog";
|
||||
import AnnouncementDialog from "./announcementdialog";
|
||||
import IssueDialog from "./issuedialog";
|
||||
import { MultifactorDialog, RecoveryCodesDialog } from "./multi-factor-dialog";
|
||||
import OnboardingDialog from "./onboarding-dialog";
|
||||
import AttachmentsDialog from "./attachmentsdialog";
|
||||
import BackupDialog from "./backupdialog";
|
||||
@@ -35,6 +36,8 @@ const Dialogs = {
|
||||
AnnouncementDialog,
|
||||
IssueDialog,
|
||||
ImportDialog,
|
||||
MultifactorDialog,
|
||||
RecoveryCodesDialog,
|
||||
OnboardingDialog,
|
||||
AttachmentsDialog,
|
||||
BackupDialog,
|
||||
|
||||
806
apps/web/src/components/dialogs/multifactordialog.tsx
Normal file
806
apps/web/src/components/dialogs/multifactordialog.tsx
Normal file
@@ -0,0 +1,806 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Text, Flex, Button, Box, BoxProps } from "rebass";
|
||||
import Dialog from "./dialog";
|
||||
import { db } from "../../common/db";
|
||||
import { ReactComponent as MFA } from "../../assets/mfa.svg";
|
||||
import { ReactComponent as Fallback2FA } from "../../assets/fallback2fa.svg";
|
||||
import * as clipboard from "clipboard-polyfill/text";
|
||||
import { Suspense } from "react";
|
||||
import FileSaver from "file-saver";
|
||||
import {
|
||||
Loading,
|
||||
MFAAuthenticator,
|
||||
MFAEmail,
|
||||
MFASMS,
|
||||
Download,
|
||||
Print,
|
||||
Copy,
|
||||
Refresh,
|
||||
} from "../icons";
|
||||
import Field from "../field";
|
||||
import { useSessionState } from "../../utils/hooks";
|
||||
import { exportToPDF } from "../../common/export";
|
||||
import { useTimer } from "../../hooks/use-timer";
|
||||
const QRCode = React.lazy(() => import("../../re-exports/react-qrcode-logo"));
|
||||
|
||||
export type AuthenticatorType = "app" | "sms" | "email";
|
||||
type StepKeys = keyof Steps; // "choose" | "setup" | "recoveryCodes" | "finish";
|
||||
type FallbackStepKeys = keyof FallbackSteps;
|
||||
type Steps = typeof steps;
|
||||
type FallbackSteps = typeof fallbackSteps;
|
||||
|
||||
type Authenticator = {
|
||||
type: AuthenticatorType;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.FunctionComponent<BoxProps>;
|
||||
recommended?: boolean;
|
||||
};
|
||||
|
||||
type StepComponentProps = {
|
||||
onNext: (...args: any[]) => void;
|
||||
onClose?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
};
|
||||
|
||||
type StepComponent = React.FunctionComponent<StepComponentProps>;
|
||||
|
||||
type Step = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
component?: StepComponent;
|
||||
next?: StepKeys;
|
||||
cancellable?: boolean;
|
||||
};
|
||||
type FallbackStep = Step & {
|
||||
next?: FallbackStepKeys;
|
||||
};
|
||||
|
||||
type SubmitCodeFunction = (code: string) => void;
|
||||
|
||||
type AuthenticatorSelectorProps = StepComponentProps & {
|
||||
authenticator: AuthenticatorType;
|
||||
isFallback?: boolean;
|
||||
};
|
||||
|
||||
type VerifyAuthenticatorFormProps = PropsWithChildren<{
|
||||
codeHelpText: string;
|
||||
onSubmitCode: SubmitCodeFunction;
|
||||
}>;
|
||||
|
||||
type SetupAuthenticatorProps = { onSubmitCode: SubmitCodeFunction };
|
||||
|
||||
type MultifactorDialogProps = {
|
||||
onClose: () => void;
|
||||
primaryMethod?: AuthenticatorType;
|
||||
};
|
||||
|
||||
type RecoveryCodesDialogProps = {
|
||||
onClose: () => void;
|
||||
primaryMethod: AuthenticatorType;
|
||||
};
|
||||
|
||||
const defaultAuthenticators: AuthenticatorType[] = ["app", "sms", "email"];
|
||||
const Authenticators: Authenticator[] = [
|
||||
{
|
||||
type: "app",
|
||||
title: "Set up using an Authenticator app",
|
||||
subtitle:
|
||||
"Use an authenticator app like Aegis or Raivo Authenticator to get the authentication codes.",
|
||||
icon: MFAAuthenticator,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
type: "sms",
|
||||
title: "Set up using SMS",
|
||||
subtitle: "Notesnook will send you an SMS text with the 2FA code at login.",
|
||||
icon: MFASMS,
|
||||
},
|
||||
{
|
||||
type: "email",
|
||||
title: "Set up using Email",
|
||||
subtitle: "Notesnook will send you the 2FA code on your email at login.",
|
||||
icon: MFAEmail,
|
||||
},
|
||||
];
|
||||
|
||||
const steps = {
|
||||
choose: (): Step => ({
|
||||
title: "Protect your notes by enabling 2FA",
|
||||
description: "Choose how you want to receive your authentication codes.",
|
||||
component: ({ onNext }) => (
|
||||
<ChooseAuthenticator
|
||||
onNext={onNext}
|
||||
authenticators={defaultAuthenticators}
|
||||
/>
|
||||
),
|
||||
next: "setup",
|
||||
cancellable: true,
|
||||
}),
|
||||
setup: (authenticator: Authenticator): Step => ({
|
||||
title: authenticator.title,
|
||||
description: authenticator.subtitle,
|
||||
next: "recoveryCodes",
|
||||
component: ({ onNext }) => (
|
||||
<AuthenticatorSelector
|
||||
onNext={onNext}
|
||||
authenticator={authenticator.type}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
recoveryCodes: (authenticatorType: AuthenticatorType): Step => ({
|
||||
title: "Save your recovery codes",
|
||||
description: `If you lose access to your ${
|
||||
authenticatorType === "email"
|
||||
? "email"
|
||||
: authenticatorType === "sms"
|
||||
? "phone"
|
||||
: "auth app"
|
||||
}, you can login to Notesnook using your recovery codes. Each code can only be used once!`,
|
||||
component: BackupRecoveryCodes,
|
||||
next: "finish",
|
||||
}),
|
||||
finish: (): Step => ({
|
||||
component: TwoFactorEnabled,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
const fallbackSteps = {
|
||||
choose: (primaryMethod: AuthenticatorType): FallbackStep => ({
|
||||
title: "Add a fallback 2FA method",
|
||||
description:
|
||||
"A fallback method helps you get your 2FA codes on an alternative device in case you lose your primary device.",
|
||||
component: ({ onNext }) => (
|
||||
<ChooseAuthenticator
|
||||
onNext={onNext}
|
||||
authenticators={defaultAuthenticators.filter(
|
||||
(i) => i !== primaryMethod
|
||||
)}
|
||||
/>
|
||||
),
|
||||
next: "setup",
|
||||
cancellable: true,
|
||||
}),
|
||||
setup: (authenticator: Authenticator): FallbackStep => ({
|
||||
title: authenticator.title,
|
||||
description: authenticator.subtitle,
|
||||
next: "finish",
|
||||
cancellable: true,
|
||||
component: ({ onNext }) => (
|
||||
<AuthenticatorSelector
|
||||
onNext={onNext}
|
||||
authenticator={authenticator.type}
|
||||
isFallback
|
||||
/>
|
||||
),
|
||||
}),
|
||||
finish: (
|
||||
fallbackMethod: AuthenticatorType,
|
||||
primaryMethod: AuthenticatorType
|
||||
): FallbackStep => ({
|
||||
component: ({ onNext, onClose }) => (
|
||||
<Fallback2FAEnabled
|
||||
onNext={onNext}
|
||||
onClose={onClose}
|
||||
primaryMethod={primaryMethod}
|
||||
fallbackMethod={fallbackMethod}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export function MultifactorDialog(props: MultifactorDialogProps) {
|
||||
const { onClose, primaryMethod } = props;
|
||||
const [step, setStep] = useState<FallbackStep | Step>(
|
||||
primaryMethod ? fallbackSteps.choose(primaryMethod) : steps.choose()
|
||||
);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
width={500}
|
||||
positiveButton={
|
||||
step.next
|
||||
? {
|
||||
text: "Continue",
|
||||
props: { form: "2faForm" },
|
||||
}
|
||||
: null
|
||||
}
|
||||
negativeButton={
|
||||
step.cancellable
|
||||
? {
|
||||
text: "Cancel",
|
||||
onClick: onClose,
|
||||
}
|
||||
: null
|
||||
}
|
||||
>
|
||||
{step.component && (
|
||||
<step.component
|
||||
onNext={(...args) => {
|
||||
if (!step.next) return onClose();
|
||||
|
||||
const nextStepCreator: Function =
|
||||
step.next !== "recoveryCodes" && primaryMethod
|
||||
? fallbackSteps[step.next]
|
||||
: steps[step.next];
|
||||
|
||||
const nextStep = primaryMethod
|
||||
? nextStepCreator(...args, primaryMethod)
|
||||
: nextStepCreator(...args);
|
||||
|
||||
setStep(nextStep);
|
||||
}}
|
||||
onError={setError}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<Text variant={"error"} bg="errorBg" p={1} mt={2}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecoveryCodesDialog(props: RecoveryCodesDialogProps) {
|
||||
const { onClose, primaryMethod } = props;
|
||||
const [error, setError] = useState<string>();
|
||||
const step = steps.recoveryCodes(primaryMethod);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
width={500}
|
||||
positiveButton={{
|
||||
text: "Okay",
|
||||
onClick: onClose,
|
||||
}}
|
||||
>
|
||||
{step.component && (
|
||||
<step.component onNext={() => {}} onError={setError} />
|
||||
)}
|
||||
{error && (
|
||||
<Text variant={"error"} bg="errorBg" p={1} mt={2}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type ChooseAuthenticatorProps = StepComponentProps & {
|
||||
authenticators: AuthenticatorType[];
|
||||
};
|
||||
|
||||
function ChooseAuthenticator(props: ChooseAuthenticatorProps) {
|
||||
const [selected, setSelected] = useSessionState("selectedAuthenticator", 0);
|
||||
const { authenticators, onNext } = props;
|
||||
const filteredAuthenticators = authenticators.map(
|
||||
(a) => Authenticators.find((auth) => auth.type === a)!
|
||||
);
|
||||
return (
|
||||
<Flex
|
||||
as="form"
|
||||
id="2faForm"
|
||||
flexDirection="column"
|
||||
flex={1}
|
||||
sx={{ overflow: "hidden" }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const authenticator = filteredAuthenticators[selected];
|
||||
onNext(authenticator);
|
||||
}}
|
||||
>
|
||||
{filteredAuthenticators.map((auth, index) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
mt={2}
|
||||
sx={{
|
||||
":first-of-type": { mt: 2 },
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
alignItems: "start",
|
||||
textAlign: "left",
|
||||
bg: "transparent",
|
||||
px: 0,
|
||||
}}
|
||||
onClick={() => setSelected(index)}
|
||||
>
|
||||
<auth.icon
|
||||
className="2fa-icon"
|
||||
sx={{
|
||||
bg: selected === index ? "shade" : "bgSecondary",
|
||||
borderRadius: 100,
|
||||
width: 35,
|
||||
height: 35,
|
||||
mr: 2,
|
||||
}}
|
||||
size={16}
|
||||
color={selected === index ? "primary" : "text"}
|
||||
/>
|
||||
<Text variant={"title"} fontWeight="body">
|
||||
{auth.title}{" "}
|
||||
{auth.recommended ? (
|
||||
<Text
|
||||
as="span"
|
||||
variant={"subBody"}
|
||||
color="primary"
|
||||
bg="shade"
|
||||
px={1}
|
||||
sx={{ borderRadius: "default" }}
|
||||
>
|
||||
Recommended
|
||||
</Text>
|
||||
) : (
|
||||
false
|
||||
)}
|
||||
<Text variant="body" fontWeight="normal" mt={1}>
|
||||
{auth.subtitle}
|
||||
</Text>
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatorSelector(props: AuthenticatorSelectorProps) {
|
||||
const { authenticator, isFallback, onNext, onError } = props;
|
||||
const onSubmitCode: SubmitCodeFunction = useCallback(
|
||||
async (code) => {
|
||||
try {
|
||||
if (isFallback) await db.mfa?.enableFallback(authenticator, code);
|
||||
else await db.mfa!.enable(authenticator, code);
|
||||
onNext(authenticator);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
onError && onError(error.message);
|
||||
}
|
||||
},
|
||||
[authenticator, onError, onNext, isFallback]
|
||||
);
|
||||
|
||||
return authenticator === "app" ? (
|
||||
<SetupAuthenticatorApp onSubmitCode={onSubmitCode} />
|
||||
) : authenticator === "email" ? (
|
||||
<SetupEmail onSubmitCode={onSubmitCode} />
|
||||
) : authenticator === "sms" ? (
|
||||
<SetupSMS onSubmitCode={onSubmitCode} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function SetupAuthenticatorApp(props: SetupAuthenticatorProps) {
|
||||
const { onSubmitCode } = props;
|
||||
const [authenticatorDetails, setAuthenticatorDetails] = useState({
|
||||
sharedKey: null,
|
||||
authenticatorUri: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
setAuthenticatorDetails(await db.mfa!.setup("app"));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VerifyAuthenticatorForm
|
||||
codeHelpText={
|
||||
"After scanning the QR code image, the app will display a code that you can enter below."
|
||||
}
|
||||
onSubmitCode={onSubmitCode}
|
||||
>
|
||||
<Text variant={"body"}>
|
||||
Scan the QR code below with your authenticator app.
|
||||
</Text>
|
||||
<Box alignSelf={"center"}>
|
||||
{authenticatorDetails.authenticatorUri ? (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<QRCode
|
||||
value={authenticatorDetails.authenticatorUri}
|
||||
ecLevel={"M"}
|
||||
size={150}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</Box>
|
||||
<Text variant={"subBody"}>
|
||||
If you can't scan the QR code above, enter this text instead (spaces
|
||||
don't matter):
|
||||
</Text>
|
||||
<Text
|
||||
mt={2}
|
||||
bg="bgSecondary"
|
||||
p={2}
|
||||
fontFamily="monospace"
|
||||
fontSize="body"
|
||||
sx={{ borderRadius: "default", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{authenticatorDetails.sharedKey ? (
|
||||
authenticatorDetails.sharedKey
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</Text>
|
||||
</VerifyAuthenticatorForm>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupEmail(props: SetupAuthenticatorProps) {
|
||||
const { onSubmitCode } = props;
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const { elapsed, enabled, setEnabled } = useTimer(`2fa.email`, 60);
|
||||
const [email, setEmail] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { email } = await db.user!.getUser();
|
||||
setEmail(email);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VerifyAuthenticatorForm
|
||||
codeHelpText={
|
||||
"You will receive a 2FA code on your email address which you can enter below"
|
||||
}
|
||||
onSubmitCode={onSubmitCode}
|
||||
>
|
||||
<Flex
|
||||
mt={2}
|
||||
bg="bgSecondary"
|
||||
alignItems={"center"}
|
||||
sx={{ borderRadius: "default", overflowWrap: "anywhere" }}
|
||||
>
|
||||
<Text ml={2} fontFamily="monospace" fontSize="subtitle" flex={1}>
|
||||
{email}
|
||||
</Text>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
alignSelf={"center"}
|
||||
sx={{ p: 2, m: 0 }}
|
||||
disabled={isSending || !enabled}
|
||||
onClick={async () => {
|
||||
setIsSending(true);
|
||||
try {
|
||||
await db.mfa!.setup("email");
|
||||
setEnabled(false);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loading size={18} />
|
||||
) : enabled ? (
|
||||
`Send code`
|
||||
) : (
|
||||
`Resend (${elapsed})`
|
||||
)}
|
||||
</Button>
|
||||
</Flex>
|
||||
{error ? (
|
||||
<Text
|
||||
variant={"error"}
|
||||
bg="errorBg"
|
||||
p={1}
|
||||
sx={{ borderRadius: "default" }}
|
||||
mt={1}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</VerifyAuthenticatorForm>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupSMS(props: SetupAuthenticatorProps) {
|
||||
const { onSubmitCode } = props;
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60);
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
|
||||
return (
|
||||
<VerifyAuthenticatorForm
|
||||
codeHelpText={
|
||||
"You will receive a 2FA code on your phone number which you can enter below"
|
||||
}
|
||||
onSubmitCode={onSubmitCode}
|
||||
>
|
||||
<Field
|
||||
inputRef={inputRef}
|
||||
id="phone-number"
|
||||
name="phone-number"
|
||||
helpText="Authentication codes will be sent to this number"
|
||||
label="Phone number"
|
||||
sx={{ mt: 2 }}
|
||||
autoFocus
|
||||
required
|
||||
styles={{
|
||||
input: { flex: 1 },
|
||||
}}
|
||||
placeholder={"+1234567890"}
|
||||
action={{
|
||||
disabled: isSending || !enabled,
|
||||
component: (
|
||||
<Text variant={"body"}>
|
||||
{isSending ? (
|
||||
<Loading size={18} />
|
||||
) : enabled ? (
|
||||
`Send code`
|
||||
) : (
|
||||
`Resend (${elapsed})`
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
onClick: async () => {
|
||||
if (!inputRef.current?.value) {
|
||||
setError("Please provide a phone number.");
|
||||
return;
|
||||
}
|
||||
setIsSending(true);
|
||||
try {
|
||||
await db.mfa!.setup("sms", inputRef.current?.value);
|
||||
setEnabled(false);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<Text
|
||||
variant={"error"}
|
||||
bg="errorBg"
|
||||
p={1}
|
||||
sx={{ borderRadius: "default" }}
|
||||
mt={1}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</VerifyAuthenticatorForm>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupRecoveryCodes(props: StepComponentProps) {
|
||||
const { onNext, onError } = props;
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
const recoveryCodesRef = useRef<HTMLDivElement>();
|
||||
const generate = useCallback(async () => {
|
||||
onError && onError("");
|
||||
try {
|
||||
const codes = await db.mfa?.codes();
|
||||
if (codes) setCodes(codes);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
onError && onError(error.message);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
await generate();
|
||||
})();
|
||||
}, [generate]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Print",
|
||||
icon: Print,
|
||||
action: async () => {
|
||||
if (!recoveryCodesRef.current) return;
|
||||
await exportToPDF(
|
||||
"Notesnook 2FA Recovery Codes",
|
||||
recoveryCodesRef.current.outerHTML
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Copy",
|
||||
icon: Copy,
|
||||
action: async () => {
|
||||
await clipboard.writeText(codes.join("\n"));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Download",
|
||||
icon: Download,
|
||||
action: () => {
|
||||
FileSaver.saveAs(
|
||||
new Blob([Buffer.from(codes.join("\n"))]),
|
||||
`notesnook-recovery-codes.txt`
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: "Regenerate", icon: Refresh, action: generate },
|
||||
],
|
||||
[codes, generate]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
as="form"
|
||||
id="2faForm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="selectable"
|
||||
ref={recoveryCodesRef}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr 1fr",
|
||||
bg: "bgSecondary",
|
||||
p: 2,
|
||||
borderRadius: "default",
|
||||
}}
|
||||
>
|
||||
{codes.map((code) => (
|
||||
<Text
|
||||
className="selectable"
|
||||
as="code"
|
||||
variant={"subheading"}
|
||||
textAlign="center"
|
||||
fontWeight="body"
|
||||
fontFamily={"monospace"}
|
||||
>
|
||||
{code}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Flex sx={{ justifyContent: "start", alignItems: "center", mt: 2 }}>
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
mr={1}
|
||||
py={1}
|
||||
sx={{ display: "flex", alignItems: "center" }}
|
||||
onClick={action.action}
|
||||
>
|
||||
<action.icon size={15} sx={{ mr: "2px" }} />
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoFactorEnabled(props: StepComponentProps) {
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
justifyContent="center"
|
||||
alignItems={"center"}
|
||||
mb={2}
|
||||
>
|
||||
<MFA width={120} />
|
||||
<Text variant={"heading"} fontSize="subheading" mt={2} textAlign="center">
|
||||
Two-factor authentication enabled!
|
||||
</Text>
|
||||
<Text variant={"body"} color="fontTertiary" mt={1} textAlign="center">
|
||||
Your account is now 100% secure against unauthorized logins.
|
||||
</Text>
|
||||
<Button mt={2} sx={{ borderRadius: 100, px: 6 }} onClick={props.onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
type Fallback2FAEnabledProps = StepComponentProps & {
|
||||
fallbackMethod: AuthenticatorType;
|
||||
primaryMethod: AuthenticatorType;
|
||||
};
|
||||
function Fallback2FAEnabled(props: Fallback2FAEnabledProps) {
|
||||
const { fallbackMethod, primaryMethod, onClose } = props;
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
justifyContent="center"
|
||||
alignItems={"center"}
|
||||
mb={2}
|
||||
>
|
||||
<Fallback2FA width={200} />
|
||||
<Text variant={"heading"} fontSize="subheading" mt={2} textAlign="center">
|
||||
Fallback 2FA method enabled!
|
||||
</Text>
|
||||
<Text variant={"body"} color="fontTertiary" mt={1} textAlign="center">
|
||||
You will now receive your 2FA codes on your{" "}
|
||||
{mfaMethodToPhrase(fallbackMethod)} in case you lose access to your{" "}
|
||||
{mfaMethodToPhrase(primaryMethod)}.
|
||||
</Text>
|
||||
<Button mt={2} sx={{ borderRadius: 100, px: 6 }} onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyAuthenticatorForm(props: VerifyAuthenticatorFormProps) {
|
||||
const { codeHelpText, onSubmitCode, children } = props;
|
||||
const formRef = useRef<HTMLFormElement>();
|
||||
return (
|
||||
<Flex
|
||||
ref={formRef}
|
||||
as="form"
|
||||
id="2faForm"
|
||||
flexDirection="column"
|
||||
flex={1}
|
||||
sx={{ overflow: "hidden" }}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(formRef.current);
|
||||
const code = form.get("code");
|
||||
if (!code || code.toString().length !== 6) return;
|
||||
onSubmitCode(code.toString());
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Field
|
||||
id="code"
|
||||
name="code"
|
||||
helpText={codeHelpText}
|
||||
label="Enter the 6-digit code"
|
||||
sx={{ alignItems: "center", mt: 2 }}
|
||||
autoFocus
|
||||
required
|
||||
placeholder="010101"
|
||||
min={99999}
|
||||
max={999999}
|
||||
type="number"
|
||||
variant="clean"
|
||||
styles={{
|
||||
input: {
|
||||
width: "100%",
|
||||
fontSize: 38,
|
||||
fontFamily: "monospace",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function mfaMethodToPhrase(method: AuthenticatorType): string {
|
||||
return method === "email"
|
||||
? "email"
|
||||
: method === "app"
|
||||
? "authentication app"
|
||||
: "phone number";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import { Input, Label } from "@rebass/forms";
|
||||
import * as Icon from "../icons";
|
||||
|
||||
@@ -48,6 +48,8 @@ function Field(props) {
|
||||
placeholder,
|
||||
validatePassword,
|
||||
onError,
|
||||
inputMode,
|
||||
pattern,
|
||||
variant = "input",
|
||||
as = "input",
|
||||
} = props;
|
||||
@@ -96,6 +98,8 @@ function Field(props) {
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
pattern={pattern}
|
||||
type={type || "text"}
|
||||
sx={{
|
||||
...styles.input,
|
||||
@@ -148,10 +152,11 @@ function Field(props) {
|
||||
</Flex>
|
||||
)}
|
||||
{action && (
|
||||
<Flex
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
data-test-id={action.testId}
|
||||
onClick={action.onClick}
|
||||
variant="rowCenter"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
@@ -162,9 +167,10 @@ function Field(props) {
|
||||
borderRadius: "default",
|
||||
":hover": { bg: "border" },
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<action.icon size={20} />
|
||||
</Flex>
|
||||
{action.component ? action.component : <action.icon size={20} />}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
{validatePassword && (
|
||||
|
||||
@@ -136,6 +136,10 @@ import {
|
||||
mdiContentSaveCheckOutline,
|
||||
mdiContentSaveAlertOutline,
|
||||
mdiCurrencyUsd,
|
||||
mdiCellphoneKey,
|
||||
mdiEmailOutline,
|
||||
mdiMessageLockOutline,
|
||||
mdiShieldCheckOutline,
|
||||
mdiAlertOctagonOutline,
|
||||
mdiGithub,
|
||||
mdiAlertCircleOutline,
|
||||
@@ -144,8 +148,10 @@ import {
|
||||
mdiCheckAll,
|
||||
mdiCloudOffOutline,
|
||||
mdiContentDuplicate,
|
||||
mdiPrinterOutline,
|
||||
mdiRefresh,
|
||||
mdiRestore,
|
||||
mdiVectorLink,
|
||||
mdiCodeString,
|
||||
mdiCodeBraces,
|
||||
} from "@mdi/js";
|
||||
import { useTheme } from "emotion-theming";
|
||||
@@ -175,6 +181,7 @@ function createIcon(name, rotate = false) {
|
||||
<AnimatedFlex
|
||||
flexShrink={0}
|
||||
id={props.id}
|
||||
className={props.className}
|
||||
title={props.title}
|
||||
variant={props.variant}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
@@ -295,6 +302,7 @@ export const Publish = createIcon(mdiCloudUploadOutline);
|
||||
export const Colors = createIcon(mdiPaletteOutline);
|
||||
export const Published = createIcon(mdiCloudCheckOutline);
|
||||
export const Copy = createIcon(mdiContentCopy);
|
||||
export const Refresh = createIcon(mdiRefresh);
|
||||
export const Duplicate = createIcon(mdiContentDuplicate);
|
||||
export const Select = createIcon(mdiCheckboxMultipleMarkedCircleOutline);
|
||||
export const NotebookEdit = createIcon(mdiBookEditOutline);
|
||||
@@ -314,6 +322,7 @@ export const Reddit = createIcon(mdiReddit);
|
||||
export const Dismiss = createIcon(mdiClose);
|
||||
export const File = createIcon(mdiFileOutline);
|
||||
export const Download = createIcon(mdiArrowDown);
|
||||
export const Print = createIcon(mdiPrinterOutline);
|
||||
export const ImageDownload = createIcon(mdiImage);
|
||||
export const Billboard = createIcon(mdiBillboard);
|
||||
export const Cellphone = createIcon(mdiCellphone);
|
||||
@@ -360,6 +369,11 @@ export const OrderNewestOldest = createIcon(mdiOrderNumericAscending);
|
||||
export const Saved = createIcon(mdiContentSaveCheckOutline);
|
||||
export const NotSaved = createIcon(mdiContentSaveAlertOutline);
|
||||
|
||||
export const MFAAuthenticator = createIcon(mdiCellphoneKey);
|
||||
export const MFAEmail = createIcon(mdiEmailOutline);
|
||||
export const MFARecoveryCode = createIcon(mdiRestore);
|
||||
export const MFASMS = createIcon(mdiMessageLockOutline);
|
||||
export const MFAEnabled = createIcon(mdiShieldCheckOutline);
|
||||
export const Reupload = createIcon(mdiProgressUpload);
|
||||
export const Rename = createIcon(mdiFormTextbox);
|
||||
export const Upload = createIcon(mdiCloudOffOutline);
|
||||
|
||||
29
apps/web/src/hooks/use-timer.ts
Normal file
29
apps/web/src/hooks/use-timer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSessionState } from "../utils/hooks";
|
||||
|
||||
export function useTimer(id: string, duration: number) {
|
||||
const [seconds, setSeconds] = useSessionState(id, duration);
|
||||
const [enabled, setEnabled] = useSessionState(`${id}.canSendAgain`, true);
|
||||
const interval = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
interval.current = setInterval(() => {
|
||||
setSeconds((seconds: number) => {
|
||||
--seconds;
|
||||
if (seconds <= 0) {
|
||||
setEnabled(true);
|
||||
if (interval.current) clearInterval(interval.current);
|
||||
return duration;
|
||||
}
|
||||
return seconds;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (interval.current) clearInterval(interval.current);
|
||||
};
|
||||
}, [enabled, setEnabled, setSeconds, duration]);
|
||||
|
||||
return { elapsed: seconds, enabled, setEnabled };
|
||||
}
|
||||
@@ -21,19 +21,27 @@ const ROUTES = {
|
||||
},
|
||||
"/signup": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "signup" },
|
||||
props: { route: "signup" },
|
||||
},
|
||||
"/sessionexpired": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "sessionexpired" },
|
||||
props: { route: "sessionExpiry" },
|
||||
},
|
||||
"/login": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "login" },
|
||||
props: { route: "login" },
|
||||
},
|
||||
"/recover": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { type: "recover" },
|
||||
props: { route: "recover" },
|
||||
},
|
||||
"/mfa/code": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { route: "login" },
|
||||
},
|
||||
"/mfa/select": {
|
||||
component: () => import("./views/auth"),
|
||||
props: { route: "login" },
|
||||
},
|
||||
default: { component: () => import("./app"), props: {} },
|
||||
};
|
||||
|
||||
@@ -104,8 +104,9 @@ export function hardNavigate(route) {
|
||||
window.open(makeURL(route, getCurrentHash()), "_self");
|
||||
}
|
||||
|
||||
export function makeURL(route, hash) {
|
||||
export function makeURL(route, hash, search) {
|
||||
const url = new URL(route, window.location.origin);
|
||||
if (!url.hash) url.hash = hash || getCurrentHash();
|
||||
url.search = search || getQueryString();
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class UserStore extends BaseStore {
|
||||
isLoggedIn = false;
|
||||
isLoggingIn = false;
|
||||
isSigningIn = false;
|
||||
/**
|
||||
* @type {User}
|
||||
*/
|
||||
user = undefined;
|
||||
|
||||
init = () => {
|
||||
@@ -85,17 +88,25 @@ class UserStore extends BaseStore {
|
||||
});
|
||||
};
|
||||
|
||||
login = (form, skipInit = false) => {
|
||||
refreshUser = async () => {
|
||||
return db.user.fetchUser().then(async (user) => {
|
||||
this.set((state) => (state.user = user));
|
||||
});
|
||||
};
|
||||
|
||||
login = async (form, skipInit = false) => {
|
||||
this.set((state) => (state.isLoggingIn = true));
|
||||
return db.user
|
||||
.login(form.email.toLowerCase(), form.password)
|
||||
.then(() => {
|
||||
if (skipInit) return true;
|
||||
return this.init();
|
||||
})
|
||||
.finally(() => {
|
||||
this.set((state) => (state.isLoggingIn = false));
|
||||
});
|
||||
const { email, password, code, method } = form;
|
||||
|
||||
try {
|
||||
if (code) await db.user.mfaLogin(email, password, { code, method });
|
||||
else await db.user.login(email, password);
|
||||
|
||||
if (skipInit) return true;
|
||||
return this.init();
|
||||
} finally {
|
||||
this.set((state) => (state.isLoggingIn = false));
|
||||
}
|
||||
};
|
||||
|
||||
signup = (form) => {
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loading,
|
||||
Error,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} from "../components/icons";
|
||||
import Field from "../components/field";
|
||||
import { getQueryParams, hardNavigate, useQueryParams } from "../navigation";
|
||||
import { store as userstore } from "../stores/user-store";
|
||||
import { db } from "../common/db";
|
||||
import Config from "../utils/config";
|
||||
import useDatabase from "../hooks/use-database";
|
||||
import Loader from "../components/loader";
|
||||
import {
|
||||
showLoadingDialog,
|
||||
showLogoutConfirmation,
|
||||
} from "../common/dialog-controller";
|
||||
import { showToast } from "../utils/toast";
|
||||
import AuthContainer from "../components/auth-container";
|
||||
|
||||
const authTypes = {
|
||||
sessionexpired: {
|
||||
title: "Your session has expired",
|
||||
subtitle: {
|
||||
text: (
|
||||
<Flex bg="shade" p={1} sx={{ borderRadius: "default" }}>
|
||||
<Text as="span" fontSize="body" color="primary">
|
||||
<b>
|
||||
All your local changes are safe and will be synced after you
|
||||
relogin.
|
||||
</b>{" "}
|
||||
Please enter your password to continue.
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Your account email",
|
||||
defaultValue: (user) => maskEmail(user?.email),
|
||||
disabled: true,
|
||||
autoComplete: "false",
|
||||
type: "email",
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
name: "password",
|
||||
label: "Enter your password",
|
||||
autoComplete: "current-password",
|
||||
type: "password",
|
||||
autoFocus: true,
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Relogin to your account",
|
||||
},
|
||||
secondaryAction: {
|
||||
text: <Text color="error">Logout permanently</Text>,
|
||||
onClick: async () => {
|
||||
if (await showLogoutConfirmation()) {
|
||||
await showLoadingDialog({
|
||||
title: "You are being logged out",
|
||||
action: () => db.user.logout(true),
|
||||
});
|
||||
showToast("success", "You have been logged out.");
|
||||
Config.set("sessionExpired", false);
|
||||
window.location.replace("/login");
|
||||
}
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
title: "Logging you in",
|
||||
text: "Please wait while you are authenticated.",
|
||||
},
|
||||
supportsPasswordRecovery: true,
|
||||
onSubmit: async (form, onError) => {
|
||||
return await userstore
|
||||
.login(form)
|
||||
.then(async () => {
|
||||
Config.set("sessionExpired", false);
|
||||
redirectToURL(form.redirect || "/");
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
signup: {
|
||||
title: "Create an account",
|
||||
subtitle: {
|
||||
text: "Already have an account?",
|
||||
action: {
|
||||
text: "Log in",
|
||||
onClick: () => hardNavigate("/login", getQueryParams()),
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Enter email",
|
||||
autoComplete: "email",
|
||||
type: "email",
|
||||
autoFocus: true,
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
name: "password",
|
||||
label: "Set password",
|
||||
autoComplete: "new-password",
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
id: "confirm-password",
|
||||
name: "confirmPassword",
|
||||
label: "Confirm password",
|
||||
autoComplete: "confirm-password",
|
||||
type: "password",
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Agree & continue",
|
||||
},
|
||||
secondaryAction: {
|
||||
text: "Continue without creating an account",
|
||||
icon: <ArrowRight size={18} />,
|
||||
onClick: () => {
|
||||
redirectToURL("/");
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
title: "Creating your account",
|
||||
text: "Please wait while we finalize your account.",
|
||||
},
|
||||
footer: (
|
||||
<>
|
||||
By pressing "Create account" button, you agree to our{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="text"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/tos"
|
||||
>
|
||||
Terms of Service
|
||||
</Text>{" "}
|
||||
&{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="text"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/privacy"
|
||||
>
|
||||
Privacy Policy
|
||||
</Text>
|
||||
.
|
||||
</>
|
||||
),
|
||||
onSubmit: async (form, onError) => {
|
||||
if (form.password !== form.confirmPassword) {
|
||||
onError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
return await userstore
|
||||
.signup(form)
|
||||
.then(() => {
|
||||
redirectToURL("/notes/#/welcome");
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
login: {
|
||||
title: "Welcome back!",
|
||||
subtitle: {
|
||||
text: "Don't have an account?",
|
||||
action: {
|
||||
text: "Sign up!",
|
||||
onClick: () => hardNavigate("/signup", getQueryParams()),
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: "email",
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Enter email",
|
||||
autoComplete: "email",
|
||||
autoFocus: true,
|
||||
defaultValue: (_user, form) => form.email,
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
id: "password",
|
||||
name: "password",
|
||||
label: "Enter password",
|
||||
autoComplete: "current-password",
|
||||
defaultValue: (_user, form) => form.password,
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Login to your account",
|
||||
},
|
||||
loading: {
|
||||
title: "Logging you in",
|
||||
text: "Please wait while you are authenticated.",
|
||||
},
|
||||
supportsPasswordRecovery: true,
|
||||
onSubmit: async (form, onError) => {
|
||||
return await userstore
|
||||
.login(form)
|
||||
.then(async () => {
|
||||
redirectToURL(form.redirect || "/");
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
recover: {
|
||||
resetOnNavigate: false,
|
||||
title: "Recover your account",
|
||||
subtitle: {
|
||||
text: "Remembered your password?",
|
||||
action: {
|
||||
text: "Log in",
|
||||
onClick: () => hardNavigate("/login", getQueryParams()),
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: "email",
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Enter your account email",
|
||||
autoComplete: "email",
|
||||
helpText:
|
||||
"You will receive instructions on how to recover your account on this email",
|
||||
autoFocus: true,
|
||||
defaultValue: (user, form) => form?.email || user?.email,
|
||||
},
|
||||
],
|
||||
primaryAction: {
|
||||
text: "Send recovery email",
|
||||
},
|
||||
loading: {
|
||||
title: "Sending recovery email",
|
||||
text: "Please wait while we send you recovery instructions",
|
||||
},
|
||||
onSubmit: async (form, onError, onSuccess) => {
|
||||
return await db.user
|
||||
.recoverAccount(form.email.toLowerCase())
|
||||
.then(async (url) => {
|
||||
return redirectToURL(url);
|
||||
|
||||
// onSuccess(
|
||||
// "Recovery email sent. Please check your inbox (and spam folder)."
|
||||
// );
|
||||
})
|
||||
.catch((e) => onError(e.message));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function Auth(props) {
|
||||
const { type } = props;
|
||||
const [{ redirect }] = useQueryParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [success, setSuccess] = useState();
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [form, setForm] = useState({});
|
||||
const [user, setUser] = useState();
|
||||
|
||||
const data = authTypes[type];
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
setError();
|
||||
setSuccess();
|
||||
}
|
||||
}, [isSubmitting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppLoaded) return;
|
||||
(async () => {
|
||||
const user = await db.user.getUser();
|
||||
const isSessionExpired = Config.get("sessionExpired", false);
|
||||
if (user) {
|
||||
if (
|
||||
(type === "recover" || type === "sessionexpired") &&
|
||||
isSessionExpired
|
||||
)
|
||||
setUser(user);
|
||||
else redirectToURL("/");
|
||||
} else if (type === "sessionexpired") {
|
||||
redirectToURL("/");
|
||||
}
|
||||
})();
|
||||
}, [isAppLoaded, type]);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader title={data.loading.title} text={data.loading.text} />
|
||||
</>
|
||||
) : (
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
sx={{
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{data.secondaryAction ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
mr={[2, 2, 4]}
|
||||
mt={[2, 2, 4]}
|
||||
alignSelf="end"
|
||||
onClick={data.secondaryAction.onClick}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "default",
|
||||
color: "icon",
|
||||
}}
|
||||
>
|
||||
<Text mr={1}>{data.secondaryAction.text}</Text>
|
||||
{data.secondaryAction.icon}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
ml={[2, 2, 4]}
|
||||
mt={[2, 2, 4]}
|
||||
alignSelf="start"
|
||||
title="Go to app"
|
||||
onClick={() => hardNavigate("/")}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "default",
|
||||
color: "icon",
|
||||
}}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
as="form"
|
||||
id="authForm"
|
||||
flexDirection="column"
|
||||
alignSelf="center"
|
||||
justifyContent={"center"}
|
||||
alignItems="center"
|
||||
flex={1}
|
||||
onSubmit={async (e) => {
|
||||
console.log(e);
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData(e.target);
|
||||
const form = Object.fromEntries(formData.entries());
|
||||
form.redirect = redirect;
|
||||
if (user) form.email = user.email;
|
||||
setForm(form);
|
||||
await data.onSubmit(
|
||||
form,
|
||||
(error) => {
|
||||
setIsSubmitting(false);
|
||||
setError(error);
|
||||
},
|
||||
(message) => {
|
||||
setSuccess(message);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text variant={"heading"} fontSize={32} textAlign="center">
|
||||
{data.title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
fontSize={"title"}
|
||||
textAlign="center"
|
||||
mt={2}
|
||||
mb={35}
|
||||
color="fontTertiary"
|
||||
>
|
||||
{data.subtitle.text}{" "}
|
||||
{data.subtitle.action && (
|
||||
<Text
|
||||
sx={{
|
||||
textDecoration: "underline",
|
||||
":hover": { color: "dimPrimary" },
|
||||
cursor: "pointer",
|
||||
}}
|
||||
as="b"
|
||||
color="text"
|
||||
onClick={data.subtitle.action.onClick}
|
||||
>
|
||||
{data.subtitle.action.text}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
{success && (
|
||||
<Flex bg="shade" p={1} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<CheckCircle size={15} color="primary" />
|
||||
<Text variant="error" color="primary" ml={1}>
|
||||
{success}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{data.fields?.map(({ defaultValue, id, autoFocus, ...rest }) => (
|
||||
<Field
|
||||
{...rest}
|
||||
id={id}
|
||||
key={id}
|
||||
required
|
||||
styles={{
|
||||
container: { mt: 2, width: 400 },
|
||||
label: { fontWeight: "normal" },
|
||||
input: {
|
||||
p: "12px",
|
||||
borderRadius: "default",
|
||||
bg: "background",
|
||||
boxShadow: "0px 0px 5px 0px #00000019",
|
||||
},
|
||||
}}
|
||||
data-test-id={id}
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={defaultValue && defaultValue(user, form)}
|
||||
/>
|
||||
))}
|
||||
{data.supportsPasswordRecovery && (
|
||||
<Button
|
||||
type="button"
|
||||
alignSelf="end"
|
||||
data-test-id="auth-forgot-password"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
color="text"
|
||||
onClick={() => hardNavigate("/recover", getQueryParams())}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-test-id="submitButton"
|
||||
display="flex"
|
||||
type="submit"
|
||||
mt={50}
|
||||
variant="primary"
|
||||
alignSelf={"center"}
|
||||
px={50}
|
||||
sx={{ borderRadius: 50 }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
disabled={!isAppLoaded}
|
||||
>
|
||||
{isAppLoaded ? (
|
||||
data.primaryAction.text
|
||||
) : (
|
||||
<Loading color="static" />
|
||||
)}
|
||||
</Button>
|
||||
{error && (
|
||||
<Flex bg="errorBg" p={1} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<Error size={15} color="error" />
|
||||
<Text variant="error" ml={1}>
|
||||
{error}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{data.footer && (
|
||||
<Text
|
||||
mt={4}
|
||||
maxWidth={350}
|
||||
variant="subBody"
|
||||
fontSize={13}
|
||||
textAlign="center"
|
||||
>
|
||||
{data.footer}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
export default Auth;
|
||||
|
||||
function redirectToURL(url) {
|
||||
Config.set("skipInitiation", true);
|
||||
hardNavigate(url);
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
if (!email) return "";
|
||||
const [username, domain] = email.split("@");
|
||||
const maskChars = "*".repeat(
|
||||
username.substring(2, username.length - 2).length
|
||||
);
|
||||
return `${username.substring(0, 2)}${maskChars}${username.substring(
|
||||
username.length - 2
|
||||
)}@${domain}`;
|
||||
}
|
||||
877
apps/web/src/views/auth.tsx
Normal file
877
apps/web/src/views/auth.tsx
Normal file
@@ -0,0 +1,877 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Flex, Text } from "rebass";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loading,
|
||||
Error as ErrorIcon,
|
||||
MFAAuthenticator,
|
||||
MFASMS,
|
||||
MFAEmail,
|
||||
MFARecoveryCode,
|
||||
} from "../components/icons";
|
||||
import Field from "../components/field";
|
||||
import { getQueryParams, hardNavigate, makeURL } from "../navigation";
|
||||
import { store as userstore } from "../stores/user-store";
|
||||
import { db } from "../common/db";
|
||||
import Config from "../utils/config";
|
||||
import useDatabase from "../hooks/use-database";
|
||||
import Loader from "../components/loader";
|
||||
import { showToast } from "../utils/toast";
|
||||
import AuthContainer from "../components/auth-container";
|
||||
import { isTesting } from "../utils/platform";
|
||||
import { AuthenticatorType } from "../components/dialogs/multi-factor-dialog";
|
||||
import { RequestError } from "notes-core/utils/http";
|
||||
import { useTimer } from "../hooks/use-timer";
|
||||
|
||||
type LoginFormData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type MFALoginFormData = LoginFormData & {
|
||||
code?: string;
|
||||
method?: MFAMethodType;
|
||||
};
|
||||
|
||||
type SignupFormData = LoginFormData & {
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
type AccountRecoveryFormData = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
type MFAFormData = LoginFormData & {
|
||||
selectedMethod: MFAMethodType;
|
||||
primaryMethod: MFAMethodType;
|
||||
code?: string;
|
||||
token: string;
|
||||
secondaryMethod?: MFAMethodType;
|
||||
phoneNumber?: string;
|
||||
};
|
||||
|
||||
type MFAErrorData = {
|
||||
primaryMethod: MFAMethodType;
|
||||
token: string;
|
||||
secondaryMethod?: MFAMethodType;
|
||||
phoneNumber?: string;
|
||||
};
|
||||
|
||||
type AuthFormData = {
|
||||
login: LoginFormData;
|
||||
signup: SignupFormData;
|
||||
sessionExpiry: LoginFormData;
|
||||
recover: AccountRecoveryFormData;
|
||||
"mfa:code": MFAFormData;
|
||||
"mfa:select": MFAFormData;
|
||||
};
|
||||
|
||||
type BaseFormData =
|
||||
| MFAFormData
|
||||
| LoginFormData
|
||||
| AccountRecoveryFormData
|
||||
| SignupFormData;
|
||||
|
||||
type NavigateFunction = <TRoute extends AuthRoutes>(
|
||||
route: TRoute,
|
||||
formData?: AuthFormData[TRoute]
|
||||
) => void;
|
||||
type BaseAuthComponentProps<TRoute extends AuthRoutes> = {
|
||||
navigate: NavigateFunction;
|
||||
formData?: AuthFormData[TRoute];
|
||||
};
|
||||
type AuthRoutes =
|
||||
| "sessionExpiry"
|
||||
| "login"
|
||||
| "signup"
|
||||
| "recover"
|
||||
| "mfa:code"
|
||||
| "mfa:select";
|
||||
type AuthProps = { route: AuthRoutes };
|
||||
|
||||
type AuthComponent<TRoute extends AuthRoutes> = (
|
||||
props: BaseAuthComponentProps<TRoute>
|
||||
) => JSX.Element;
|
||||
|
||||
function getRouteComponent<TRoute extends AuthRoutes>(
|
||||
route: TRoute
|
||||
): AuthComponent<TRoute> | undefined {
|
||||
switch (route) {
|
||||
case "login":
|
||||
return Login as AuthComponent<TRoute>;
|
||||
case "signup":
|
||||
return Signup as AuthComponent<TRoute>;
|
||||
case "sessionExpiry":
|
||||
return SessionExpiry as AuthComponent<TRoute>;
|
||||
case "recover":
|
||||
return AccountRecovery as AuthComponent<TRoute>;
|
||||
case "mfa:code":
|
||||
return MFACode as AuthComponent<TRoute>;
|
||||
case "mfa:select":
|
||||
return MFASelector as AuthComponent<TRoute>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const routePaths: Record<AuthRoutes, string> = {
|
||||
login: "/login",
|
||||
recover: "/recover",
|
||||
sessionExpiry: "/sessionexpired",
|
||||
signup: "/signup",
|
||||
"mfa:code": "/mfa/code",
|
||||
"mfa:select": "/mfa/select",
|
||||
};
|
||||
|
||||
function Auth(props: AuthProps) {
|
||||
const [route, setRoute] = useState(props.route);
|
||||
const [storedFormData, setStoredFormData] = useState<
|
||||
BaseFormData | undefined
|
||||
>();
|
||||
const Route = useMemo(() => getRouteComponent(route), [route]);
|
||||
useEffect(() => {
|
||||
window.history.replaceState({}, "", makeURL(routePaths[route]));
|
||||
}, [route]);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<Flex
|
||||
flexDirection={"column"}
|
||||
sx={{
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{Route && (
|
||||
<Route
|
||||
navigate={(route, formData) => {
|
||||
setStoredFormData(formData);
|
||||
setRoute(route);
|
||||
}}
|
||||
formData={storedFormData}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
export default Auth;
|
||||
|
||||
function Login(props: BaseAuthComponentProps<"login">) {
|
||||
const { navigate } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="login"
|
||||
title="Welcome back!"
|
||||
subtitle={
|
||||
<SubtitleWithAction
|
||||
text="Don't have an account?"
|
||||
action={{ text: "Sign up", onClick: () => navigate("signup") }}
|
||||
/>
|
||||
}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={(form) => login(form, navigate)}
|
||||
>
|
||||
{(form?: LoginFormData) => (
|
||||
<>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
label="Enter email"
|
||||
autoFocus={!form?.password}
|
||||
defaultValue={form?.email}
|
||||
/>
|
||||
<AuthField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
label="Enter password"
|
||||
autoFocus={!!form?.password}
|
||||
/>
|
||||
<Button
|
||||
data-test-id="auth-forgot-password"
|
||||
type="button"
|
||||
alignSelf="end"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
color="text"
|
||||
onClick={() => navigate("recover")}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
<SubmitButton
|
||||
text="Login to your account"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function Signup(props: BaseAuthComponentProps<"signup">) {
|
||||
const { navigate } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="signup"
|
||||
title="Create an account"
|
||||
subtitle={
|
||||
<SubtitleWithAction
|
||||
text="Already have an account?"
|
||||
action={{ text: "Log in", onClick: () => navigate("login") }}
|
||||
/>
|
||||
}
|
||||
loading={{
|
||||
title: "Creating your account",
|
||||
subtitle: "Please wait while we finalize your account.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
if (form.password !== form.confirmPassword) {
|
||||
throw new Error("Passwords do not match.");
|
||||
}
|
||||
|
||||
await userstore.signup(form);
|
||||
openURL("/notes/#/welcome");
|
||||
}}
|
||||
>
|
||||
{(form?: SignupFormData) => (
|
||||
<>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
label="Enter email"
|
||||
autoFocus
|
||||
defaultValue={form?.email}
|
||||
/>
|
||||
<AuthField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
label="Set password"
|
||||
defaultValue={form?.password}
|
||||
/>
|
||||
<AuthField
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
autoComplete="confirm-password"
|
||||
label="Confirm password"
|
||||
defaultValue={form?.confirmPassword}
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Create account"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
<Text mt={4} variant="subBody" fontSize={13} textAlign="center">
|
||||
By pressing "Create account" button, you agree to our{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="primary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/tos"
|
||||
>
|
||||
Terms of Service
|
||||
</Text>{" "}
|
||||
&{" "}
|
||||
<Text
|
||||
as="a"
|
||||
color="primary"
|
||||
rel="noreferrer"
|
||||
href="https://notesnook.com/privacy"
|
||||
>
|
||||
Privacy Policy
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
|
||||
const { navigate } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [user, setUser] = useState<User | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppLoaded) return;
|
||||
(async () => {
|
||||
const user = await db.user?.getUser();
|
||||
const isSessionExpired = Config.get("sessionExpired", false);
|
||||
if (user && isSessionExpired) {
|
||||
setUser(user);
|
||||
} else openURL("/");
|
||||
})();
|
||||
}, [isAppLoaded]);
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="sessionExpiry"
|
||||
title="Your session has expired"
|
||||
subtitle={
|
||||
<Flex bg="shade" p={1} sx={{ borderRadius: "default" }}>
|
||||
<Text as="span" fontSize="body" color="primary">
|
||||
<b>
|
||||
All your local changes are safe and will be synced after you
|
||||
login.
|
||||
</b>{" "}
|
||||
Please enter your password to continue.
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={(form) => login(form, navigate)}
|
||||
>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete={"false"}
|
||||
label="Enter email"
|
||||
defaultValue={maskEmail(user!.email)}
|
||||
autoFocus
|
||||
disabled
|
||||
/>
|
||||
<AuthField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
label="Enter password"
|
||||
/>
|
||||
<Button
|
||||
data-test-id="auth-forgot-password"
|
||||
type="button"
|
||||
alignSelf="end"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
color="text"
|
||||
onClick={() => navigate("recover", { email: user!.email })}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
<SubmitButton
|
||||
text="Relogin to your account"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
|
||||
const { navigate, formData } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [success, setSuccess] = useState<string>();
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="recover"
|
||||
title="Recover your account"
|
||||
subtitle={
|
||||
<SubtitleWithAction
|
||||
text="Remembered your password?"
|
||||
action={{ text: "Log in", onClick: () => navigate("login") }}
|
||||
/>
|
||||
}
|
||||
loading={{
|
||||
title: "Sending recovery email",
|
||||
subtitle: "Please wait while we send you recovery instructions.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const url = await db.user?.recoverAccount(form.email.toLowerCase());
|
||||
if (isTesting()) return openURL(url);
|
||||
setSuccess(
|
||||
`Recovery email sent. Please check your inbox (and spam folder) for further instructions.`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{success ? (
|
||||
<Flex bg="background" p={2} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<CheckCircle size={20} color="primary" />
|
||||
<Text variant="body" color="primary" ml={2}>
|
||||
{success}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<AuthField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete={"email"}
|
||||
label="Enter your account email"
|
||||
helpText="You will receive instructions on how to recover your account on this email"
|
||||
defaultValue={formData ? formData.email : ""}
|
||||
autoFocus
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Send recovery email"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
function getTexts(formData: MFAFormData) {
|
||||
return {
|
||||
app: {
|
||||
subtitle:
|
||||
"Please confirm your identity by entering the authentication code from your authenticator app.",
|
||||
instructions: `Open the two-factor authentication (TOTP) app to view your authentication code.`,
|
||||
selector: `Don't have access to your authenticator app?`,
|
||||
label: "Enter 6-digit code",
|
||||
},
|
||||
email: {
|
||||
subtitle:
|
||||
"Please confirm your identity by entering the authentication code sent to your email address.",
|
||||
instructions: `It may take a minute to receive your code.`,
|
||||
selector: `Don't have access to your email address?`,
|
||||
label: "Enter 6-digit code",
|
||||
},
|
||||
sms: {
|
||||
subtitle: `Please confirm your identity by entering the authentication code sent to ${
|
||||
formData.phoneNumber || "your registered phone number."
|
||||
}.`,
|
||||
instructions: `It may take a minute to receive your code.`,
|
||||
selector: `Don't have access to your phone number?`,
|
||||
label: "Enter 6-digit code",
|
||||
},
|
||||
recoveryCode: {
|
||||
subtitle: `Please confirm your identity by entering a recovery code.`,
|
||||
instructions: "",
|
||||
selector: `Don't have your recovery codes?`,
|
||||
label: "Enter recovery code",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function MFACode(props: BaseAuthComponentProps<"mfa:code">) {
|
||||
const { navigate, formData } = props;
|
||||
const [isAppLoaded] = useDatabase();
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const { elapsed, enabled, setEnabled } = useTimer(
|
||||
`2fa.${formData?.primaryMethod}`,
|
||||
60
|
||||
);
|
||||
|
||||
if (!formData) {
|
||||
openURL("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { selectedMethod, token } = formData;
|
||||
const texts = getTexts(formData)[selectedMethod];
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="mfa:code"
|
||||
title="Two-factor authentication"
|
||||
subtitle={texts.subtitle}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const loginForm: MFALoginFormData = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
code: form.code,
|
||||
method: formData.selectedMethod,
|
||||
};
|
||||
await login(loginForm, navigate);
|
||||
}}
|
||||
>
|
||||
<AuthField
|
||||
id="code"
|
||||
type="number"
|
||||
autoComplete={"one-time-code"}
|
||||
label={texts.label}
|
||||
autoFocus
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
helpText={texts.instructions}
|
||||
action={
|
||||
selectedMethod === "sms" || selectedMethod === "email"
|
||||
? {
|
||||
disabled: isSending || !enabled,
|
||||
component: (
|
||||
<Text variant={"body"}>
|
||||
{isSending ? (
|
||||
<Loading size={18} />
|
||||
) : enabled ? (
|
||||
`Send code`
|
||||
) : (
|
||||
`Resend (${elapsed})`
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
onClick: async () => {
|
||||
setIsSending(true);
|
||||
try {
|
||||
await db.mfa!.sendCode(selectedMethod, token);
|
||||
setEnabled(false);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(error);
|
||||
showToast("error", error.message);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Submit"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
mt={4}
|
||||
variant={"anchor"}
|
||||
color="text"
|
||||
onClick={() => navigate("mfa:select", formData)}
|
||||
>
|
||||
{texts.selector}
|
||||
</Button>
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
type MFAMethodType = AuthenticatorType | "recoveryCode";
|
||||
type MFAMethod = {
|
||||
type: MFAMethodType;
|
||||
title: string;
|
||||
icon: (props: any) => JSX.Element;
|
||||
};
|
||||
const MFAMethods: MFAMethod[] = [
|
||||
{ type: "app", title: "Use an authenticator app", icon: MFAAuthenticator },
|
||||
{ type: "sms", title: "Send code to your phone number", icon: MFASMS },
|
||||
{ type: "email", title: "Send code to your email address", icon: MFAEmail },
|
||||
{ type: "recoveryCode", title: "Use a recovery code", icon: MFARecoveryCode },
|
||||
];
|
||||
function MFASelector(props: BaseAuthComponentProps<"mfa:select">) {
|
||||
const { navigate, formData } = props;
|
||||
const [selected, setSelected] = useState(0);
|
||||
const isValidMethod = useCallback(
|
||||
(method: MFAMethodType) => {
|
||||
return (
|
||||
method === formData?.primaryMethod ||
|
||||
method === formData?.secondaryMethod ||
|
||||
method === "recoveryCode"
|
||||
);
|
||||
},
|
||||
[formData]
|
||||
);
|
||||
if (!formData) {
|
||||
openURL("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
type="mfa:select"
|
||||
title="Select two-factor authentication method"
|
||||
subtitle={`Where should we send you the authentication code?`}
|
||||
loading={{
|
||||
title: "Logging you in",
|
||||
subtitle: "Please wait while you are authenticated.",
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const selectedType = MFAMethods[selected];
|
||||
formData.selectedMethod = selectedType.type;
|
||||
navigate("mfa:code", formData);
|
||||
}}
|
||||
>
|
||||
{MFAMethods.map(
|
||||
(method, index) =>
|
||||
isValidMethod(method.type) && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"secondary"}
|
||||
mt={2}
|
||||
sx={{
|
||||
":first-of-type": { mt: 2 },
|
||||
display: "flex",
|
||||
bg: "bgSecondary",
|
||||
alignSelf: "stretch",
|
||||
alignItems: "center",
|
||||
textAlign: "left",
|
||||
px: 2,
|
||||
}}
|
||||
onClick={() => setSelected(index)}
|
||||
>
|
||||
<method.icon
|
||||
sx={{
|
||||
bg: selected === index ? "shade" : "border",
|
||||
borderRadius: 100,
|
||||
width: 35,
|
||||
height: 35,
|
||||
mr: 2,
|
||||
}}
|
||||
size={16}
|
||||
color={selected === index ? "primary" : "text"}
|
||||
/>
|
||||
<Text variant={"title"} fontWeight="body">
|
||||
{method.title}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{/* <SubmitButton
|
||||
text="Submit"
|
||||
disabled={!isAppLoaded}
|
||||
loading={!isAppLoaded}
|
||||
/> */}
|
||||
{/* <Button type="button" mt={4} variant={"anchor"} color="text">
|
||||
Don't have access to your {mfaMethodToPhrase(formData.primaryMethod)}?
|
||||
</Button> */}
|
||||
</AuthForm>
|
||||
);
|
||||
}
|
||||
|
||||
// function MFAMethodSelector(params) {}
|
||||
|
||||
type AuthFormProps<TType extends AuthRoutes> = {
|
||||
title: string;
|
||||
subtitle: string | JSX.Element;
|
||||
loading: { title: string; subtitle: string };
|
||||
type: TType;
|
||||
onSubmit: (form: AuthFormData[TType]) => Promise<void>;
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((form?: AuthFormData[TType]) => React.ReactNode);
|
||||
};
|
||||
|
||||
function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
|
||||
const { title, subtitle, children } = props;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const formRef = useRef<HTMLFormElement>();
|
||||
const [form, setForm] = useState<AuthFormData[T] | undefined>();
|
||||
|
||||
if (isSubmitting)
|
||||
return <Loader title={props.loading.title} text={props.loading.subtitle} />;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={formRef}
|
||||
as="form"
|
||||
id="authForm"
|
||||
flexDirection="column"
|
||||
alignSelf="center"
|
||||
justifyContent={"center"}
|
||||
alignItems="center"
|
||||
width={["95%", 420]}
|
||||
flex={1}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData(formRef.current);
|
||||
const form = Object.fromEntries(formData.entries()) as AuthFormData[T];
|
||||
try {
|
||||
setForm(form);
|
||||
await props.onSubmit(form);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text variant={"heading"} fontSize={32} textAlign="center">
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
fontSize={"title"}
|
||||
textAlign="center"
|
||||
mt={2}
|
||||
mb={35}
|
||||
color="fontTertiary"
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{typeof children === "function" ? children(form) : children}
|
||||
{error && (
|
||||
<Flex bg="errorBg" p={1} mt={2} sx={{ borderRadius: "default" }}>
|
||||
<ErrorIcon size={15} color="error" />
|
||||
<Text variant="error" ml={1}>
|
||||
{error}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
type SubtitleWithActionProps = {
|
||||
text: string;
|
||||
action: {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
function SubtitleWithAction(props: SubtitleWithActionProps) {
|
||||
return (
|
||||
<>
|
||||
{props.text}{" "}
|
||||
<Text
|
||||
sx={{
|
||||
textDecoration: "underline",
|
||||
":hover": { color: "dimPrimary" },
|
||||
cursor: "pointer",
|
||||
}}
|
||||
as="b"
|
||||
color="text"
|
||||
onClick={props.action.onClick}
|
||||
>
|
||||
{props.action.text}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type AuthFieldProps = {
|
||||
id: string;
|
||||
type: string;
|
||||
autoFocus?: boolean;
|
||||
autoComplete: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
defaultValue?: string;
|
||||
disabled?: boolean;
|
||||
inputMode?: string;
|
||||
pattern?: string;
|
||||
action?: {
|
||||
disabled?: boolean;
|
||||
component?: JSX.Element;
|
||||
onClick?: () => void | Promise<void>;
|
||||
};
|
||||
};
|
||||
function AuthField(props: AuthFieldProps) {
|
||||
return (
|
||||
<Field
|
||||
type={props.type}
|
||||
id={props.id}
|
||||
name={props.id}
|
||||
data-test-id={props.id}
|
||||
autoComplete={props.autoComplete}
|
||||
label={props.label}
|
||||
autoFocus={props.autoFocus}
|
||||
defaultValue={props.defaultValue}
|
||||
helpText={props.helpText}
|
||||
disabled={props.disabled}
|
||||
pattern={props.pattern}
|
||||
inputMode={props.inputMode}
|
||||
placeholder={props.placeholder}
|
||||
required
|
||||
action={props.action}
|
||||
styles={{
|
||||
container: { mt: 2, width: "100%" },
|
||||
// label: { fontWeight: "normal" },
|
||||
input: {
|
||||
p: "12px",
|
||||
borderRadius: "default",
|
||||
bg: "background",
|
||||
boxShadow: "0px 0px 5px 0px #00000019",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SubmitButtonProps = {
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
function SubmitButton(props: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
data-test-id="submitButton"
|
||||
display="flex"
|
||||
type="submit"
|
||||
mt={50}
|
||||
variant="primary"
|
||||
alignSelf={"center"}
|
||||
px={50}
|
||||
sx={{ borderRadius: 50 }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.loading ? <Loading color="static" /> : props.text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
async function login(
|
||||
form: LoginFormData | MFALoginFormData,
|
||||
navigate: NavigateFunction
|
||||
) {
|
||||
try {
|
||||
await userstore.login(form);
|
||||
Config.set("sessionExpired", false);
|
||||
openURL("/");
|
||||
} catch (e) {
|
||||
if (e instanceof RequestError && e.code === "mfa_required") {
|
||||
const { primaryMethod, phoneNumber, secondaryMethod, token } =
|
||||
e.data as MFAErrorData;
|
||||
|
||||
if (!primaryMethod)
|
||||
throw new Error(
|
||||
"Multi-factor is required but the server didn't send a primary MFA method."
|
||||
);
|
||||
|
||||
navigate("mfa:code", {
|
||||
...form,
|
||||
token,
|
||||
selectedMethod: primaryMethod,
|
||||
primaryMethod,
|
||||
phoneNumber,
|
||||
secondaryMethod,
|
||||
});
|
||||
} else throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function openURL(url: string, force?: boolean) {
|
||||
const queryParams = getQueryParams();
|
||||
const redirect = queryParams?.redirect;
|
||||
Config.set("skipInitiation", true);
|
||||
hardNavigate(force ? url : redirect || url);
|
||||
}
|
||||
|
||||
function maskEmail(email: string) {
|
||||
if (!email) return "";
|
||||
const [username, domain] = email.split("@");
|
||||
const maskChars = "*".repeat(
|
||||
username.substring(2, username.length - 2).length
|
||||
);
|
||||
return `${username.substring(0, 2)}${maskChars}${username.substring(
|
||||
username.length - 2
|
||||
)}@${domain}`;
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
showLoadingDialog,
|
||||
showBuyDialog,
|
||||
showPasswordDialog,
|
||||
showMultifactorDialog,
|
||||
showAttachmentsDialog,
|
||||
show2FARecoveryCodesDialog,
|
||||
} from "../common/dialog-controller";
|
||||
import { SUBSCRIPTION_STATUS } from "../common/constants";
|
||||
import { createBackup, verifyAccount } from "../common";
|
||||
@@ -129,6 +131,7 @@ const otherItems = [
|
||||
function Settings(props) {
|
||||
const [groups, setGroups] = useState({
|
||||
appearance: false,
|
||||
mfa: false,
|
||||
backup: false,
|
||||
importer: false,
|
||||
privacy: false,
|
||||
@@ -153,6 +156,7 @@ function Settings(props) {
|
||||
(store) => store.toggleEncryptBackups
|
||||
);
|
||||
const user = useUserStore((store) => store.user);
|
||||
const refreshUser = useUserStore((store) => store.refreshUser);
|
||||
const isLoggedIn = useUserStore((store) => store.isLoggedIn);
|
||||
const [backupReminderOffset, setBackupReminderOffset] = usePersistentState(
|
||||
"backupReminderOffset",
|
||||
@@ -284,6 +288,93 @@ function Settings(props) {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
<Header
|
||||
title="2-factor authentication"
|
||||
isOpen={groups.mfa}
|
||||
onClick={() => {
|
||||
setGroups((g) => ({ ...g, mfa: !g.mfa }));
|
||||
}}
|
||||
/>
|
||||
{groups.mfa &&
|
||||
(user.mfa.isEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await showMultifactorDialog(user.mfa.primaryMethod);
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text={
|
||||
user.mfa.secondaryMethod
|
||||
? "Reconfigure fallback 2FA method"
|
||||
: "Add fallback 2FA method"
|
||||
}
|
||||
tip="You can use the fallback 2FA method if you cannot login via the primary method."
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await show2FARecoveryCodesDialog(
|
||||
user.mfa.primaryMethod
|
||||
);
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text="View recovery codes"
|
||||
tip={`Recovery codes can be used to login in case you cannot use any of the other 2FA methods. You have ${user.mfa.remainingValidCodes} recovery codes left.`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await db.mfa.disable();
|
||||
showToast(
|
||||
"success",
|
||||
"2-factor authentication disabled."
|
||||
);
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text="Disable 2-factor authentication"
|
||||
tip="You can disable 2FA if you want to reset or change 2FA settings."
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="list"
|
||||
onClick={async () => {
|
||||
if (await verifyAccount()) {
|
||||
await showMultifactorDialog();
|
||||
await refreshUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text="Enable 2-factor authentication"
|
||||
tip="Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to sign in."
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Header
|
||||
title="Appearance"
|
||||
isOpen={groups.appearance}
|
||||
@@ -364,6 +455,7 @@ function Settings(props) {
|
||||
setGroups((g) => ({ ...g, backup: !g.backup }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{groups.backup && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"downlevelIteration": true,
|
||||
"maxNodeModuleJsDepth": 1,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
Reference in New Issue
Block a user