mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-22 14:39:34 +01:00
feat: improve account recovery
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
"immer": "^9.0.6",
|
"immer": "^9.0.6",
|
||||||
"just-debounce-it": "^3.0.1",
|
"just-debounce-it": "^3.0.1",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
|
"localforage-driver-memory": "^1.0.5",
|
||||||
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
|
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
|
||||||
"nncryptoworker": "file:packages/nncryptoworker",
|
"nncryptoworker": "file:packages/nncryptoworker",
|
||||||
"notes-core": "npm:@streetwriters/notesnook-core@latest",
|
"notes-core": "npm:@streetwriters/notesnook-core@latest",
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { EventSourcePolyfill as EventSource } from "event-source-polyfill";
|
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}
|
* @type {import("notes-core/api").default}
|
||||||
*/
|
*/
|
||||||
var db;
|
var db;
|
||||||
async function initializeDatabase() {
|
async function initializeDatabase(persistence) {
|
||||||
const { default: Database } = await import("notes-core/api");
|
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");
|
const { default: FS } = await import("../interfaces/fs");
|
||||||
db = new Database(Storage, EventSource, FS);
|
db = new Database(new NNStorage(persistence), EventSource, FS);
|
||||||
|
|
||||||
// if (isTesting()) {
|
// if (isTesting()) {
|
||||||
// db.host({
|
// db.host({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { store as userstore } from "../stores/user-store";
|
|||||||
import FileSaver from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import { showToast } from "../utils/toast";
|
import { showToast } from "../utils/toast";
|
||||||
import { SUBSCRIPTION_STATUS } from "./constants";
|
import { SUBSCRIPTION_STATUS } from "./constants";
|
||||||
|
import { showFilePicker } from "../components/editor/plugins/picker";
|
||||||
|
|
||||||
export const CREATE_BUTTON_MAP = {
|
export const CREATE_BUTTON_MAP = {
|
||||||
notes: {
|
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) {
|
export function getTotalNotes(notebook) {
|
||||||
return notebook.topics.reduce((sum, topic) => {
|
return notebook.topics.reduce((sum, topic) => {
|
||||||
return sum + topic.notes.length;
|
return sum + topic.notes.length;
|
||||||
@@ -138,3 +186,13 @@ export async function showUpgradeReminderDialogs() {
|
|||||||
await showReminderDialog("trialexpiring");
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { initializeDatabase } from "../common/db";
|
|||||||
const memory = {
|
const memory = {
|
||||||
isAppLoaded: false,
|
isAppLoaded: false,
|
||||||
};
|
};
|
||||||
export default function useDatabase() {
|
export default function useDatabase(persistence) {
|
||||||
const [isAppLoaded, setIsAppLoaded] = useState(memory.isAppLoaded);
|
const [isAppLoaded, setIsAppLoaded] = useState(memory.isAppLoaded);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -12,11 +12,11 @@ export default function useDatabase() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await import("../app.css");
|
await import("../app.css");
|
||||||
await initializeDatabase();
|
await initializeDatabase(persistence);
|
||||||
setIsAppLoaded(true);
|
setIsAppLoaded(true);
|
||||||
memory.isAppLoaded = true;
|
memory.isAppLoaded = true;
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, [persistence]);
|
||||||
|
|
||||||
return [isAppLoaded];
|
return [isAppLoaded];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ if (process.env.REACT_APP_PLATFORM === "desktop") require("./commands");
|
|||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
"/account/recovery": {
|
"/account/recovery": {
|
||||||
component: () => import("./views/recovery"),
|
component: () => import("./views/recovery"),
|
||||||
props: {},
|
props: { route: "methods" },
|
||||||
},
|
},
|
||||||
"/account/verified": {
|
"/account/verified": {
|
||||||
component: () => import("./views/email-confirmed"),
|
component: () => import("./views/email-confirmed"),
|
||||||
|
|||||||
@@ -1,128 +1,124 @@
|
|||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
import { extendPrototype } from "localforage-getitems";
|
import { extendPrototype } from "localforage-getitems";
|
||||||
|
import * as MemoryDriver from "localforage-driver-memory";
|
||||||
import sort from "fast-sort";
|
import sort from "fast-sort";
|
||||||
import { getNNCrypto } from "./nncrypto.stub";
|
import { getNNCrypto } from "./nncrypto.stub";
|
||||||
import { Cipher, SerializedKey } from "@notesnook/crypto/dist/src/types";
|
import { Cipher, SerializedKey } from "@notesnook/crypto/dist/src/types";
|
||||||
|
|
||||||
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
|
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
|
||||||
|
|
||||||
|
localforage.defineDriver(MemoryDriver);
|
||||||
extendPrototype(localforage);
|
extendPrototype(localforage);
|
||||||
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
|
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
|
||||||
|
|
||||||
localforage.config({
|
export class NNStorage {
|
||||||
name: "Notesnook",
|
database: LocalForage;
|
||||||
driver: [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE],
|
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<T>(key: string): Promise<T | null> {
|
read<T>(key: string): Promise<T | null> {
|
||||||
return localforage.getItem(key);
|
return this.database.getItem(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readMulti(keys: string[]) {
|
readMulti(keys: string[]) {
|
||||||
if (keys.length <= 0) return [];
|
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) {
|
write<T>(key: string, data: T) {
|
||||||
return localforage.setItem(key, data);
|
return this.database.setItem(key, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(key: string) {
|
remove(key: string) {
|
||||||
return localforage.removeItem(key);
|
return this.database.removeItem(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
clear() {
|
||||||
return localforage.clear();
|
return this.database.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllKeys() {
|
getAllKeys() {
|
||||||
return localforage.keys();
|
return this.database.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deriveCryptoKey(name: string, credentials: SerializedKey) {
|
async deriveCryptoKey(name: string, credentials: SerializedKey) {
|
||||||
const { password, salt } = credentials;
|
const { password, salt } = credentials;
|
||||||
if (!password) throw new Error("Invalid data provided to deriveCryptoKey.");
|
if (!password) throw new Error("Invalid data provided to deriveCryptoKey.");
|
||||||
|
|
||||||
const crypto = await getNNCrypto();
|
const crypto = await getNNCrypto();
|
||||||
const keyData = await crypto.exportKey(password, salt);
|
const keyData = await crypto.exportKey(password, salt);
|
||||||
|
|
||||||
if (isIndexedDBSupported() && window?.crypto?.subtle) {
|
if (this.isIndexedDBSupported() && window?.crypto?.subtle) {
|
||||||
const pbkdfKey = await derivePBKDF2Key(password);
|
const pbkdfKey = await derivePBKDF2Key(password);
|
||||||
await write(name, pbkdfKey);
|
await this.write(name, pbkdfKey);
|
||||||
const cipheredKey = await aesEncrypt(pbkdfKey, keyData.key!);
|
const cipheredKey = await aesEncrypt(pbkdfKey, keyData.key!);
|
||||||
await write(`${name}@_k`, cipheredKey);
|
await this.write(`${name}@_k`, cipheredKey);
|
||||||
} else {
|
} else {
|
||||||
await write(`${name}@_k`, keyData.key);
|
await this.write(`${name}@_k`, keyData.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 this.read<string>(`${name}@_k`);
|
||||||
|
if (!key) return;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isIndexedDBSupported(): boolean {
|
||||||
|
return this.database.driver() === "asyncStorage";
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateCryptoKey(
|
||||||
|
password: string,
|
||||||
|
salt?: string
|
||||||
|
): Promise<SerializedKey> {
|
||||||
|
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<string> {
|
||||||
|
const crypto = await getNNCrypto();
|
||||||
|
return await crypto.hash(password, `${APP_SALT}${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async encrypt(key: SerializedKey, plainText: string): Promise<Cipher> {
|
||||||
|
const crypto = await getNNCrypto();
|
||||||
|
return await crypto.encrypt(
|
||||||
|
key,
|
||||||
|
{ format: "text", data: plainText },
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(
|
||||||
|
key: SerializedKey,
|
||||||
|
cipherData: Cipher
|
||||||
|
): 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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`);
|
|
||||||
if (typeof cipheredKey === "string") return cipheredKey;
|
|
||||||
if (!pbkdfKey || !cipheredKey) return;
|
|
||||||
return await aesDecrypt(pbkdfKey, cipheredKey);
|
|
||||||
} else {
|
|
||||||
const key = await read<string>(`${name}@_k`);
|
|
||||||
if (!key) return;
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIndexedDBSupported(): boolean {
|
|
||||||
return localforage.driver() === "asyncStorage";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateCryptoKey(
|
|
||||||
password: string,
|
|
||||||
salt?: string
|
|
||||||
): 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> {
|
|
||||||
const crypto = await getNNCrypto();
|
|
||||||
return await crypto.hash(password, `${APP_SALT}${email}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encrypt(key: SerializedKey, plainText: string): Promise<Cipher> {
|
|
||||||
const crypto = await getNNCrypto();
|
|
||||||
return await crypto.encrypt(
|
|
||||||
key,
|
|
||||||
{ format: "text", data: plainText },
|
|
||||||
"base64"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decrypt(
|
|
||||||
key: SerializedKey,
|
|
||||||
cipherData: Cipher
|
|
||||||
): 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 enc = new TextEncoder();
|
||||||
let dec = new TextDecoder();
|
let dec = new TextDecoder();
|
||||||
|
|
||||||
|
|||||||
@@ -433,7 +433,13 @@ function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
|
|||||||
subtitle: "Please wait while we send you recovery instructions.",
|
subtitle: "Please wait while we send you recovery instructions.",
|
||||||
}}
|
}}
|
||||||
onSubmit={async (form) => {
|
onSubmit={async (form) => {
|
||||||
|
if (!form.email) {
|
||||||
|
setSuccess(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = await db.user?.recoverAccount(form.email.toLowerCase());
|
const url = await db.user?.recoverAccount(form.email.toLowerCase());
|
||||||
|
console.log(url);
|
||||||
if (isTesting()) return openURL(url);
|
if (isTesting()) return openURL(url);
|
||||||
setSuccess(
|
setSuccess(
|
||||||
`Recovery email sent. Please check your inbox (and spam folder) for further instructions.`
|
`Recovery email sent. Please check your inbox (and spam folder) for further instructions.`
|
||||||
@@ -441,12 +447,19 @@ function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{success ? (
|
{success ? (
|
||||||
<Flex bg="background" p={2} mt={2} sx={{ borderRadius: "default" }}>
|
<>
|
||||||
<CheckCircle size={20} color="primary" />
|
<Flex bg="background" p={2} mt={2} sx={{ borderRadius: "default" }}>
|
||||||
<Text variant="body" color="primary" ml={2}>
|
<CheckCircle size={20} color="primary" />
|
||||||
{success}
|
<Text variant="body" color="primary" ml={2}>
|
||||||
</Text>
|
{success}
|
||||||
</Flex>
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<SubmitButton
|
||||||
|
text="Send again"
|
||||||
|
disabled={!isAppLoaded}
|
||||||
|
loading={!isAppLoaded}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AuthField
|
<AuthField
|
||||||
@@ -722,7 +735,7 @@ type AuthFormProps<TType extends AuthRoutes> = {
|
|||||||
| ((form?: AuthFormData[TType]) => React.ReactNode);
|
| ((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 { title, subtitle, children } = props;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
@@ -832,7 +845,7 @@ type AuthFieldProps = {
|
|||||||
onClick?: () => void | Promise<void>;
|
onClick?: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
function AuthField(props: AuthFieldProps) {
|
export function AuthField(props: AuthFieldProps) {
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
type={props.type}
|
type={props.type}
|
||||||
@@ -869,7 +882,7 @@ type SubmitButtonProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
};
|
};
|
||||||
function SubmitButton(props: SubmitButtonProps) {
|
export function SubmitButton(props: SubmitButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
data-test-id="submitButton"
|
data-test-id="submitButton"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
599
apps/web/src/views/recovery.tsx
Normal file
599
apps/web/src/views/recovery.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
show2FARecoveryCodesDialog,
|
show2FARecoveryCodesDialog,
|
||||||
} from "../common/dialog-controller";
|
} from "../common/dialog-controller";
|
||||||
import { SUBSCRIPTION_STATUS } from "../common/constants";
|
import { SUBSCRIPTION_STATUS } from "../common/constants";
|
||||||
import { createBackup, verifyAccount } from "../common";
|
import { createBackup, importBackup, verifyAccount } from "../common";
|
||||||
import { db } from "../common/db";
|
import { db } from "../common/db";
|
||||||
import { usePersistentState } from "../utils/hooks";
|
import { usePersistentState } from "../utils/hooks";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -45,35 +45,6 @@ import debounce from "just-debounce-it";
|
|||||||
import { PATHS } from "@notesnook/desktop/paths";
|
import { PATHS } from "@notesnook/desktop/paths";
|
||||||
import { openPath } from "../commands/open";
|
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) {
|
function subscriptionStatusToString(user) {
|
||||||
const status = user?.subscription?.type;
|
const status = user?.subscription?.type;
|
||||||
|
|
||||||
@@ -469,52 +440,14 @@ function Settings(props) {
|
|||||||
tip="Create a backup file of all your data"
|
tip="Create a backup file of all your data"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="restore-backup"
|
|
||||||
hidden
|
|
||||||
accept=".nnbackup,application/json"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="list"
|
variant="list"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
if (!isLoggedIn)
|
||||||
if (!isLoggedIn)
|
throw new Error("You must be logged in to restore backups.");
|
||||||
throw new Error(
|
await importBackup();
|
||||||
"You must be logged in to restore backups."
|
await refreshApp();
|
||||||
);
|
|
||||||
|
|
||||||
const backup = await importBackup();
|
|
||||||
|
|
||||||
async function restore(password) {
|
|
||||||
await db.backup.import(backup, password);
|
|
||||||
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
|
<Tip
|
||||||
|
|||||||
Reference in New Issue
Block a user