diff --git a/apps/web/package.json b/apps/web/package.json
index b3aea54e1..57f2da665 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -35,6 +35,7 @@
"immer": "^9.0.6",
"just-debounce-it": "^3.0.1",
"localforage": "^1.10.0",
+ "localforage-driver-memory": "^1.0.5",
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
"nncryptoworker": "file:packages/nncryptoworker",
"notes-core": "npm:@streetwriters/notesnook-core@latest",
diff --git a/apps/web/src/common/db.js b/apps/web/src/common/db.js
index 5457face5..3af1c4987 100644
--- a/apps/web/src/common/db.js
+++ b/apps/web/src/common/db.js
@@ -1,21 +1,14 @@
import { EventSourcePolyfill as EventSource } from "event-source-polyfill";
-//const EventSource = NativeEventSource || EventSourcePolyfill;
-// OR: may also need to set as global property
-//global.EventSource = NativeEventSource || EventSourcePolyfill;
-global.HTMLParser = new DOMParser().parseFromString(
- "
",
- "text/html"
-);
/**
* @type {import("notes-core/api").default}
*/
var db;
-async function initializeDatabase() {
+async function initializeDatabase(persistence) {
const { default: Database } = await import("notes-core/api");
- const { default: Storage } = await import("../interfaces/storage");
+ const { NNStorage } = await import("../interfaces/storage");
const { default: FS } = await import("../interfaces/fs");
- db = new Database(Storage, EventSource, FS);
+ db = new Database(new NNStorage(persistence), EventSource, FS);
// if (isTesting()) {
// db.host({
diff --git a/apps/web/src/common/index.js b/apps/web/src/common/index.js
index 8fee2f7b5..c5872fa5d 100644
--- a/apps/web/src/common/index.js
+++ b/apps/web/src/common/index.js
@@ -13,6 +13,7 @@ import { store as userstore } from "../stores/user-store";
import FileSaver from "file-saver";
import { showToast } from "../utils/toast";
import { SUBSCRIPTION_STATUS } from "./constants";
+import { showFilePicker } from "../components/editor/plugins/picker";
export const CREATE_BUTTON_MAP = {
notes: {
@@ -98,6 +99,53 @@ export async function createBackup(save = true) {
}
}
+export async function selectBackupFile() {
+ const file = await showFilePicker({
+ acceptedFileTypes: ".nnbackup,application/json",
+ });
+ if (!file) return;
+
+ const reader = new FileReader();
+ const backup = await new Promise((resolve, reject) => {
+ reader.addEventListener("load", (event) => {
+ const text = event.target.result;
+ try {
+ resolve(JSON.parse(text));
+ } catch (e) {
+ alert(
+ "Error: Could not read the backup file provided. Either it's corrupted or invalid."
+ );
+ resolve();
+ }
+ });
+ reader.readAsText(file);
+ });
+ console.log(file, backup);
+ return { file, backup };
+}
+
+export async function importBackup() {
+ const { backup } = await selectBackupFile();
+ await restoreBackupFile(backup);
+}
+
+export async function restoreBackupFile(backup) {
+ console.log("[restore]", backup);
+ if (backup.data.iv && backup.data.salt) {
+ await showPasswordDialog("ask_backup_password", async ({ password }) => {
+ const error = await restore(backup, password);
+ return !error;
+ });
+ } else {
+ await showLoadingDialog({
+ title: "Restoring backup",
+ subtitle:
+ "Please do NOT close your browser or shut down your PC until the process completes.",
+ action: () => restore(backup),
+ });
+ }
+}
+
export function getTotalNotes(notebook) {
return notebook.topics.reduce((sum, topic) => {
return sum + topic.notes.length;
@@ -138,3 +186,13 @@ export async function showUpgradeReminderDialogs() {
await showReminderDialog("trialexpiring");
}
}
+
+async function restore(backup, password) {
+ try {
+ await db.backup.import(backup, password);
+ showToast("success", "Backup restored!");
+ } catch (e) {
+ console.error(e);
+ await showToast("error", `Could not restore the backup: ${e.message || e}`);
+ }
+}
diff --git a/apps/web/src/hooks/use-database.js b/apps/web/src/hooks/use-database.js
index ce25fe01e..da906cc3a 100644
--- a/apps/web/src/hooks/use-database.js
+++ b/apps/web/src/hooks/use-database.js
@@ -4,7 +4,7 @@ import { initializeDatabase } from "../common/db";
const memory = {
isAppLoaded: false,
};
-export default function useDatabase() {
+export default function useDatabase(persistence) {
const [isAppLoaded, setIsAppLoaded] = useState(memory.isAppLoaded);
useEffect(() => {
@@ -12,11 +12,11 @@ export default function useDatabase() {
(async () => {
await import("../app.css");
- await initializeDatabase();
+ await initializeDatabase(persistence);
setIsAppLoaded(true);
memory.isAppLoaded = true;
})();
- }, []);
+ }, [persistence]);
return [isAppLoaded];
}
diff --git a/apps/web/src/index.js b/apps/web/src/index.js
index f77980958..135957d22 100644
--- a/apps/web/src/index.js
+++ b/apps/web/src/index.js
@@ -14,7 +14,7 @@ if (process.env.REACT_APP_PLATFORM === "desktop") require("./commands");
const ROUTES = {
"/account/recovery": {
component: () => import("./views/recovery"),
- props: {},
+ props: { route: "methods" },
},
"/account/verified": {
component: () => import("./views/email-confirmed"),
diff --git a/apps/web/src/interfaces/storage.ts b/apps/web/src/interfaces/storage.ts
index 89538cc97..133dd6198 100644
--- a/apps/web/src/interfaces/storage.ts
+++ b/apps/web/src/interfaces/storage.ts
@@ -1,128 +1,124 @@
import localforage from "localforage";
import { extendPrototype } from "localforage-getitems";
+import * as MemoryDriver from "localforage-driver-memory";
import sort from "fast-sort";
import { getNNCrypto } from "./nncrypto.stub";
import { Cipher, SerializedKey } from "@notesnook/crypto/dist/src/types";
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
+localforage.defineDriver(MemoryDriver);
extendPrototype(localforage);
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
-localforage.config({
- name: "Notesnook",
- driver: [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE],
-});
+export class NNStorage {
+ database: LocalForage;
+ constructor(persistence: "memory" | "db" = "db") {
+ const drivers =
+ persistence === "memory"
+ ? [MemoryDriver._driver]
+ : [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE];
+ this.database = localforage.createInstance({
+ name: "Notesnook",
+ driver: drivers,
+ });
+ }
-function read(key: string): Promise {
- return localforage.getItem(key);
-}
+ read(key: string): Promise {
+ return this.database.getItem(key);
+ }
-function readMulti(keys: string[]) {
- if (keys.length <= 0) return [];
- return localforage.getItems(sort(keys).asc());
-}
+ readMulti(keys: string[]) {
+ if (keys.length <= 0) return [];
+ return this.database.getItems(sort(keys).asc());
+ }
-function write(key: string, data: T) {
- return localforage.setItem(key, data);
-}
+ write(key: string, data: T) {
+ return this.database.setItem(key, data);
+ }
-function remove(key: string) {
- return localforage.removeItem(key);
-}
+ remove(key: string) {
+ return this.database.removeItem(key);
+ }
-function clear() {
- return localforage.clear();
-}
+ clear() {
+ return this.database.clear();
+ }
-function getAllKeys() {
- return localforage.keys();
-}
+ getAllKeys() {
+ return this.database.keys();
+ }
-async function deriveCryptoKey(name: string, credentials: SerializedKey) {
- const { password, salt } = credentials;
- if (!password) throw new Error("Invalid data provided to deriveCryptoKey.");
+ async deriveCryptoKey(name: string, credentials: SerializedKey) {
+ const { password, salt } = credentials;
+ if (!password) throw new Error("Invalid data provided to deriveCryptoKey.");
- const crypto = await getNNCrypto();
- const keyData = await crypto.exportKey(password, salt);
+ const crypto = await getNNCrypto();
+ const keyData = await crypto.exportKey(password, salt);
- if (isIndexedDBSupported() && window?.crypto?.subtle) {
- const pbkdfKey = await derivePBKDF2Key(password);
- await write(name, pbkdfKey);
- const cipheredKey = await aesEncrypt(pbkdfKey, keyData.key!);
- await write(`${name}@_k`, cipheredKey);
- } else {
- await write(`${name}@_k`, keyData.key);
+ if (this.isIndexedDBSupported() && window?.crypto?.subtle) {
+ const pbkdfKey = await derivePBKDF2Key(password);
+ await this.write(name, pbkdfKey);
+ const cipheredKey = await aesEncrypt(pbkdfKey, keyData.key!);
+ await this.write(`${name}@_k`, cipheredKey);
+ } else {
+ await this.write(`${name}@_k`, keyData.key);
+ }
+ }
+
+ async getCryptoKey(name: string): Promise {
+ if (this.isIndexedDBSupported() && window?.crypto?.subtle) {
+ const pbkdfKey = await this.read(name);
+ const cipheredKey = await this.read(`${name}@_k`);
+ if (typeof cipheredKey === "string") return cipheredKey;
+ if (!pbkdfKey || !cipheredKey) return;
+ return await aesDecrypt(pbkdfKey, cipheredKey);
+ } else {
+ const key = await this.read(`${name}@_k`);
+ if (!key) return;
+ return key;
+ }
+ }
+
+ isIndexedDBSupported(): boolean {
+ return this.database.driver() === "asyncStorage";
+ }
+
+ async generateCryptoKey(
+ password: string,
+ salt?: string
+ ): Promise {
+ if (!password)
+ throw new Error("Invalid data provided to generateCryptoKey.");
+ const crypto = await getNNCrypto();
+ return await crypto.exportKey(password, salt);
+ }
+
+ async hash(password: string, email: string): Promise {
+ const crypto = await getNNCrypto();
+ return await crypto.hash(password, `${APP_SALT}${email}`);
+ }
+
+ async encrypt(key: SerializedKey, plainText: string): Promise {
+ const crypto = await getNNCrypto();
+ return await crypto.encrypt(
+ key,
+ { format: "text", data: plainText },
+ "base64"
+ );
+ }
+
+ async decrypt(
+ key: SerializedKey,
+ cipherData: Cipher
+ ): Promise {
+ const crypto = await getNNCrypto();
+ cipherData.format = "base64";
+ const result = await crypto.decrypt(key, cipherData);
+ if (typeof result.data === "string") return result.data;
}
}
-async function getCryptoKey(name: string): Promise {
- if (isIndexedDBSupported() && window?.crypto?.subtle) {
- const pbkdfKey = await read(name);
- const cipheredKey = await read(`${name}@_k`);
- if (typeof cipheredKey === "string") return cipheredKey;
- if (!pbkdfKey || !cipheredKey) return;
- return await aesDecrypt(pbkdfKey, cipheredKey);
- } else {
- const key = await read(`${name}@_k`);
- if (!key) return;
- return key;
- }
-}
-
-function isIndexedDBSupported(): boolean {
- return localforage.driver() === "asyncStorage";
-}
-
-async function generateCryptoKey(
- password: string,
- salt?: string
-): Promise {
- if (!password) throw new Error("Invalid data provided to generateCryptoKey.");
- const crypto = await getNNCrypto();
- return await crypto.exportKey(password, salt);
-}
-
-async function hash(password: string, email: string): Promise {
- const crypto = await getNNCrypto();
- return await crypto.hash(password, `${APP_SALT}${email}`);
-}
-
-async function encrypt(key: SerializedKey, plainText: string): Promise {
- const crypto = await getNNCrypto();
- return await crypto.encrypt(
- key,
- { format: "text", data: plainText },
- "base64"
- );
-}
-
-async function decrypt(
- key: SerializedKey,
- cipherData: Cipher
-): Promise {
- const crypto = await getNNCrypto();
- cipherData.format = "base64";
- const result = await crypto.decrypt(key, cipherData);
- if (typeof result.data === "string") return result.data;
-}
-
-const Storage = {
- read,
- readMulti,
- write,
- remove,
- clear,
- getAllKeys,
- generateCryptoKey,
- deriveCryptoKey,
- getCryptoKey,
- hash,
- encrypt,
- decrypt,
-};
-export default Storage;
-
let enc = new TextEncoder();
let dec = new TextDecoder();
diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx
index 048b20cda..128ec1334 100644
--- a/apps/web/src/views/auth.tsx
+++ b/apps/web/src/views/auth.tsx
@@ -433,7 +433,13 @@ function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
subtitle: "Please wait while we send you recovery instructions.",
}}
onSubmit={async (form) => {
+ if (!form.email) {
+ setSuccess(undefined);
+ return;
+ }
+
const url = await db.user?.recoverAccount(form.email.toLowerCase());
+ console.log(url);
if (isTesting()) return openURL(url);
setSuccess(
`Recovery email sent. Please check your inbox (and spam folder) for further instructions.`
@@ -441,12 +447,19 @@ function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
}}
>
{success ? (
-
-
-
- {success}
-
-
+ <>
+
+
+
+ {success}
+
+
+
+ >
) : (
<>
= {
| ((form?: AuthFormData[TType]) => React.ReactNode);
};
-function AuthForm(props: AuthFormProps) {
+export function AuthForm(props: AuthFormProps) {
const { title, subtitle, children } = props;
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState();
@@ -832,7 +845,7 @@ type AuthFieldProps = {
onClick?: () => void | Promise;
};
};
-function AuthField(props: AuthFieldProps) {
+export function AuthField(props: AuthFieldProps) {
return (
{
- if (!isAppLoaded) return;
- performAction({
- message: "Authenticating. Please wait...",
- error: "Failed to authenticate. Please try again.",
- onError: () => hardNavigate("/"),
- action: authenticateUser,
- });
-
- async function authenticateUser() {
- await db.init();
-
- // if we already have an access token
- const accessToken = await db.user.tokenManager.getAccessToken();
- if (!accessToken) {
- await db.user.tokenManager.getAccessTokenFromAuthorizationCode(
- userId,
- code.replace(/ /gm, "+")
- );
- }
- await db.user.fetchUser(true);
- }
- }, [code, userId, performAction, isAppLoaded]);
-}
-
-const steps = {
- recoveryOptions: RecoveryOptionsStep,
- recoveryKey: RecoveryKeyStep,
- oldPassword: OldPasswordStep,
- backupData: BackupDataStep,
- newPassword: NewPasswordStep,
- final: FinalStep,
-};
-
-function AccountRecovery() {
- const { code, userId, loading, performAction } = useRecovery();
- const [step, setStep] = useState("recoveryOptions");
- const Step = useMemo(() => steps[step], [step]);
- useAuthenticateUser({ code, userId, performAction });
- useEffect(() => {
- if (!code || !userId) return hardNavigate("/");
- }, [code, userId]);
-
- return (
-
-
- {loading.isLoading ? (
-
-
-
- ) : (
- <>
- {
- setStep(next);
- }}
- onRestart={() => {
- setStep(0);
- }}
- />
- >
- )}
-
-
- );
-}
-export default AccountRecovery;
-
-function Step({ testId, heading, children, subtitle }) {
- return (
-
-
-
- {heading}
-
- {subtitle && (
-
- {subtitle}
-
- )}
-
- {children}
-
- );
-}
-
-const recoveryMethods = [
- {
- key: "recoveryKey",
- testId: "step-recovery-key",
- title: "Use recovery key",
- description:
- "Your data recovery key is basically a hashed version of your password (plus some random salt). It can be used to decrypt your data for re-encryption.",
- },
- {
- key: "oldPassword",
- testId: "step-old-password",
- title: "Use your old account password",
- description:
- "In some cases, you cannot login due to case sensitivity issues in your email address. This option can be used to recover your account. (This is very similar to changing your account password but without logging in).",
- },
-];
-function RecoveryOptionsStep({ onFinished }) {
- const isSessionExpired = useIsSessionExpired();
-
- if (isSessionExpired) {
- onFinished("newPassword");
- return null;
- }
-
- return (
-
-
- {recoveryMethods.map((method) => (
-
- ))}
-
-
- );
-}
-
-function RecoveryKeyStep({ performAction, onFinished }) {
- const isSessionExpired = useIsSessionExpired();
-
- if (isSessionExpired) {
- onFinished();
- return null;
- }
-
- return (
- {
- var recoveryKey = formData.get("recovery_key");
- if (recoveryKey) {
- await performAction({
- message: "Downloading your data. This might take a bit.",
- error: "Invalid recovery key.",
- action: async function recoverData() {
- const user = await db.user.getUser();
- await db.storage.write(`_uk_@${user.email}@_k`, recoveryKey);
- await db.sync(true);
- onFinished("backupData");
- },
- });
- }
- }}
- >
-
-
- );
-}
-
-function OldPasswordStep({ performAction, onFinished }) {
- return (
- {
- var oldPassword = formData.get("old_password");
- if (oldPassword) {
- await performAction({
- message: "Downloading your data. This might take a bit.",
- error: "Incorrect old password.",
- action: async function recoverData() {
- const { email, salt } = await db.user.getUser();
- await db.storage.deriveCryptoKey(`_uk_@${email}`, {
- password: oldPassword,
- salt,
- });
- await db.sync(true);
- onFinished("backupData");
- },
- });
- }
- }}
- >
-
-
- );
-}
-
-function RecoveryStep({
- onSubmit,
- children,
- onFinished,
- testId,
- backButtonText,
-}) {
- const isSessionExpired = useIsSessionExpired();
-
- if (isSessionExpired) {
- onFinished();
- return null;
- }
-
- return (
-
- WARNING!
-
- You'll be logged out from all your devices. If you have any unsynced
- data on any device, make sure to sync before continuing.
-
-
- }
- >
- {
- e.preventDefault();
- var formData = new FormData(e.target);
- onSubmit(formData);
- }}
- >
- {children}
-
-
-
-
- );
-}
-
-function BackupDataStep({ performAction, onFinished }) {
- return (
-
-
-
- );
-}
-
-function NewPasswordStep({ performAction, onFinished, onRestart }) {
- const [error, setError] = useState(true);
-
- return (
-
- {
- e.preventDefault();
- var formData = new FormData(e.target);
-
- var newPassword = formData.get("new_password");
- if (error) {
- showToast(
- "error",
- "Your password does not meet all requirements. Please try a different password."
- );
- return;
- }
- await performAction({
- message: "Resetting account password. Please wait...",
- error: "Invalid password.",
- onError: () => {},
- action: async function setNewPassword() {
- if (await db.user.resetPassword(newPassword)) {
- onFinished("final");
- }
- },
- });
- }}
- >
-
-
-
-
- );
-}
-
-function FinalStep() {
- const [key, setKey] = useState();
- const isSessionExpired = useIsSessionExpired();
- const [isReady, setIsReady] = useState(false);
- useEffect(() => {
- (async () => {
- const { key } = await db.user.getEncryptionKey();
- setKey(key);
- if (!isSessionExpired) {
- await db.user.logout(true, "Password changed.");
- await db.user.clearSessions(true);
- setIsReady(true);
- }
- })();
- }, [isSessionExpired]);
-
- if (!isReady && !isSessionExpired)
- return ;
- return (
-
-
- {key}
-
-
-
- );
-}
diff --git a/apps/web/src/views/recovery.tsx b/apps/web/src/views/recovery.tsx
new file mode 100644
index 000000000..51c13dfaa
--- /dev/null
+++ b/apps/web/src/views/recovery.tsx
@@ -0,0 +1,599 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Button, Flex, Text } from "rebass";
+import { Error as ErrorIcon } from "../components/icons";
+import { makeURL, useQueryParams } from "../navigation";
+import { db } from "../common/db";
+import useDatabase from "../hooks/use-database";
+import Loader from "../components/loader";
+import { showToast } from "../utils/toast";
+import AuthContainer from "../components/auth-container";
+import { AuthField, SubmitButton } from "./auth";
+import { createBackup, restoreBackupFile, selectBackupFile } from "../common";
+import { showRecoveryKeyDialog } from "../common/dialog-controller";
+import Config from "../utils/config";
+
+type RecoveryMethodType = "key" | "backup" | "reset";
+type RecoveryMethodsFormData = {};
+
+type RecoveryKeyFormData = {
+ recoveryKey: string;
+};
+
+type BackupFileFormData = {
+ backupFile: {
+ file: File;
+ backup: any;
+ };
+};
+
+type NewPasswordFormData = BackupFileFormData & {
+ userResetRequired?: boolean;
+ password: string;
+ confirmPassword: string;
+};
+
+type RecoveryFormData = {
+ methods: RecoveryMethodsFormData;
+ "method:key": RecoveryKeyFormData;
+ "method:backup": BackupFileFormData;
+ "method:reset": NewPasswordFormData;
+ backup: RecoveryMethodsFormData;
+ new: NewPasswordFormData;
+ final: RecoveryMethodsFormData;
+};
+
+type BaseFormData = RecoveryMethodsFormData;
+
+type NavigateFunction = (
+ route: TRoute,
+ formData?: Partial
+) => void;
+type BaseRecoveryComponentProps = {
+ navigate: NavigateFunction;
+ formData?: Partial;
+};
+type RecoveryRoutes =
+ | "methods"
+ | "method:key"
+ | "method:backup"
+ | "method:reset"
+ | "backup"
+ | "new"
+ | "final";
+type RecoveryProps = { route: RecoveryRoutes };
+
+type RecoveryComponent = (
+ props: BaseRecoveryComponentProps
+) => JSX.Element;
+
+function getRouteComponent(
+ route: TRoute
+): RecoveryComponent | undefined {
+ switch (route) {
+ case "methods":
+ return RecoveryMethods as RecoveryComponent;
+ case "method:key":
+ return RecoveryKeyMethod as RecoveryComponent;
+ case "method:backup":
+ return BackupFileMethod as RecoveryComponent;
+ case "backup":
+ return BackupData as RecoveryComponent;
+ case "method:reset":
+ case "new":
+ return NewPassword as RecoveryComponent;
+ case "final":
+ return Final as RecoveryComponent;
+ }
+ return undefined;
+}
+
+const routePaths: Record = {
+ methods: "/account/recovery/methods",
+ "method:key": "/account/recovery/method/key",
+ "method:backup": "/account/recovery/method/backup",
+ "method:reset": "/account/recovery/method/reset",
+ backup: "/account/recovery/backup",
+ new: "/account/recovery/new",
+ final: "/account/recovery/final",
+};
+
+function useAuthenticateUser({
+ code,
+ userId,
+}: {
+ code: string;
+ userId: string;
+}) {
+ const [isAppLoaded] = useDatabase(isSessionExpired() ? "db" : "memory");
+ const [isAuthenticating, setIsAuthenticating] = useState(true);
+ const [user, setUser] = useState();
+ useEffect(() => {
+ if (!isAppLoaded) return;
+ async function authenticateUser() {
+ setIsAuthenticating(true);
+ try {
+ await db.init();
+
+ const accessToken = await db.user?.tokenManager.getAccessToken();
+ if (!accessToken) {
+ await db.user?.tokenManager.getAccessTokenFromAuthorizationCode(
+ userId,
+ code.replace(/ /gm, "+")
+ );
+ }
+ const user = await db.user?.fetchUser();
+ setUser(user);
+ } catch (e) {
+ showToast("error", "Failed to authenticate. Please try again.");
+ openURL("/");
+ } finally {
+ setIsAuthenticating(false);
+ }
+ }
+
+ authenticateUser();
+ }, [code, userId, isAppLoaded]);
+ return { isAuthenticating, user };
+}
+
+function Recovery(props: RecoveryProps) {
+ const [route, setRoute] = useState(props.route);
+ const [storedFormData, setStoredFormData] = useState<
+ BaseFormData | undefined
+ >();
+
+ const [{ code, userId }] = useQueryParams();
+ const { isAuthenticating, user } = useAuthenticateUser({ code, userId });
+ const Route = useMemo(() => getRouteComponent(route), [route]);
+ useEffect(() => {
+ window.history.replaceState({}, "", makeURL(routePaths[route]));
+ }, [route]);
+
+ return (
+
+
+ {isAuthenticating ? (
+
+ ) : (
+ <>
+
+
+ Authenticated as {user?.email}
+
+
+
+ {Route && (
+ {
+ setStoredFormData(formData);
+ setRoute(route);
+ }}
+ formData={storedFormData}
+ />
+ )}
+ >
+ )}
+
+
+ );
+}
+export default Recovery;
+
+type RecoveryMethod = {
+ type: RecoveryMethodType;
+ title: string;
+ testId: string;
+ description: string;
+ isDangerous?: boolean;
+};
+
+const recoveryMethods: RecoveryMethod[] = [
+ {
+ type: "key",
+ testId: "step-recovery-key",
+ title: "Use recovery key",
+ description:
+ "Your data recovery key is basically a hashed version of your password (plus some random salt). It can be used to decrypt your data for re-encryption.",
+ },
+ {
+ type: "backup",
+ testId: "step-backup",
+ title: "Use a backup file",
+ description:
+ "If you don't have a recovery key, you can recover your data by restoring a Notesnook data backup file (.nnbackup).",
+ },
+ {
+ type: "reset",
+ testId: "step-reset-account",
+ title: "Clear data & reset account",
+ description:
+ "EXTREMELY DANGEROUS! This action is irreversible. All your data including notes, notebooks, attachments & settings will be deleted. This is a full account reset. Proceed with caution.",
+ isDangerous: true,
+ },
+];
+function RecoveryMethods(props: BaseRecoveryComponentProps<"methods">) {
+ const { navigate } = props;
+ const [selected, setSelected] = useState(0);
+
+ if (isSessionExpired()) {
+ navigate("new");
+ return null;
+ }
+
+ return (
+ {
+ const selectedMethod = recoveryMethods[selected].type;
+ navigate(`method:${selectedMethod}`, {
+ userResetRequired: selectedMethod === "reset",
+ });
+ }}
+ >
+ {recoveryMethods.map((method, index) => (
+
+ ))}
+
+ );
+}
+
+function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
+ const { navigate } = props;
+
+ return (
+ {
+ const user = await db.user?.getUser();
+ if (!user) throw new Error("User not authenticated");
+ await db.storage.write(`_uk_@${user.email}@_k`, form.recoveryKey);
+ await db.sync(true, true);
+ navigate("backup");
+ }}
+ >
+
+
+
+
+
+ );
+}
+
+function BackupFileMethod(props: BaseRecoveryComponentProps<"method:backup">) {
+ const { navigate } = props;
+ const [backupFile, setBackupFile] =
+ useState();
+
+ return (
+
+ All the data in your account will be overwritten with the data in the
+ backup file. There is no way to reverse this action.
+
+ }
+ onSubmit={async () => {
+ navigate("new", { backupFile, userResetRequired: true });
+ }}
+ >
+ Browse,
+ onClick: async () => {
+ setBackupFile(await selectBackupFile());
+ },
+ }}
+ />
+
+
+
+
+ );
+}
+
+function BackupData(props: BaseRecoveryComponentProps<"backup">) {
+ const { navigate } = props;
+
+ return (
+ {
+ await createBackup();
+ navigate("new");
+ }}
+ >
+
+
+ );
+}
+
+function NewPassword(props: BaseRecoveryComponentProps<"new">) {
+ const { navigate, formData } = props;
+
+ return (
+ {
+ if (form.password !== form.confirmPassword)
+ throw new Error("Passwords do not match.");
+
+ if (formData?.userResetRequired && !(await db.user?.resetUser()))
+ throw new Error("Failed to reset user.");
+
+ if (!(await db.user?.resetPassword(form.password)))
+ throw new Error("Could not reset account password.");
+
+ if (formData?.backupFile) {
+ await restoreBackupFile(formData?.backupFile.backup);
+ await db.sync(true, true);
+ }
+
+ navigate("final");
+ }}
+ >
+ {(form?: NewPasswordFormData) => (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
+
+function Final(_props: BaseRecoveryComponentProps<"final">) {
+ const [isReady, setIsReady] = useState(false);
+ useEffect(() => {
+ async function finalize() {
+ await showRecoveryKeyDialog();
+ if (!isSessionExpired()) {
+ await db.user?.logout(true, "Password changed.");
+ await db.user?.clearSessions(true);
+ }
+ setIsReady(true);
+ }
+ finalize();
+ }, []);
+
+ if (!isReady && !isSessionExpired)
+ return ;
+
+ return (
+ {
+ openURL(isSessionExpired() ? "/sessionexpired" : "/login");
+ }}
+ >
+
+
+ );
+}
+
+type RecoveryFormProps = {
+ title: string;
+ subtitle: string | JSX.Element;
+ loading?: { title: string; subtitle: string };
+ type: TType;
+ onSubmit: (form: RecoveryFormData[TType]) => Promise;
+ children?:
+ | React.ReactNode
+ | ((form?: RecoveryFormData[TType]) => React.ReactNode);
+};
+
+export function RecoveryForm(
+ props: RecoveryFormProps
+) {
+ const { title, subtitle, children } = props;
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState();
+ const formRef = useRef();
+ const [form, setForm] = useState();
+
+ if (isSubmitting && props.loading)
+ return ;
+
+ return (
+ {
+ e.preventDefault();
+
+ setError("");
+ setIsSubmitting(true);
+ const formData = new FormData(formRef.current);
+ const form = Object.fromEntries(
+ formData.entries()
+ ) as RecoveryFormData[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}
+
+
+ )}
+
+ );
+}
+
+function openURL(url: string) {
+ window.open(url, "_self");
+}
+
+function isSessionExpired() {
+ return Config.get("sessionExpired", false);
+}
diff --git a/apps/web/src/views/settings.js b/apps/web/src/views/settings.js
index baff6f0d5..849d78184 100644
--- a/apps/web/src/views/settings.js
+++ b/apps/web/src/views/settings.js
@@ -24,7 +24,7 @@ import {
show2FARecoveryCodesDialog,
} from "../common/dialog-controller";
import { SUBSCRIPTION_STATUS } from "../common/constants";
-import { createBackup, verifyAccount } from "../common";
+import { createBackup, importBackup, verifyAccount } from "../common";
import { db } from "../common/db";
import { usePersistentState } from "../utils/hooks";
import dayjs from "dayjs";
@@ -45,35 +45,6 @@ import debounce from "just-debounce-it";
import { PATHS } from "@notesnook/desktop/paths";
import { openPath } from "../commands/open";
-function importBackup() {
- return new Promise((resolve, reject) => {
- const importFileElem = document.getElementById("restore-backup");
- importFileElem.click();
- importFileElem.onchange = function () {
- const file = importFileElem.files[0];
- if (!file) return reject("No file selected.");
- if (!file.name.endsWith(".nnbackup")) {
- return reject(
- "The given file does not have .nnbackup extension. Only files with .nnbackup extension are supported."
- );
- }
- const reader = new FileReader();
- reader.addEventListener("load", (event) => {
- const text = event.target.result;
- try {
- resolve(JSON.parse(text));
- } catch (e) {
- alert(
- "Error: Could not read the backup file provided. Either it's corrupted or invalid."
- );
- resolve();
- }
- });
- reader.readAsText(file);
- };
- });
-}
-
function subscriptionStatusToString(user) {
const status = user?.subscription?.type;
@@ -469,52 +440,14 @@ function Settings(props) {
tip="Create a backup file of all your data"
/>
-
+