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:
Abdullah Atta
2022-03-23 09:15:49 +05:00
committed by GitHub
parent 785e79cf4a
commit eed1a8565d
17 changed files with 1909 additions and 540 deletions

View File

@@ -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",

View 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

View 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

View File

@@ -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)} />

View File

@@ -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 = [];

View File

@@ -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,

View 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";
}

View File

@@ -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 && (

View File

@@ -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);

View 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 };
}

View File

@@ -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: {} },
};

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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>{" "}
&amp;{" "}
<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
View 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>{" "}
&amp;{" "}
<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}`;
}

View File

@@ -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

View File

@@ -3,6 +3,8 @@
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"downlevelIteration": true,
"maxNodeModuleJsDepth": 1,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,