diff --git a/apps/web/package.json b/apps/web/package.json
index c56ecb753..20f03fe2d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/assets/fallback2fa.svg b/apps/web/src/assets/fallback2fa.svg
new file mode 100644
index 000000000..906092ea5
--- /dev/null
+++ b/apps/web/src/assets/fallback2fa.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/apps/web/src/assets/mfa.svg b/apps/web/src/assets/mfa.svg
new file mode 100644
index 000000000..8df864e0c
--- /dev/null
+++ b/apps/web/src/assets/mfa.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/apps/web/src/common/dialogcontroller.js b/apps/web/src/common/dialogcontroller.js
index 7558cc885..e19b83478 100644
--- a/apps/web/src/common/dialogcontroller.js
+++ b/apps/web/src/common/dialogcontroller.js
@@ -607,6 +607,24 @@ export function showImportDialog() {
));
}
+export function showMultifactorDialog(primaryMethod = undefined) {
+ return showDialog((Dialogs, perform) => (
+ perform(res)}
+ primaryMethod={primaryMethod}
+ />
+ ));
+}
+
+export function show2FARecoveryCodesDialog(primaryMethod) {
+ return showDialog((Dialogs, perform) => (
+ perform(res)}
+ primaryMethod={primaryMethod}
+ />
+ ));
+}
+
export function showAttachmentsDialog() {
return showDialog((Dialogs, perform) => (
perform(res)} />
diff --git a/apps/web/src/common/export.ts b/apps/web/src/common/export.ts
index a2add2cb7..bc1d8b318 100644
--- a/apps/web/src/common/export.ts
+++ b/apps/web/src/common/export.ts
@@ -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 {
+export async function exportToPDF(
+ title: string,
+ content: string
+): Promise {
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: 'My custom header
',
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 = [];
diff --git a/apps/web/src/components/dialogs/index.js b/apps/web/src/components/dialogs/index.js
index fdd593256..fae02e01e 100644
--- a/apps/web/src/components/dialogs/index.js
+++ b/apps/web/src/components/dialogs/index.js
@@ -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,
diff --git a/apps/web/src/components/dialogs/multifactordialog.tsx b/apps/web/src/components/dialogs/multifactordialog.tsx
new file mode 100644
index 000000000..9c328e463
--- /dev/null
+++ b/apps/web/src/components/dialogs/multifactordialog.tsx
@@ -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;
+ recommended?: boolean;
+};
+
+type StepComponentProps = {
+ onNext: (...args: any[]) => void;
+ onClose?: () => void;
+ onError?: (error: string) => void;
+};
+
+type StepComponent = React.FunctionComponent;
+
+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 }) => (
+
+ ),
+ next: "setup",
+ cancellable: true,
+ }),
+ setup: (authenticator: Authenticator): Step => ({
+ title: authenticator.title,
+ description: authenticator.subtitle,
+ next: "recoveryCodes",
+ component: ({ onNext }) => (
+
+ ),
+ }),
+ 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 }) => (
+ i !== primaryMethod
+ )}
+ />
+ ),
+ next: "setup",
+ cancellable: true,
+ }),
+ setup: (authenticator: Authenticator): FallbackStep => ({
+ title: authenticator.title,
+ description: authenticator.subtitle,
+ next: "finish",
+ cancellable: true,
+ component: ({ onNext }) => (
+
+ ),
+ }),
+ finish: (
+ fallbackMethod: AuthenticatorType,
+ primaryMethod: AuthenticatorType
+ ): FallbackStep => ({
+ component: ({ onNext, onClose }) => (
+
+ ),
+ }),
+} as const;
+
+export function MultifactorDialog(props: MultifactorDialogProps) {
+ const { onClose, primaryMethod } = props;
+ const [step, setStep] = useState(
+ primaryMethod ? fallbackSteps.choose(primaryMethod) : steps.choose()
+ );
+ const [error, setError] = useState();
+
+ return (
+
+ );
+}
+
+export function RecoveryCodesDialog(props: RecoveryCodesDialogProps) {
+ const { onClose, primaryMethod } = props;
+ const [error, setError] = useState();
+ const step = steps.recoveryCodes(primaryMethod);
+
+ return (
+
+ );
+}
+
+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 (
+ {
+ e.preventDefault();
+ const authenticator = filteredAuthenticators[selected];
+ onNext(authenticator);
+ }}
+ >
+ {filteredAuthenticators.map((auth, index) => (
+
+ ))}
+
+ );
+}
+
+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" ? (
+
+ ) : authenticator === "email" ? (
+
+ ) : authenticator === "sms" ? (
+
+ ) : 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 (
+
+
+ Scan the QR code below with your authenticator app.
+
+
+ {authenticatorDetails.authenticatorUri ? (
+ }>
+
+
+ ) : (
+
+ )}
+
+
+ If you can't scan the QR code above, enter this text instead (spaces
+ don't matter):
+
+
+ {authenticatorDetails.sharedKey ? (
+ authenticatorDetails.sharedKey
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function SetupEmail(props: SetupAuthenticatorProps) {
+ const { onSubmitCode } = props;
+ const [isSending, setIsSending] = useState(false);
+ const [error, setError] = useState();
+ const { elapsed, enabled, setEnabled } = useTimer(`2fa.email`, 60);
+ const [email, setEmail] = useState();
+
+ useEffect(() => {
+ (async () => {
+ const { email } = await db.user!.getUser();
+ setEmail(email);
+ })();
+ }, []);
+
+ return (
+
+
+
+ {email}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+function SetupSMS(props: SetupAuthenticatorProps) {
+ const { onSubmitCode } = props;
+ const [isSending, setIsSending] = useState(false);
+ const [error, setError] = useState();
+ const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60);
+ const inputRef = useRef();
+
+ return (
+
+
+ {isSending ? (
+
+ ) : enabled ? (
+ `Send code`
+ ) : (
+ `Resend (${elapsed})`
+ )}
+
+ ),
+ 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 ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+function BackupRecoveryCodes(props: StepComponentProps) {
+ const { onNext, onError } = props;
+ const [codes, setCodes] = useState([]);
+ const recoveryCodesRef = useRef();
+ 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 (
+ {
+ e.preventDefault();
+ onNext();
+ }}
+ >
+
+ {codes.map((code) => (
+
+ {code}
+
+ ))}
+
+
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+}
+
+function TwoFactorEnabled(props: StepComponentProps) {
+ return (
+
+
+
+ Two-factor authentication enabled!
+
+
+ Your account is now 100% secure against unauthorized logins.
+
+
+
+ );
+}
+
+type Fallback2FAEnabledProps = StepComponentProps & {
+ fallbackMethod: AuthenticatorType;
+ primaryMethod: AuthenticatorType;
+};
+function Fallback2FAEnabled(props: Fallback2FAEnabledProps) {
+ const { fallbackMethod, primaryMethod, onClose } = props;
+ return (
+
+
+
+ Fallback 2FA method enabled!
+
+
+ You will now receive your 2FA codes on your{" "}
+ {mfaMethodToPhrase(fallbackMethod)} in case you lose access to your{" "}
+ {mfaMethodToPhrase(primaryMethod)}.
+
+
+
+ );
+}
+
+function VerifyAuthenticatorForm(props: VerifyAuthenticatorFormProps) {
+ const { codeHelpText, onSubmitCode, children } = props;
+ const formRef = useRef();
+ return (
+ {
+ e.preventDefault();
+ const form = new FormData(formRef.current);
+ const code = form.get("code");
+ if (!code || code.toString().length !== 6) return;
+ onSubmitCode(code.toString());
+ }}
+ >
+ {children}
+
+
+ );
+}
+
+export function mfaMethodToPhrase(method: AuthenticatorType): string {
+ return method === "email"
+ ? "email"
+ : method === "app"
+ ? "authentication app"
+ : "phone number";
+}
diff --git a/apps/web/src/components/field/index.js b/apps/web/src/components/field/index.js
index 8f64f1a2c..5bbc44513 100644
--- a/apps/web/src/components/field/index.js
+++ b/apps/web/src/components/field/index.js
@@ -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) {
)}
{action && (
-
-
-
+ {action.component ? action.component : }
+
)}
{validatePassword && (
diff --git a/apps/web/src/components/icons/index.js b/apps/web/src/components/icons/index.js
index b7b86ff37..9b93262ec 100644
--- a/apps/web/src/components/icons/index.js
+++ b/apps/web/src/components/icons/index.js
@@ -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) {
();
+
+ 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 };
+}
diff --git a/apps/web/src/index.js b/apps/web/src/index.js
index b91b51cf1..faaa9ea6b 100644
--- a/apps/web/src/index.js
+++ b/apps/web/src/index.js
@@ -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: {} },
};
diff --git a/apps/web/src/navigation/index.js b/apps/web/src/navigation/index.js
index 11f39971c..92969c721 100644
--- a/apps/web/src/navigation/index.js
+++ b/apps/web/src/navigation/index.js
@@ -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;
}
diff --git a/apps/web/src/stores/user-store.js b/apps/web/src/stores/user-store.js
index 2c316f7c7..c679ed9ca 100644
--- a/apps/web/src/stores/user-store.js
+++ b/apps/web/src/stores/user-store.js
@@ -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) => {
diff --git a/apps/web/src/views/auth.js b/apps/web/src/views/auth.js
deleted file mode 100644
index ad5a0e981..000000000
--- a/apps/web/src/views/auth.js
+++ /dev/null
@@ -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: (
-
-
-
- All your local changes are safe and will be synced after you
- relogin.
- {" "}
- Please enter your password to continue.
-
-
- ),
- },
- 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: Logout permanently,
- 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: ,
- 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{" "}
-
- Terms of Service
- {" "}
- &{" "}
-
- Privacy Policy
-
- .
- >
- ),
- 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 (
-
- {isSubmitting ? (
- <>
-
- >
- ) : (
-
- {data.secondaryAction ? (
- <>
-
- >
- ) : (
-
- )}
-
- {
- 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);
- }
- );
- }}
- >
-
- {data.title}
-
-
- {data.subtitle.text}{" "}
- {data.subtitle.action && (
-
- {data.subtitle.action.text}
-
- )}
-
- {success && (
-
-
-
- {success}
-
-
- )}
- {data.fields?.map(({ defaultValue, id, autoFocus, ...rest }) => (
-
- ))}
- {data.supportsPasswordRecovery && (
-
- )}
-
- {error && (
-
-
-
- {error}
-
-
- )}
-
- {data.footer && (
-
- {data.footer}
-
- )}
-
-
- )}
-
- );
-}
-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}`;
-}
diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx
new file mode 100644
index 000000000..ef20cf443
--- /dev/null
+++ b/apps/web/src/views/auth.tsx
@@ -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 = (
+ route: TRoute,
+ formData?: AuthFormData[TRoute]
+) => void;
+type BaseAuthComponentProps = {
+ navigate: NavigateFunction;
+ formData?: AuthFormData[TRoute];
+};
+type AuthRoutes =
+ | "sessionExpiry"
+ | "login"
+ | "signup"
+ | "recover"
+ | "mfa:code"
+ | "mfa:select";
+type AuthProps = { route: AuthRoutes };
+
+type AuthComponent = (
+ props: BaseAuthComponentProps
+) => JSX.Element;
+
+function getRouteComponent(
+ route: TRoute
+): AuthComponent | undefined {
+ switch (route) {
+ case "login":
+ return Login as AuthComponent;
+ case "signup":
+ return Signup as AuthComponent;
+ case "sessionExpiry":
+ return SessionExpiry as AuthComponent;
+ case "recover":
+ return AccountRecovery as AuthComponent;
+ case "mfa:code":
+ return MFACode as AuthComponent;
+ case "mfa:select":
+ return MFASelector as AuthComponent;
+ }
+ return undefined;
+}
+
+const routePaths: Record = {
+ 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 (
+
+
+ {Route && (
+ {
+ setStoredFormData(formData);
+ setRoute(route);
+ }}
+ formData={storedFormData}
+ />
+ )}
+
+
+ );
+}
+export default Auth;
+
+function Login(props: BaseAuthComponentProps<"login">) {
+ const { navigate } = props;
+ const [isAppLoaded] = useDatabase();
+
+ return (
+ navigate("signup") }}
+ />
+ }
+ loading={{
+ title: "Logging you in",
+ subtitle: "Please wait while you are authenticated.",
+ }}
+ onSubmit={(form) => login(form, navigate)}
+ >
+ {(form?: LoginFormData) => (
+ <>
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+function Signup(props: BaseAuthComponentProps<"signup">) {
+ const { navigate } = props;
+ const [isAppLoaded] = useDatabase();
+
+ return (
+ 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) => (
+ <>
+
+
+
+
+
+ By pressing "Create account" button, you agree to our{" "}
+
+ Terms of Service
+ {" "}
+ &{" "}
+
+ Privacy Policy
+
+ .
+
+ >
+ )}
+
+ );
+}
+
+function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
+ const { navigate } = props;
+ const [isAppLoaded] = useDatabase();
+ const [user, setUser] = useState();
+
+ 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 (
+
+
+
+ All your local changes are safe and will be synced after you
+ login.
+ {" "}
+ Please enter your password to continue.
+
+
+ }
+ loading={{
+ title: "Logging you in",
+ subtitle: "Please wait while you are authenticated.",
+ }}
+ onSubmit={(form) => login(form, navigate)}
+ >
+
+
+
+
+
+ );
+}
+
+function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
+ const { navigate, formData } = props;
+ const [isAppLoaded] = useDatabase();
+ const [success, setSuccess] = useState();
+
+ return (
+ 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 ? (
+
+
+
+ {success}
+
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+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 (
+ {
+ const loginForm: MFALoginFormData = {
+ email: formData.email,
+ password: formData.password,
+ code: form.code,
+ method: formData.selectedMethod,
+ };
+ await login(loginForm, navigate);
+ }}
+ >
+
+ {isSending ? (
+
+ ) : enabled ? (
+ `Send code`
+ ) : (
+ `Resend (${elapsed})`
+ )}
+
+ ),
+ 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
+ }
+ />
+
+
+
+ );
+}
+
+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 (
+ {
+ const selectedType = MFAMethods[selected];
+ formData.selectedMethod = selectedType.type;
+ navigate("mfa:code", formData);
+ }}
+ >
+ {MFAMethods.map(
+ (method, index) =>
+ isValidMethod(method.type) && (
+
+ )
+ )}
+ {/* */}
+ {/* */}
+
+ );
+}
+
+// function MFAMethodSelector(params) {}
+
+type AuthFormProps = {
+ title: string;
+ subtitle: string | JSX.Element;
+ loading: { title: string; subtitle: string };
+ type: TType;
+ onSubmit: (form: AuthFormData[TType]) => Promise;
+ children?:
+ | React.ReactNode
+ | ((form?: AuthFormData[TType]) => React.ReactNode);
+};
+
+function AuthForm(props: AuthFormProps) {
+ const { title, subtitle, children } = props;
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState();
+ const formRef = useRef();
+ const [form, setForm] = useState();
+
+ if (isSubmitting)
+ return ;
+
+ return (
+ {
+ 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);
+ }
+ }}
+ >
+
+ {title}
+
+
+ {subtitle}
+
+ {typeof children === "function" ? children(form) : children}
+ {error && (
+
+
+
+ {error}
+
+
+ )}
+
+ );
+}
+
+type SubtitleWithActionProps = {
+ text: string;
+ action: {
+ text: string;
+ onClick: () => void;
+ };
+};
+function SubtitleWithAction(props: SubtitleWithActionProps) {
+ return (
+ <>
+ {props.text}{" "}
+
+ {props.action.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;
+ };
+};
+function AuthField(props: AuthFieldProps) {
+ return (
+
+ );
+}
+
+type SubmitButtonProps = {
+ text: string;
+ disabled?: boolean;
+ loading?: boolean;
+};
+function SubmitButton(props: SubmitButtonProps) {
+ return (
+
+ );
+}
+
+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}`;
+}
diff --git a/apps/web/src/views/settings.js b/apps/web/src/views/settings.js
index dc12457f2..04c7f4ae2 100644
--- a/apps/web/src/views/settings.js
+++ b/apps/web/src/views/settings.js
@@ -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) {
>
)}
+
+ {isLoggedIn && (
+ <>
+ {
+ setGroups((g) => ({ ...g, mfa: !g.mfa }));
+ }}
+ />
+ {groups.mfa &&
+ (user.mfa.isEnabled ? (
+ <>
+
+
+
+
+ >
+ ) : (
+ <>
+
+ >
+ ))}
+ >
+ )}
({ ...g, backup: !g.backup }));
}}
/>
+
{groups.backup && (
<>