diff --git a/apps/web/desktop/package.json b/apps/web/desktop/package.json
index 7d35c874c..1f4611946 100644
--- a/apps/web/desktop/package.json
+++ b/apps/web/desktop/package.json
@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
- "version": "1.8.2",
+ "version": "1.8.3",
"private": true,
"main": "./build/electron.js",
"homepage": "https://notesnook.com/",
diff --git a/apps/web/package.json b/apps/web/package.json
index c56ecb753..819d37c6f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,7 +1,7 @@
{
"name": "notesnook",
"description": "Your private note taking space",
- "version": "1.8.2",
+ "version": "1.8.3",
"private": true,
"main": "./src/App.js",
"homepage": "https://notesnook.com/",
@@ -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",
@@ -37,6 +38,7 @@
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
"nncryptoworker": "file:packages/nncryptoworker",
"notes-core": "npm:@streetwriters/notesnook-core@latest",
+ "phone": "^3.1.14",
"platform": "^1.3.6",
"print-js": "^1.6.0",
"qclone": "^1.0.4",
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/db.js b/apps/web/src/common/db.js
index 233ef5e5d..5457face5 100644
--- a/apps/web/src/common/db.js
+++ b/apps/web/src/common/db.js
@@ -18,24 +18,24 @@ async function initializeDatabase() {
db = new Database(Storage, EventSource, FS);
// if (isTesting()) {
- db.host({
- API_HOST: "https://api.notesnook.com",
- AUTH_HOST: "https://auth.streetwriters.co",
- SSE_HOST: "https://events.streetwriters.co",
- });
+ // db.host({
+ // API_HOST: "https://api.notesnook.com",
+ // AUTH_HOST: "https://auth.streetwriters.co",
+ // SSE_HOST: "https://events.streetwriters.co",
+ // });
// } else {
// db.host({
// API_HOST: "http://localhost:5264",
// AUTH_HOST: "http://localhost:8264",
// SSE_HOST: "http://localhost:7264",
// });
- // db.host({
- // API_HOST: "http://192.168.10.29:5264",
- // AUTH_HOST: "http://192.168.10.29:8264",
- // SSE_HOST: "http://192.168.10.29:7264",
- // ISSUES_HOST: "http://192.168.10.29:2624",
- // SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264",
- // });
+ db.host({
+ API_HOST: "http://192.168.10.29:5264",
+ AUTH_HOST: "http://192.168.10.29:8264",
+ SSE_HOST: "http://192.168.10.29:7264",
+ ISSUES_HOST: "http://192.168.10.29:2624",
+ SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264",
+ });
// }
await db.init();
diff --git a/apps/web/src/common/dialogcontroller.js b/apps/web/src/common/dialogcontroller.js
index 7558cc885..fb424ef7d 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 = "") {
+ 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/common/multi-select.ts b/apps/web/src/common/multi-select.ts
index 2447d604c..e737064d5 100644
--- a/apps/web/src/common/multi-select.ts
+++ b/apps/web/src/common/multi-select.ts
@@ -8,12 +8,11 @@ import Vault from "./vault";
import { showItemDeletedToast } from "./toasts";
import { TaskManager } from "./task-manager";
-async function moveNotesToTrash(notes: any[]) {
+async function moveNotesToTrash(notes: any[], confirm = true) {
const item = notes[0];
- const isMultiselect = notes.length > 1;
- if (isMultiselect) {
- if (!(await showMultiDeleteConfirmation(notes.length))) return;
- } else {
+ if (confirm && !(await showMultiDeleteConfirmation(notes.length))) return;
+
+ if (notes.length === 1) {
if (
item.locked &&
!(await Vault.unlockNote(item.id, "unlock_and_delete_note"))
@@ -38,11 +37,7 @@ async function moveNotesToTrash(notes: any[]) {
},
});
- if (isMultiselect) {
- showToast("success", `${items.length} notes moved to trash`);
- } else {
- showItemDeletedToast(item);
- }
+ showToast("success", `${items.length} notes moved to trash`);
}
async function moveNotebooksToTrash(notebooks: any[]) {
diff --git a/apps/web/src/common/reminders.js b/apps/web/src/common/reminders.js
index 37b4f5038..30c93b552 100644
--- a/apps/web/src/common/reminders.js
+++ b/apps/web/src/common/reminders.js
@@ -88,6 +88,7 @@ export const Reminders = {
},
};
+var openedToast = null;
export async function resetReminders() {
const reminders = [];
@@ -102,7 +103,28 @@ export async function resetReminders() {
saveFile(filePath, data);
showToast("success", `Backup saved at ${filePath}.`);
} else if (isUserPremium() && !isTesting()) {
- await showBackupDialog();
+ if (openedToast !== null) return;
+ openedToast = showToast(
+ "success",
+ "Your backup is ready for download.",
+ [
+ {
+ text: "Later",
+ onClick: () => {
+ createBackup(false);
+ openedToast?.hide();
+ openedToast = null;
+ },
+ type: "text",
+ },
+ {
+ text: "Download",
+ onClick: () => createBackup(true),
+ type: "primary",
+ },
+ ],
+ 0
+ );
}
}
if (await shouldAddLoginReminder()) {
diff --git a/apps/web/src/components/dialogs/backupdialog.tsx b/apps/web/src/components/dialogs/backupdialog.tsx
deleted file mode 100644
index dcabccde3..000000000
--- a/apps/web/src/components/dialogs/backupdialog.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Text } from "rebass";
-import { createBackup } from "../../common";
-import Dialog from "./dialog";
-
-type BackupDialogProps = {
- onClose: () => void;
-};
-function BackupDialog(props: BackupDialogProps) {
- return (
-
- );
-}
-
-export default BackupDialog;
diff --git a/apps/web/src/components/dialogs/dialog.js b/apps/web/src/components/dialogs/dialog.js
index 4f420cad8..58fb60538 100644
--- a/apps/web/src/components/dialogs/dialog.js
+++ b/apps/web/src/components/dialogs/dialog.js
@@ -120,16 +120,10 @@ function Dialog(props) {
>
{props.negativeButton && (
{props.negativeButton.text || "Cancel"}
@@ -137,15 +131,9 @@ function Dialog(props) {
{props.positiveButton && (
= {
subtitle: `Welcome to v${appVersion.clean}`,
subFeatures: [
{
- title: "Duplicate notes",
+ title: "Two-factor authentication",
subtitle: (
<>
- Tired of copy-pasting notes? Wish there were templates? Not anymore.{" "}
- on a note to create its
- duplicate.
+ Add an additional layer of security for your notes by enabling 2FA
+ from .
>
),
- icon: Icon.Duplicate,
- },
- {
- title: "Disable sync for specific notes",
- subtitle: (
- <>
- Make any note 100% offline by{" "}
- on a note.
- >
- ),
- icon: Icon.Sync,
- },
- {
- title: "Improved pasting of code",
- subtitle:
- "Pasting code from anywhere (GitHub, VSCode etc.) will now keep the highlighting & formatting intact.",
- icon: Icon.Codeblock,
+ icon: Icon.MFAAuthenticator,
},
],
cta: {
diff --git a/apps/web/src/components/dialogs/index.js b/apps/web/src/components/dialogs/index.js
index fdd593256..2ec07b91d 100644
--- a/apps/web/src/components/dialogs/index.js
+++ b/apps/web/src/components/dialogs/index.js
@@ -14,9 +14,9 @@ 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";
const Dialogs = {
AddNotebookDialog,
@@ -35,8 +35,9 @@ const Dialogs = {
AnnouncementDialog,
IssueDialog,
ImportDialog,
+ MultifactorDialog,
+ RecoveryCodesDialog,
OnboardingDialog,
AttachmentsDialog,
- BackupDialog,
};
export default Dialogs;
diff --git a/apps/web/src/components/dialogs/multifactordialog.tsx b/apps/web/src/components/dialogs/multifactordialog.tsx
new file mode 100644
index 000000000..4c8e64c65
--- /dev/null
+++ b/apps/web/src/components/dialogs/multifactordialog.tsx
@@ -0,0 +1,852 @@
+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";
+import { phone } from "phone";
+import { showMultifactorDialog } from "../../common/dialog-controller";
+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: ({ onNext, onClose, onError }) => (
+
+ ),
+ next: "finish",
+ }),
+ finish: (authenticatorType: AuthenticatorType): Step => ({
+ component: ({ onNext, onClose, onError }) => (
+
+ ),
+ }),
+} 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 [phoneNumber, setPhoneNumber] = useState();
+ const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60);
+ const inputRef = useRef();
+
+ return (
+
+ {
+ const number = inputRef.current?.value;
+ if (!number) return setError("");
+ const validationResult = phone(number);
+
+ if (validationResult.isValid) {
+ setPhoneNumber(validationResult.phoneNumber);
+ setError("");
+ } else {
+ setPhoneNumber("");
+ setError("Please enter a valid phone number with country code.");
+ }
+ }}
+ action={{
+ disabled: error || isSending || !enabled,
+ component: (
+
+ {isSending ? (
+
+ ) : enabled ? (
+ `Send code`
+ ) : (
+ `Resend (${elapsed})`
+ )}
+
+ ),
+ onClick: async () => {
+ if (!phoneNumber) {
+ setError("Please provide a phone number.");
+ return;
+ }
+
+ setIsSending(true);
+ try {
+ await db.mfa!.setup("sms", phoneNumber);
+ setEnabled(false);
+ } catch (e) {
+ const error = e as Error;
+ console.error(error);
+ setError(error.message);
+ } finally {
+ setIsSending(false);
+ }
+ },
+ }}
+ />
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+function BackupRecoveryCodes(props: TwoFactorEnabledProps) {
+ 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(props.authenticatorType);
+ }}
+ >
+
+ {codes.map((code) => (
+
+ {code}
+
+ ))}
+
+
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+}
+
+type TwoFactorEnabledProps = StepComponentProps & {
+ authenticatorType: AuthenticatorType;
+};
+function TwoFactorEnabled(props: TwoFactorEnabledProps) {
+ 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) {
{
+ return () => {
+ selectionStore.toggleSelectionMode(false);
+ };
+ }, []);
+
return (
{!props.items.length && props.placeholder ? (
diff --git a/apps/web/src/components/list-item/index.js b/apps/web/src/components/list-item/index.js
index 3a7c14240..90b468819 100644
--- a/apps/web/src/components/list-item/index.js
+++ b/apps/web/src/components/list-item/index.js
@@ -58,12 +58,6 @@ function ListItem(props) {
const selectItem = useSelectionStore((store) => store.selectItem);
- useEffect(() => {
- return () => {
- selectionStore.toggleSelectionMode(false);
- };
- }, []);
-
return (
{
if (e.key === "Delete") {
- await confirm({
- title: "Delete note?",
- message:
- "This item will be kept in your Trash for 7 days after which it will be permanently removed",
- noText: "No",
- yesText: "Yes",
- yesAction: () => Multiselect.moveNotesToTrash([item]),
- });
+ let selectedItems = selectionStore
+ .get()
+ .selectedItems.filter((i) => i.type === item.type && i !== item);
+ await Multiselect.moveNotesToTrash([item, ...selectedItems]);
}
}}
colors={{
@@ -380,7 +377,7 @@ const menuItems = [
items.length === 1 && db.monographs.isPublished(items[0].id),
disableReason: "Please unpublish this note to move it to trash",
onClick: async ({ items }) => {
- await Multiselect.moveNotesToTrash(items);
+ await Multiselect.moveNotesToTrash(items, items.length > 1);
},
multiSelect: true,
},
diff --git a/apps/web/src/hooks/use-timer.ts b/apps/web/src/hooks/use-timer.ts
new file mode 100644
index 000000000..ee71ce110
--- /dev/null
+++ b/apps/web/src/hooks/use-timer.ts
@@ -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();
+
+ 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..f77980958 100644
--- a/apps/web/src/index.js
+++ b/apps/web/src/index.js
@@ -1,3 +1,4 @@
+import "notes-core/types";
import { EVENTS } from "@notesnook/desktop/events";
import { render } from "react-dom";
import { AppEventManager } from "./common/app-events";
@@ -21,19 +22,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/interfaces/fs.js b/apps/web/src/interfaces/fs.js
index 42c2431e7..08dea456d 100644
--- a/apps/web/src/interfaces/fs.js
+++ b/apps/web/src/interfaces/fs.js
@@ -549,7 +549,7 @@ function parseS3Error(data) {
}
function handleS3Error(e, message) {
- if (axios.isAxiosError(e)) {
+ if (axios.isAxiosError(e) && e.response?.data) {
const error = parseS3Error(e.response.data);
showToast("error", `${message}: [${error.Code}] ${error.Message}`);
} else {
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/editor-store.js b/apps/web/src/stores/editor-store.js
index 864a8192f..9a3713ff1 100644
--- a/apps/web/src/stores/editor-store.js
+++ b/apps/web/src/stores/editor-store.js
@@ -231,7 +231,8 @@ class EditorStore extends BaseStore {
});
noteStore.setSelectedNote(0);
this.toggleProperties(false);
- if (shouldNavigate) hashNavigate(`/`, { replace: true });
+ if (shouldNavigate)
+ hashNavigate(`/notes/create`, { replace: true, addNonce: true });
};
setTitle = (sessionId, title) => {
diff --git a/apps/web/src/stores/note-store.js b/apps/web/src/stores/note-store.js
index 805736c64..314d0f3a7 100644
--- a/apps/web/src/stores/note-store.js
+++ b/apps/web/src/stores/note-store.js
@@ -16,6 +16,7 @@ class NoteStore extends BaseStore {
notes = [];
context = undefined;
selectedNote = 0;
+ nonce = 0;
viewMode = Config.get("notes:viewMode", "detailed");
setViewMode = (viewMode) => {
@@ -43,6 +44,7 @@ class NoteStore extends BaseStore {
db.notes.all,
db.settings.getGroupOptions("home")
);
+ state.nonce = Math.random();
});
this.refreshContext();
};
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/theme/variants/button.js b/apps/web/src/theme/variants/button.js
index 9d7bf1f1b..155c61a06 100644
--- a/apps/web/src/theme/variants/button.js
+++ b/apps/web/src/theme/variants/button.js
@@ -9,7 +9,7 @@ class ButtonFactory {
anchor: new Anchor(),
tool: new Tool(),
icon: new Icon(),
- shade: new Shade(),
+ dialog: new Dialog(),
statusitem: new StatusItem(),
menuitem: new MenuItem(),
};
@@ -37,7 +37,8 @@ class Default {
},
outline: "none",
":focus-visible:not(:active)": {
- boxShadow: "0px 0px 0px 2px var(--text)",
+ filter: "brightness(90%)",
+ bg: "bgSecondary",
},
":disabled": {
opacity: 0.5,
@@ -57,9 +58,24 @@ class Primary {
}
}
-class Shade {
+class Dialog {
constructor() {
- return { variant: "buttons.primary", color: "primary", bg: "shade" };
+ return {
+ variant: "buttons.primary",
+ color: "primary",
+ fontWeight: "bold",
+ bg: "transparent",
+ ":hover": { bg: "bgSecondary" },
+ ":focus:not(:active), :focus-within:not(:active), :focus-visible:not(:active)":
+ {
+ bg: "hover",
+ filter: "brightness(90%)",
+ },
+ ":disabled": {
+ opacity: 0.7,
+ cursor: "not-allowed",
+ },
+ };
}
}
diff --git a/apps/web/src/utils/analytics.js b/apps/web/src/utils/analytics.ts
similarity index 66%
rename from apps/web/src/utils/analytics.js
rename to apps/web/src/utils/analytics.ts
index 29f25f39c..ba0c52a84 100644
--- a/apps/web/src/utils/analytics.js
+++ b/apps/web/src/utils/analytics.ts
@@ -2,6 +2,20 @@ import Config from "./config";
import { getPlatform } from "./platform";
import { appVersion } from "./version";
+declare global {
+ interface Window {
+ umami?: {
+ trackEvent: (
+ value: string,
+ type: string,
+ url?: string,
+ websiteId?: string
+ ) => void;
+ trackView: (url: string, referrer?: string, websiteId?: string) => void;
+ };
+ }
+}
+
export function loadTrackerScript() {
if (Config.get("telemetry") === "false") return;
var script = document.createElement("script");
@@ -18,9 +32,15 @@ export function loadTrackerScript() {
script.onload = function () {
trackVisit();
};
- firstScriptElement.parentNode.insertBefore(script, firstScriptElement);
+ firstScriptElement.parentNode?.insertBefore(script, firstScriptElement);
}
+type TrackerEvent = {
+ name: string;
+ description: string;
+ type?: "event" | "view";
+};
+
export const ANALYTICS_EVENTS = {
version: {
name: "version",
@@ -53,22 +73,30 @@ export const ANALYTICS_EVENTS = {
name: "announcement:cta",
description: "Sent whenever you an announcement CTA is invoked.",
},
-};
+ accountCreated: {
+ name: "/account/created",
+ description: "Sent when you create an account.",
+ type: "view",
+ },
+} as const;
-export function trackEvent(event, eventMessage) {
+export function trackEvent(event: TrackerEvent, eventMessage?: string) {
if (Config.get("telemetry") === "false") return;
- if (window.umami) {
+ if (!window.umami) return;
+ if (event.type === "event" && eventMessage)
window.umami.trackEvent(eventMessage, event.name);
- }
+ else trackVisit(event.name);
}
-export function trackVisit() {
+export function trackVisit(url: string = "/") {
if (Config.get("telemetry") === "false") return;
- if (window.umami) {
- window.umami.trackView("/");
+ const platform = getPlatform();
+ if (!window.umami || !platform) return;
+
+ window.umami.trackView(url);
+ if (url === "/")
trackEvent(
ANALYTICS_EVENTS.version,
- `${appVersion.formatted}-${getPlatform().toLowerCase()}`
+ `${appVersion.formatted}-${platform.toLowerCase()}`
);
- }
}
diff --git a/apps/web/src/utils/toast.js b/apps/web/src/utils/toast.js
deleted file mode 100644
index 6800696aa..000000000
--- a/apps/web/src/utils/toast.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from "react";
-import CogoToast from "cogo-toast";
-import { Button, Flex, Text } from "rebass";
-import ThemeProvider from "../components/theme-provider";
-import * as Icon from "../components/icons";
-import { toTitleCase } from "./string";
-import { store as appstore } from "../stores/app-store";
-/**
- *
- * @returns {import("cogo-toast").CTReturn}
- */
-function showToast(type, message, actions) {
- if (appstore.get().isFocusMode) return null;
- const IconComponent = Icon[toTitleCase(type)];
- const toast = CogoToast[type];
- if (!toast) return;
- return toast(
- ,
- {
- position: "top-right",
- hideAfter: actions ? 5 : type === "error" ? 5 : 3,
- bar: { size: "0px" },
- renderIcon: () => {
- return (
-
-
-
- );
- },
- }
- );
-}
-
-function ToastContainer(props) {
- const { type, message, actions } = props;
- return (
-
-
-
- {message}
-
- {actions?.map((action) => (
-
- ))}
-
-
- );
-}
-
-export { showToast };
diff --git a/apps/web/src/utils/toast.tsx b/apps/web/src/utils/toast.tsx
new file mode 100644
index 000000000..17eaa765c
--- /dev/null
+++ b/apps/web/src/utils/toast.tsx
@@ -0,0 +1,96 @@
+import CogoToast, { CTReturn } from "cogo-toast";
+import { Button, Flex, Text } from "rebass";
+import ThemeProvider from "../components/theme-provider";
+import { Error, Warn, Success } from "../components/icons";
+import { store as appstore } from "../stores/app-store";
+
+type ToastType = "success" | "error" | "warn" | "info";
+type ToastAction = {
+ text: string;
+ onClick: () => void;
+ type: "primary" | "text";
+};
+
+function showToast(
+ type: ToastType,
+ message: string,
+ actions?: ToastAction[],
+ hideAfter?: number
+): CTReturn | null | undefined {
+ if (appstore.get().isFocusMode) return null;
+ const IconComponent =
+ type === "error" ? Error : type === "success" ? Success : Warn;
+ const toast = CogoToast[type];
+ if (!toast) return;
+ const t = toast(, {
+ position: "top-right",
+ hideAfter:
+ hideAfter === undefined
+ ? actions
+ ? 5
+ : type === "error"
+ ? 5
+ : 3
+ : hideAfter,
+ bar: { size: "0px" },
+ renderIcon: () => {
+ return (
+
+
+
+ );
+ },
+ });
+ return t;
+}
+
+type ToastContainerProps = {
+ message: string;
+ actions?: ToastAction[];
+};
+
+function ToastContainer(props: ToastContainerProps) {
+ const { message, actions } = props;
+ return (
+
+
+
+ {message}
+
+ {actions?.map((action) => (
+
+ ))}
+
+
+ );
+}
+
+export { showToast };
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..c602930f8
--- /dev/null
+++ b/apps/web/src/views/auth.tsx
@@ -0,0 +1,938 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Button, Flex, Text } from "rebass";
+import {
+ CheckCircle,
+ Loading,
+ Error as ErrorIcon,
+ MFAAuthenticator,
+ MFASMS,
+ MFAEmail,
+ MFARecoveryCode,
+ ArrowRight,
+ Logout,
+} 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";
+import { ANALYTICS_EVENTS, trackEvent } from "../utils/analytics";
+
+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 === "login" || route === "signup" || route === "recover" ? (
+
+ ) : route === "sessionExpiry" ? (
+ <>
+
+ >
+ ) : null}
+
+ {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);
+ trackEvent(ANALYTICS_EVENTS.accountCreated);
+ 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={async (form) => {
+ if (!user) return;
+ await login({ email: user.email, password: form.password }, 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
+ );
+
+ const sendCode = useCallback(
+ async (selectedMethod, token) => {
+ 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);
+ }
+ },
+ [setEnabled]
+ );
+
+ useEffect(() => {
+ if (
+ !formData ||
+ formData.selectedMethod === "recoveryCode" ||
+ formData.selectedMethod === "app"
+ )
+ return;
+
+ (async function () {
+ await sendCode(formData.selectedMethod, formData.token);
+ })();
+ }, [formData, sendCode]);
+
+ 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 ? (
+ `Resend code`
+ ) : (
+ `Resend in ${elapsed}`
+ )}
+
+ ),
+ onClick: async () => {
+ await sendCode(selectedMethod, token);
+ },
+ }
+ : 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/search.js b/apps/web/src/views/search.js
index 186792d61..29bd254a8 100644
--- a/apps/web/src/views/search.js
+++ b/apps/web/src/views/search.js
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ListContainer from "../components/list-container";
import SearchPlaceholder from "../components/placeholders/search-placeholder";
import { db } from "../common/db";
@@ -40,6 +40,31 @@ function Search({ type }) {
});
const [results, setResults] = useState([]);
const context = useNoteStore((store) => store.context);
+ const nonce = useNoteStore((store) => store.nonce);
+ const cachedQuery = useRef();
+
+ const onSearch = useCallback(
+ async (query) => {
+ if (!query) return;
+ cachedQuery.current = query;
+
+ const [lookupType, items] = await typeToItems(type, context);
+ setResults([]);
+
+ if (items.length <= 0) {
+ showToast("error", `There are no items to search in.`);
+ return;
+ }
+ setSearchState({ isSearching: true, totalItems: items.length });
+ const results = await db.lookup[lookupType](items, query);
+ setResults(results);
+ setSearchState({ isSearching: false, totalItems: 0 });
+ if (!results.length) {
+ showToast("error", `Nothing found for "${query}".`);
+ }
+ },
+ [context, type]
+ );
const title = useMemo(() => {
switch (type) {
@@ -77,6 +102,10 @@ function Search({ type }) {
}
}, [type, context]);
+ useEffect(() => {
+ onSearch(cachedQuery.current);
+ }, [nonce, onSearch]);
+
if (!title) return hardNavigate("/");
return (
@@ -84,25 +113,7 @@ function Search({ type }) {
Searching {title}
- {
- if (!query) return;
- const [lookupType, items] = await typeToItems(type, context);
- setResults([]);
-
- if (items.length <= 0) {
- showToast("error", `There are no items to search in.`);
- return;
- }
- setSearchState({ isSearching: true, totalItems: items.length });
- const results = await db.lookup[lookupType](items, query);
- setResults(results);
- setSearchState({ isSearching: false, totalItems: 0 });
- if (!results.length) {
- showToast("error", `Nothing found for "${query}".`);
- }
- }}
- />
+
{searchState.isSearching ? (
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 && user.mfa && (
+ <>
+ {
+ setGroups((g) => ({ ...g, mfa: !g.mfa }));
+ }}
+ />
+ {groups.mfa &&
+ (user.mfa.isEnabled ? (
+ <>
+
+
+
+
+ >
+ ) : (
+ <>
+
+ >
+ ))}
+ >
+ )}
({ ...g, backup: !g.backup }));
}}
/>
+
{groups.backup && (
<>