feat: improve account recovery

This commit is contained in:
thecodrr
2022-04-01 19:01:06 +05:00
parent 57b6164891
commit 0b2e43d88d
10 changed files with 791 additions and 755 deletions

View File

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

View File

@@ -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(
"<body></body>",
"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({

View File

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

View File

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

View File

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

View File

@@ -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({
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: [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE],
});
driver: drivers,
});
}
function read<T>(key: string): Promise<T | null> {
return localforage.getItem(key);
}
read<T>(key: string): Promise<T | null> {
return this.database.getItem(key);
}
function readMulti(keys: string[]) {
readMulti(keys: string[]) {
if (keys.length <= 0) return [];
return localforage.getItems(sort(keys).asc());
}
return this.database.getItems(sort(keys).asc());
}
function write<T>(key: string, data: T) {
return localforage.setItem(key, data);
}
write<T>(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) {
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);
if (isIndexedDBSupported() && window?.crypto?.subtle) {
if (this.isIndexedDBSupported() && window?.crypto?.subtle) {
const pbkdfKey = await derivePBKDF2Key(password);
await write(name, pbkdfKey);
await this.write(name, pbkdfKey);
const cipheredKey = await aesEncrypt(pbkdfKey, keyData.key!);
await write(`${name}@_k`, cipheredKey);
await this.write(`${name}@_k`, cipheredKey);
} else {
await write(`${name}@_k`, keyData.key);
await this.write(`${name}@_k`, keyData.key);
}
}
}
async function getCryptoKey(name: string): Promise<string | undefined> {
if (isIndexedDBSupported() && window?.crypto?.subtle) {
const pbkdfKey = await read<CryptoKey>(name);
const cipheredKey = await read<EncryptedKey | string>(`${name}@_k`);
async getCryptoKey(name: string): Promise<string | undefined> {
if (this.isIndexedDBSupported() && window?.crypto?.subtle) {
const pbkdfKey = await this.read<CryptoKey>(name);
const cipheredKey = await this.read<EncryptedKey | string>(`${name}@_k`);
if (typeof cipheredKey === "string") return cipheredKey;
if (!pbkdfKey || !cipheredKey) return;
return await aesDecrypt(pbkdfKey, cipheredKey);
} else {
const key = await read<string>(`${name}@_k`);
const key = await this.read<string>(`${name}@_k`);
if (!key) return;
return key;
}
}
}
function isIndexedDBSupported(): boolean {
return localforage.driver() === "asyncStorage";
}
isIndexedDBSupported(): boolean {
return this.database.driver() === "asyncStorage";
}
async function generateCryptoKey(
async generateCryptoKey(
password: string,
salt?: string
): Promise<SerializedKey> {
if (!password) throw new Error("Invalid data provided to generateCryptoKey.");
): Promise<SerializedKey> {
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<string> {
async hash(password: string, email: string): Promise<string> {
const crypto = await getNNCrypto();
return await crypto.hash(password, `${APP_SALT}${email}`);
}
}
async function encrypt(key: SerializedKey, plainText: string): Promise<Cipher> {
async encrypt(key: SerializedKey, plainText: string): Promise<Cipher> {
const crypto = await getNNCrypto();
return await crypto.encrypt(
key,
{ format: "text", data: plainText },
"base64"
);
}
}
async function decrypt(
async decrypt(
key: SerializedKey,
cipherData: Cipher
): Promise<string | undefined> {
): Promise<string | undefined> {
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();

View File

@@ -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 ? (
<>
<Flex bg="background" p={2} mt={2} sx={{ borderRadius: "default" }}>
<CheckCircle size={20} color="primary" />
<Text variant="body" color="primary" ml={2}>
{success}
</Text>
</Flex>
<SubmitButton
text="Send again"
disabled={!isAppLoaded}
loading={!isAppLoaded}
/>
</>
) : (
<>
<AuthField
@@ -722,7 +735,7 @@ type AuthFormProps<TType extends AuthRoutes> = {
| ((form?: AuthFormData[TType]) => React.ReactNode);
};
function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
const { title, subtitle, children } = props;
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>();
@@ -832,7 +845,7 @@ type AuthFieldProps = {
onClick?: () => void | Promise<void>;
};
};
function AuthField(props: AuthFieldProps) {
export function AuthField(props: AuthFieldProps) {
return (
<Field
type={props.type}
@@ -869,7 +882,7 @@ type SubmitButtonProps = {
disabled?: boolean;
loading?: boolean;
};
function SubmitButton(props: SubmitButtonProps) {
export function SubmitButton(props: SubmitButtonProps) {
return (
<Button
data-test-id="submitButton"

View File

@@ -1,557 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Flex, Text } from "rebass";
import { hardNavigate, useQueryParams } from "../navigation";
import Field from "../components/field";
import * as Icon from "../components/icons";
import { db } from "../common/db";
import { showToast } from "../utils/toast";
import { useCallback } from "react";
import { createBackup } from "../common";
import useDatabase from "../hooks/use-database";
import Loader from "../components/loader";
import Config from "../utils/config";
import AuthContainer from "../components/auth-container";
const INPUT_STYLES = {
container: { mt: 2, width: 400 },
label: { fontWeight: "normal" },
input: {
p: "12px",
borderRadius: "default",
bg: "background",
boxShadow: "0px 0px 5px 0px #00000019",
},
};
function useRecovery() {
const [{ code, userId }] = useQueryParams();
const [loading, setLoading] = useState({
isLoading: true,
message: "Authenticating. Please wait...",
});
const performAction = useCallback(async function ({
message,
error,
onError,
action,
}) {
try {
setLoading({ isLoading: true, message });
await action();
} catch (e) {
console.error(e);
showToast("error", `${error} Error: ${e.message || "unknown."}`);
if (onError) await onError(e);
} finally {
setLoading({ isLoading: false });
}
},
[]);
return { code, userId, loading, setLoading, performAction };
}
function useIsSessionExpired() {
const isSessionExpired = Config.get("sessionExpired", false);
return isSessionExpired;
}
function useAuthenticateUser({ code, userId, performAction }) {
const [isAppLoaded] = useDatabase();
useEffect(() => {
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 (
<AuthContainer>
<Flex
flex="1"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{ zIndex: 1 }}
>
{loading.isLoading ? (
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
width="400px"
>
<Loader title={loading.message} />
</Flex>
) : (
<>
<Step
performAction={performAction}
onFinished={(next) => {
setStep(next);
}}
onRestart={() => {
setStep(0);
}}
/>
</>
)}
</Flex>
</AuthContainer>
);
}
export default AccountRecovery;
function Step({ testId, heading, children, subtitle }) {
return (
<Flex
data-test-id={testId}
flexDirection="column"
justifyContent="center"
alignItems="center"
width={400}
// bg="#fff"
// sx={{
// border: "1px solid var(--border)",
// borderRadius: "dialog",
// boxShadow: "0px 0px 60px 10px #00000022",
// p: 30,
// }}
>
<Flex flexDirection={"column"} mb={30}>
<Text variant="heading" fontSize={32} textAlign="center">
{heading}
</Text>
{subtitle && (
<Text
variant="body"
fontSize={"title"}
mt={1}
textAlign="center"
color="fontTertiary"
>
{subtitle}
</Text>
)}
</Flex>
{children}
</Flex>
);
}
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 (
<Step
heading="Choose a recovery option"
testId={"step-recovery-options"}
subtitle={"How do you want to recover your account?"}
>
<Flex flexDirection="column" width="100%">
{recoveryMethods.map((method) => (
<Button
variant={"tool"}
data-test-id={method.testId}
key={method.key}
onClick={() => onFinished(method.key)}
sx={{
mb: 4,
p: "12px",
boxShadow: "0px 0px 10px 0px #00000011",
alignItems: "start",
justifyContent: "start",
textAlign: "start",
bg: "background",
}}
>
<Text variant={"subtitle"}>{method.title}</Text>
<Text variant={"body"} color="fontTertiary">
{method.description}
</Text>
</Button>
))}
</Flex>
</Step>
);
}
function RecoveryKeyStep({ performAction, onFinished }) {
const isSessionExpired = useIsSessionExpired();
if (isSessionExpired) {
onFinished();
return null;
}
return (
<RecoveryStep
testId={"step-recovery-key"}
onFinished={onFinished}
backButtonText="Don't have a recovery key?"
onSubmit={async (formData) => {
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");
},
});
}
}}
>
<Field
data-test-id="recovery_key"
id="recovery_key"
name="recovery_key"
label="Enter your recovery key"
autoFocus
required
helpText="Your data recovery key will be used to decrypt your data"
type="password"
styles={INPUT_STYLES}
/>
</RecoveryStep>
);
}
function OldPasswordStep({ performAction, onFinished }) {
return (
<RecoveryStep
testId={"step-old-password"}
backButtonText="Don't remember old password?"
onFinished={onFinished}
onSubmit={async (formData) => {
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");
},
});
}
}}
>
<Field
data-test-id="old_password"
id="old_password"
name="old_password"
label="Enter your old password"
type="password"
autoFocus
required
helpText="Your old account password will be used to decrypt your data."
styles={INPUT_STYLES}
/>
</RecoveryStep>
);
}
function RecoveryStep({
onSubmit,
children,
onFinished,
testId,
backButtonText,
}) {
const isSessionExpired = useIsSessionExpired();
if (isSessionExpired) {
onFinished();
return null;
}
return (
<Step
heading="Recover your account"
testId={testId}
subtitle={
<Text
color="warn"
bg="background"
p={2}
mt={2}
sx={{ borderRadius: "default" }}
>
<Text fontWeight={"bold"}>WARNING!</Text>
<Text variant={"body"} color="warn">
You'll be logged out from all your devices. If you have any unsynced
data on any device, make sure to sync before continuing.
</Text>
</Text>
}
>
<Flex
flexDirection="column"
as="form"
width="100%"
onSubmit={async (e) => {
e.preventDefault();
var formData = new FormData(e.target);
onSubmit(formData);
}}
>
{children}
<Button
data-test-id="step-prev"
variant={"anchor"}
type={"button"}
alignSelf="end"
color="text"
mt={2}
onClick={() => onFinished("recoveryOptions")}
>
{backButtonText}
</Button>
<Button
data-test-id="step-next"
display="flex"
type="submit"
mt={50}
px={50}
variant="primary"
alignSelf={"center"}
sx={{ borderRadius: 50 }}
justifyContent="center"
alignItems="center"
>
Next
</Button>
</Flex>
</Step>
);
}
function BackupDataStep({ performAction, onFinished }) {
return (
<Step
heading="Backup your data"
subtitle={"Please download a backup of your data before continuing."}
testId={"step-backup-data"}
>
<Button
data-test-id="step-next"
alignSelf="center"
display="flex"
sx={{
justifyContent: "center",
alignItems: "center",
borderRadius: 50,
}}
px={30}
onClick={async () => {
await performAction({
message: "Creating a backup...",
error: "Could not create a backup.",
onError: onFinished,
action: async function downloadBackup() {
await createBackup();
onFinished("newPassword");
},
});
}}
>
<Icon.ArrowDown sx={{ mr: 1 }} color="static" size={16} /> Download
backup
</Button>
</Step>
);
}
function NewPasswordStep({ performAction, onFinished, onRestart }) {
const [error, setError] = useState(true);
return (
<Step
heading="Set new password"
action={{ type: "submit", text: "Next" }}
testId={"step-new-password"}
>
<Flex
flexDirection="column"
as="form"
width="100%"
onSubmit={async (e) => {
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");
}
},
});
}}
>
<Field
data-test-id="new_password"
id="new_password"
name="new_password"
type="password"
label="Enter new password"
autoComplete="new-password"
validatePassword
onError={setError}
autoFocus
required
helpText="This will be your new account password — a strong memorable password is recommended."
styles={INPUT_STYLES}
/>
<Button
data-test-id="step-next"
display="flex"
type="submit"
mt={50}
px={50}
variant="primary"
alignSelf={"center"}
sx={{ borderRadius: 50 }}
justifyContent="center"
alignItems="center"
>
Next
</Button>
</Flex>
</Step>
);
}
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 <Loader title={"Finalizing. Please wait..."} />;
return (
<Step
heading="Account password changed"
subtitle={"Please save your new recovery key in a safe place"}
testId={"step-finished"}
>
<Text
bg="background"
p={2}
fontFamily="monospace"
fontSize="body"
sx={{ borderRadius: "default", overflowWrap: "anywhere" }}
data-test-id="new-recovery-key"
>
{key}
</Text>
<Button
data-test-id="step-finish"
variant="secondary"
onClick={() =>
(window.location.href = isSessionExpired
? "/sessionexpired"
: "/login")
}
display="flex"
type="submit"
mt={50}
px={50}
alignSelf={"center"}
sx={{ borderRadius: 50 }}
justifyContent="center"
alignItems="center"
>
{isSessionExpired ? "Renew session" : "Login to your account"}
</Button>
</Step>
);
}

View File

@@ -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 = <TRoute extends RecoveryRoutes>(
route: TRoute,
formData?: Partial<RecoveryFormData[TRoute]>
) => void;
type BaseRecoveryComponentProps<TRoute extends RecoveryRoutes> = {
navigate: NavigateFunction;
formData?: Partial<RecoveryFormData[TRoute]>;
};
type RecoveryRoutes =
| "methods"
| "method:key"
| "method:backup"
| "method:reset"
| "backup"
| "new"
| "final";
type RecoveryProps = { route: RecoveryRoutes };
type RecoveryComponent<TRoute extends RecoveryRoutes> = (
props: BaseRecoveryComponentProps<TRoute>
) => JSX.Element;
function getRouteComponent<TRoute extends RecoveryRoutes>(
route: TRoute
): RecoveryComponent<TRoute> | undefined {
switch (route) {
case "methods":
return RecoveryMethods as RecoveryComponent<TRoute>;
case "method:key":
return RecoveryKeyMethod as RecoveryComponent<TRoute>;
case "method:backup":
return BackupFileMethod as RecoveryComponent<TRoute>;
case "backup":
return BackupData as RecoveryComponent<TRoute>;
case "method:reset":
case "new":
return NewPassword as RecoveryComponent<TRoute>;
case "final":
return Final as RecoveryComponent<TRoute>;
}
return undefined;
}
const routePaths: Record<RecoveryRoutes, string> = {
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<User>();
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 (
<AuthContainer>
<Flex
flexDirection={"column"}
sx={{
zIndex: 1,
flex: 1,
overflowY: "auto",
}}
>
{isAuthenticating ? (
<Loader
title="Authenticating user"
text={"Please wait while you are authenticated."}
/>
) : (
<>
<Flex justifyContent={"space-between"} alignItems="start" m={2}>
<Text
sx={{
display: "flex",
alignSelf: "end",
alignItems: "center",
}}
variant={"body"}
>
Authenticated as {user?.email}
</Text>
<Button
sx={{
display: "flex",
mt: 0,
ml: 2,
alignSelf: "start",
alignItems: "center",
}}
variant={"secondary"}
onClick={() => openURL("/login")}
>
Remembered your password?
</Button>
</Flex>
{Route && (
<Route
navigate={(route, formData) => {
setStoredFormData(formData);
setRoute(route);
}}
formData={storedFormData}
/>
)}
</>
)}
</Flex>
</AuthContainer>
);
}
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 (
<RecoveryForm
type="methods"
title="Choose a recovery method"
subtitle="How do you want to recover your account?"
onSubmit={async () => {
const selectedMethod = recoveryMethods[selected].type;
navigate(`method:${selectedMethod}`, {
userResetRequired: selectedMethod === "reset",
});
}}
>
{recoveryMethods.map((method, index) => (
<Button
type="submit"
variant={"secondary"}
mt={2}
sx={{
":first-of-type": { mt: 2 },
display: "flex",
flexDirection: "column",
bg: method.isDangerous ? "errorBg" : "bgSecondary",
alignSelf: "stretch",
// alignItems: "center",
textAlign: "left",
px: 2,
}}
onClick={() => setSelected(index)}
>
<Text variant={"title"} color={method.isDangerous ? "error" : "text"}>
{method.title}
</Text>
<Text
variant={"body"}
color={method.isDangerous ? "error" : "fontTertiary"}
>
{method.description}
</Text>
</Button>
))}
</RecoveryForm>
);
}
function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
const { navigate } = props;
return (
<RecoveryForm
type="method:key"
title="Recover your account"
subtitle={"Use a data recovery key to reset your account password."}
loading={{
title: "Downloading your data",
subtitle: "Please wait while your data is downloaded & decrypted.",
}}
onSubmit={async (form) => {
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");
}}
>
<AuthField
id="recoveryKey"
type="password"
label="Enter your data recovery key"
helpText="Your data recovery key will be used to decrypt your data"
autoComplete="none"
autoFocus
/>
<SubmitButton text="Start account recovery" />
<Button
type="button"
mt={4}
variant={"anchor"}
color="text"
onClick={() => navigate("methods")}
>
Don't have your recovery key?
</Button>
</RecoveryForm>
);
}
function BackupFileMethod(props: BaseRecoveryComponentProps<"method:backup">) {
const { navigate } = props;
const [backupFile, setBackupFile] =
useState<BackupFileFormData["backupFile"]>();
return (
<RecoveryForm
type="method:backup"
title="Recover your account"
subtitle={
<Text
variant="body"
bg="background"
p={2}
mt={2}
sx={{ borderRadius: "default" }}
color="error"
ml={2}
>
All the data in your account will be overwritten with the data in the
backup file. There is no way to reverse this action.
</Text>
}
onSubmit={async () => {
navigate("new", { backupFile, userResetRequired: true });
}}
>
<AuthField
id="backupFile"
type="text"
label="Select backup file"
helpText="Backup files have .nnbackup extension"
autoComplete="none"
defaultValue={backupFile?.file?.name}
autoFocus
disabled
action={{
component: <Text variant={"body"}>Browse</Text>,
onClick: async () => {
setBackupFile(await selectBackupFile());
},
}}
/>
<SubmitButton text="Start account recovery" />
<Button
type="button"
mt={4}
variant={"anchor"}
color="text"
onClick={() => navigate("methods")}
>
Don't have a backup file?
</Button>
</RecoveryForm>
);
}
function BackupData(props: BaseRecoveryComponentProps<"backup">) {
const { navigate } = props;
return (
<RecoveryForm
type="backup"
title="Backup your data"
subtitle={
"Please download a backup of your data as your account will be cleared before recovery."
}
loading={{
title: "Creating backup...",
subtitle:
"Please wait while we create a backup file for you to download.",
}}
onSubmit={async () => {
await createBackup();
navigate("new");
}}
>
<SubmitButton text="Download backup file" />
</RecoveryForm>
);
}
function NewPassword(props: BaseRecoveryComponentProps<"new">) {
const { navigate, formData } = props;
return (
<RecoveryForm
type="new"
title="Reset account password"
subtitle={
"Notesnook is E2E encrypted — your password never leaves this device."
}
loading={{
title: "Resetting account password",
subtitle: "Please wait while we reset your account password.",
}}
onSubmit={async (form) => {
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) => (
<>
<AuthField
id="password"
type="password"
autoComplete="current-password"
label="Set new password"
helpText="Your account password must be strong & unique."
defaultValue={form?.password}
/>
<AuthField
id="confirmPassword"
type="password"
autoComplete="confirm-password"
label="Confirm new password"
defaultValue={form?.confirmPassword}
/>
<SubmitButton text="Continue" />
</>
)}
</RecoveryForm>
);
}
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 <Loader text="" title={"Finalizing. Please wait..."} />;
return (
<RecoveryForm
type="final"
title="Recovery successful!"
subtitle={"Your account has been recovered."}
onSubmit={async () => {
openURL(isSessionExpired() ? "/sessionexpired" : "/login");
}}
>
<SubmitButton
text={
isSessionExpired() ? "Continue with login" : "Login to your account"
}
/>
</RecoveryForm>
);
}
type RecoveryFormProps<TType extends RecoveryRoutes> = {
title: string;
subtitle: string | JSX.Element;
loading?: { title: string; subtitle: string };
type: TType;
onSubmit: (form: RecoveryFormData[TType]) => Promise<void>;
children?:
| React.ReactNode
| ((form?: RecoveryFormData[TType]) => React.ReactNode);
};
export function RecoveryForm<T extends RecoveryRoutes>(
props: RecoveryFormProps<T>
) {
const { title, subtitle, children } = props;
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>();
const formRef = useRef<HTMLFormElement>();
const [form, setForm] = useState<RecoveryFormData[T] | undefined>();
if (isSubmitting && props.loading)
return <Loader title={props.loading.title} text={props.loading.subtitle} />;
return (
<Flex
ref={formRef}
as="form"
id="authForm"
flexDirection="column"
alignSelf="center"
justifyContent={"center"}
alignItems="center"
width={["95%", 420]}
flex={1}
onSubmit={async (e) => {
e.preventDefault();
setError("");
setIsSubmitting(true);
const formData = new FormData(formRef.current);
const form = Object.fromEntries(
formData.entries()
) as RecoveryFormData[T];
try {
setForm(form);
await props.onSubmit(form);
} catch (e) {
const error = e as Error;
setError(error.message);
} finally {
setIsSubmitting(false);
}
}}
>
<Text variant={"heading"} fontSize={32} textAlign="center">
{title}
</Text>
<Text
variant="body"
fontSize={"title"}
textAlign="center"
mt={2}
mb={35}
color="fontTertiary"
>
{subtitle}
</Text>
{typeof children === "function" ? children(form) : children}
{error && (
<Flex bg="errorBg" p={1} mt={2} sx={{ borderRadius: "default" }}>
<ErrorIcon size={15} color="error" />
<Text variant="error" ml={1}>
{error}
</Text>
</Flex>
)}
</Flex>
);
}
function openURL(url: string) {
window.open(url, "_self");
}
function isSessionExpired() {
return Config.get("sessionExpired", false);
}

View File

@@ -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"
/>
</Button>
<input
type="file"
id="restore-backup"
hidden
accept=".nnbackup,application/json"
/>
<Button
variant="list"
onClick={async () => {
try {
if (!isLoggedIn)
throw new Error(
"You must be logged in to restore backups."
);
const backup = await importBackup();
async function restore(password) {
await db.backup.import(backup, password);
throw new Error("You must be logged in to restore backups.");
await importBackup();
await refreshApp();
showToast("success", "Backup restored!");
}
if (backup.data.iv && backup.data.salt) {
await showPasswordDialog(
"ask_backup_password",
async ({ password }) => {
const error = await restore(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,
});
}
} catch (e) {
console.error(e);
await showToast(
"error",
`Could not restore the backup: ${e.message || e}`
);
}
}}
>
<Tip