Merge branch 'release/1.8.3'

This commit is contained in:
thecodrr
2022-03-23 20:32:37 +05:00
35 changed files with 2270 additions and 764 deletions

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "1.8.2",
"version": "1.8.3",
"private": true,
"main": "./build/electron.js",
"homepage": "https://notesnook.com/",

View File

@@ -1,7 +1,7 @@
{
"name": "notesnook",
"description": "Your private note taking space",
"version": "1.8.2",
"version": "1.8.3",
"private": true,
"main": "./src/App.js",
"homepage": "https://notesnook.com/",
@@ -14,6 +14,7 @@
"@rebass/forms": "^4.0.6",
"@streetwriters/tinymce-plugins": "^1.5.17",
"@tinymce/tinymce-react": "^3.13.0",
"@types/rebass": "^4.0.10",
"async-mutex": "^0.3.2",
"axios": "^0.21.4",
"clipboard-polyfill": "^3.0.3",
@@ -37,6 +38,7 @@
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
"nncryptoworker": "file:packages/nncryptoworker",
"notes-core": "npm:@streetwriters/notesnook-core@latest",
"phone": "^3.1.14",
"platform": "^1.3.6",
"print-js": "^1.6.0",
"qclone": "^1.0.4",

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 717.67 430.74">
<path fill="#f2f2f2" d="M120.43 410.02a2.8 2.8 0 0 1-2.04-4.87l.2-.76-.08-.18a7.54 7.54 0 0 0-13.9.05c-2.28 5.48-5.18 10.96-5.89 16.75a22.3 22.3 0 0 0 .4 7.68 89.42 89.42 0 0 1-8.14-37.14 86.3 86.3 0 0 1 .53-9.63q.45-3.93 1.23-7.8a90.46 90.46 0 0 1 17.94-38.35 24.07 24.07 0 0 0 10.01-10.38 18.36 18.36 0 0 0 1.67-5.02c-.48.06-1.83-7.36-1.47-7.82-.68-1.03-1.9-1.54-2.63-2.54-3.7-5-8.78-4.13-11.43 2.66-5.67 2.87-5.72 7.61-2.24 12.17 2.2 2.9 2.51 6.84 4.45 9.94l-.6.76a91.04 91.04 0 0 0-9.5 15.06 37.85 37.85 0 0 0-2.26-17.58c-2.17-5.22-6.22-9.61-9.8-14.12-4.28-5.42-13.07-3.06-13.83 3.81l-.02.2q.8.45 1.56.95a3.8 3.8 0 0 1-1.54 6.93l-.07.01a37.89 37.89 0 0 0 1 5.67c-4.58 17.71 5.3 24.16 19.42 24.45.31.16.61.32.93.47a92.92 92.92 0 0 0-5 23.54 88.14 88.14 0 0 0 .06 14.23l-.03-.17a23.29 23.29 0 0 0-7.95-13.44c-6.11-5.03-14.76-6.88-21.36-10.92a4.37 4.37 0 0 0-6.7 4.25l.03.18a25.58 25.58 0 0 1 2.87 1.38q.8.45 1.56.95a3.8 3.8 0 0 1-1.54 6.93l-.07.01-.16.03A37.92 37.92 0 0 0 63 399.28c2.87 15.46 15.16 16.93 28.32 12.43a92.9 92.9 0 0 0 6.25 18.21h22.3l.21-.75a25.33 25.33 0 0 1-6.16-.36c1.65-2.03 3.3-4.08 4.96-6.1a1.39 1.39 0 0 0 .1-.13l2.53-3.1a37.1 37.1 0 0 0-1.09-9.46Zm465.62 0a2.8 2.8 0 0 0 2.04-4.87l-.2-.76.08-.18a7.54 7.54 0 0 1 13.9.05c2.28 5.48 5.18 10.96 5.89 16.75a22.3 22.3 0 0 1-.4 7.68 89.42 89.42 0 0 0 8.14-37.14 86.3 86.3 0 0 0-.53-9.63q-.45-3.93-1.23-7.8a90.46 90.46 0 0 0-17.94-38.35 24.07 24.07 0 0 1-10.01-10.38 18.36 18.36 0 0 1-1.67-5.02c.48.06 1.84-7.36 1.47-7.82.68-1.03 1.9-1.54 2.63-2.54 3.7-5 8.78-4.13 11.43 2.66 5.67 2.87 5.72 7.61 2.25 12.17-2.22 2.9-2.52 6.84-4.46 9.94l.6.76a91.04 91.04 0 0 1 9.5 15.06 37.85 37.85 0 0 1 2.27-17.58c2.16-5.22 6.21-9.61 9.78-14.12 4.29-5.42 13.08-3.06 13.84 3.81l.02.2q-.8.45-1.56.95a3.8 3.8 0 0 0 1.54 6.93l.08.01a37.89 37.89 0 0 1-1 5.67c4.58 17.71-5.31 24.16-19.43 24.45l-.92.47a92.93 92.93 0 0 1 5 23.54 88.14 88.14 0 0 1-.07 14.23l.03-.17a23.29 23.29 0 0 1 7.95-13.44c6.12-5.03 14.76-6.88 21.36-10.92a4.37 4.37 0 0 1 6.7 4.25l-.03.18a25.58 25.58 0 0 0-2.87 1.38q-.8.45-1.56.95a3.8 3.8 0 0 0 1.54 6.93l.07.01.16.03a37.92 37.92 0 0 1-6.97 10.92c-2.86 15.46-15.16 16.93-28.32 12.43a92.9 92.9 0 0 1-6.25 18.21h-22.29l-.22-.75a25.33 25.33 0 0 0 6.16-.36c-1.65-2.03-3.3-4.08-4.96-6.1a1.39 1.39 0 0 1-.1-.13l-2.53-3.1a37.1 37.1 0 0 1 1.1-9.46Zm-439.98 19.4h35.97c6.16-20.71.04-69.67 8.15-89.68 15.56-38.37 35.43-78.85 72.53-97.59 15.84-8 33.11-11.12 50.75-10.95 24.37.23 49.42 6.77 72.35 15.44a672.45 672.45 0 0 1 27.94 11.4c34.88 14.99 69.39 31.32 106.9 35.73 47.48 5.58 103.29-15.66 116.25-61.4 9.9-35.03-7.92-71.4-26.61-102.68-18.7-31.28-39.58-64.75-36.17-100.96.01-.12.02-.26.05-.38 1.28-13 9.05-22.36 19.9-28.35h-47.24c-.62 33.79 18.92 63.6 36.51 93.05 18.7 31.27 36.51 67.65 26.62 102.67-12.97 45.74-120.33-7.87-167.8-13.47-13.4-1.57 7.89 4.01-4.9 0-20.04-6.28-22.26 51.37-41.77 42.9-12.15-5.27-24.3-10.51-36.63-15.17a305.14 305.14 0 0 0-15.58-5.42l-.02-.01c-35.4-11.21-74.12-15.58-106.82.63l-.68.33c-37.1 18.72-56.97 59.2-72.53 97.57-12.86 31.72-10.83 92.89-17.17 126.34Z"/>
<circle cx="346.21" cy="263.14" r="165.22" fill="#fff"/>
<path fill="#3f3d56" d="M345.74 429.42c-163.47 3.76-167.06-73.9-167.06-166.28 0-92.37 75.16-167.53 167.53-167.53s167.53 75.16 167.53 167.53c0 92.38-75.62 166.28-168 166.28Zm.47-329.2c-89.83 0-161.4 73.1-162.92 162.92-1.4 83.54 87.35 187.8 162.92 162.92 40.53-44.64 155.11-92.71 162.92-162.92 9.93-89.28-73.08-162.92-162.92-162.92Z"/>
<path fill="#f2f2f2" d="M323.67 349.13a18.37 18.37 0 0 1-14.7-7.35l-45.07-60.1a18.38 18.38 0 1 1 29.4-22.06l29.5 39.32 75.73-113.6a18.38 18.38 0 0 1 30.59 20.38l-90.15 135.23a18.39 18.39 0 0 1-14.79 8.18h-.5Z"/>
<path fill="var(--primary)" d="M317.67 347.13a18.37 18.37 0 0 1-14.7-7.35l-45.07-60.1a18.38 18.38 0 1 1 29.4-22.06l29.5 39.32 75.73-113.6a18.38 18.38 0 1 1 30.59 20.38l-90.15 135.23a18.39 18.39 0 0 1-14.79 8.18h-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 382.94 405.93">
<path fill="var(--text)" d="M192.58 405.92a75.19 75.19 0 0 1-18.64-2.41l-1.2-.33-1.12-.56c-40.24-20.18-74.19-46.83-100.9-79.21a299.86 299.86 0 0 1-50.95-90.47A348.21 348.21 0 0 1 .07 110.27l.04-2.02c0-20.29 11.26-38.09 28.7-45.35C42.13 57.34 163.24 7.6 172 4c16.48-8.26 34.06-1.36 36.87-.16 6.31 2.58 118.28 48.38 142.47 59.9 24.94 11.87 31.6 33.2 31.6 43.93 0 48.6-8.43 94-25.02 134.97a312.52 312.52 0 0 1-56.16 90.51c-45.85 51.6-91.7 69.89-92.15 70.05a50.11 50.11 0 0 1-17.04 2.72zm-10.79-26.71c3.98.89 13.13 2.22 19.1.05 7.58-2.77 45.96-22.67 81.83-63.03 49.55-55.77 74.7-125.88 74.74-208.38-.1-1.67-1.28-13.59-17.07-21.1-23.72-11.3-140.1-58.89-141.27-59.37l-.32-.14c-2.44-1.02-10.2-3.17-15.55-.37l-1.08.5c-1.3.54-129.86 53.34-143.57 59.05-9.6 4-13 13.9-13 21.83 0 .58-.02 1.43-.05 2.52-1.1 56.44 11.97 195.34 156.24 268.44z"/>
<path fill="var(--bgSecondary)" d="M177.33 15.59S47.61 68.87 33.71 74.66c-13.9 5.79-20.85 19.7-20.85 33.6 0 13.9-10.45 195.26 164.47 282.96 0 0 15.88 4.39 27.92 0 12.04-4.39 164.96-78.52 164.96-283.55 0 0 0-20.85-24.33-32.43C321.55 63.66 203.94 15.6 203.94 15.6s-14.44-6.37-26.6 0z"/>
<path d="M191.23 57.29v284.25S60.34 278.53 61.51 112.89z" opacity=".2"/>
<path fill="var(--icon)" d="m192.94 261.58-41.69-53.61 24.24-18.86 19.75 25.38 66.7-70.4 22.3 21.13z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -18,24 +18,24 @@ async function initializeDatabase() {
db = new Database(Storage, EventSource, FS);
// if (isTesting()) {
db.host({
API_HOST: "https://api.notesnook.com",
AUTH_HOST: "https://auth.streetwriters.co",
SSE_HOST: "https://events.streetwriters.co",
});
// db.host({
// API_HOST: "https://api.notesnook.com",
// AUTH_HOST: "https://auth.streetwriters.co",
// SSE_HOST: "https://events.streetwriters.co",
// });
// } else {
// db.host({
// API_HOST: "http://localhost:5264",
// AUTH_HOST: "http://localhost:8264",
// SSE_HOST: "http://localhost:7264",
// });
// db.host({
// API_HOST: "http://192.168.10.29:5264",
// AUTH_HOST: "http://192.168.10.29:8264",
// SSE_HOST: "http://192.168.10.29:7264",
// ISSUES_HOST: "http://192.168.10.29:2624",
// SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264",
// });
db.host({
API_HOST: "http://192.168.10.29:5264",
AUTH_HOST: "http://192.168.10.29:8264",
SSE_HOST: "http://192.168.10.29:7264",
ISSUES_HOST: "http://192.168.10.29:2624",
SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264",
});
// }
await db.init();

View File

@@ -607,6 +607,24 @@ export function showImportDialog() {
));
}
export function showMultifactorDialog(primaryMethod = "") {
return showDialog((Dialogs, perform) => (
<Dialogs.MultifactorDialog
onClose={(res) => perform(res)}
primaryMethod={primaryMethod}
/>
));
}
export function show2FARecoveryCodesDialog(primaryMethod) {
return showDialog((Dialogs, perform) => (
<Dialogs.RecoveryCodesDialog
onClose={(res) => perform(res)}
primaryMethod={primaryMethod}
/>
));
}
export function showAttachmentsDialog() {
return showDialog((Dialogs, perform) => (
<Dialogs.AttachmentsDialog onClose={(res) => perform(res)} />

View File

@@ -3,13 +3,18 @@ import { TaskManager } from "./task-manager";
import { zip } from "../utils/zip";
import { saveAs } from "file-saver";
async function exportToPDF(content: string): Promise<boolean> {
export async function exportToPDF(
title: string,
content: string
): Promise<boolean> {
if (!content) return false;
const { default: printjs } = await import("print-js");
return new Promise(async (resolve) => {
printjs({
printable: content,
type: "raw-html",
documentTitle: title,
header: '<h3 class="custom-h3">My custom header</h3>',
onPrintDialogClose: () => {
resolve(false);
},
@@ -30,7 +35,7 @@ export async function exportNotes(
action: async (report) => {
if (format === "pdf") {
const note = db.notes!.note(noteIds[0]);
return await exportToPDF(await note.export("html", null));
return await exportToPDF(note.title, await note.export("html", null));
}
var files = [];

View File

@@ -8,12 +8,11 @@ import Vault from "./vault";
import { showItemDeletedToast } from "./toasts";
import { TaskManager } from "./task-manager";
async function moveNotesToTrash(notes: any[]) {
async function moveNotesToTrash(notes: any[], confirm = true) {
const item = notes[0];
const isMultiselect = notes.length > 1;
if (isMultiselect) {
if (!(await showMultiDeleteConfirmation(notes.length))) return;
} else {
if (confirm && !(await showMultiDeleteConfirmation(notes.length))) return;
if (notes.length === 1) {
if (
item.locked &&
!(await Vault.unlockNote(item.id, "unlock_and_delete_note"))
@@ -38,11 +37,7 @@ async function moveNotesToTrash(notes: any[]) {
},
});
if (isMultiselect) {
showToast("success", `${items.length} notes moved to trash`);
} else {
showItemDeletedToast(item);
}
showToast("success", `${items.length} notes moved to trash`);
}
async function moveNotebooksToTrash(notebooks: any[]) {

View File

@@ -88,6 +88,7 @@ export const Reminders = {
},
};
var openedToast = null;
export async function resetReminders() {
const reminders = [];
@@ -102,7 +103,28 @@ export async function resetReminders() {
saveFile(filePath, data);
showToast("success", `Backup saved at ${filePath}.`);
} else if (isUserPremium() && !isTesting()) {
await showBackupDialog();
if (openedToast !== null) return;
openedToast = showToast(
"success",
"Your backup is ready for download.",
[
{
text: "Later",
onClick: () => {
createBackup(false);
openedToast?.hide();
openedToast = null;
},
type: "text",
},
{
text: "Download",
onClick: () => createBackup(true),
type: "primary",
},
],
0
);
}
}
if (await shouldAddLoginReminder()) {

View File

@@ -1,39 +0,0 @@
import { Text } from "rebass";
import { createBackup } from "../../common";
import Dialog from "./dialog";
type BackupDialogProps = {
onClose: () => void;
};
function BackupDialog(props: BackupDialogProps) {
return (
<Dialog
isOpen={true}
alignment="center"
onClose={props.onClose}
title="Your backup is ready"
description="Download a backup of your notes to keep them safe."
positiveButton={{
text: "Save to disk",
onClick: async () => {
await createBackup(true);
props.onClose();
},
}}
negativeButton={{
text: "Remind me later",
onClick: async () => {
await createBackup(false);
props.onClose();
},
}}
>
<Text variant={"body"}>
In case forget your password or something unfortunate happens, you can
restore a backup to recover lost data.{" "}
</Text>
</Dialog>
);
}
export default BackupDialog;

View File

@@ -120,16 +120,10 @@ function Dialog(props) {
>
{props.negativeButton && (
<RebassButton
variant="primary"
variant="dialog"
data-test-id="dialog-no"
onClick={props.negativeButton.onClick}
color="text"
fontWeight="bold"
bg={"transparent"}
sx={{
opacity: props.negativeButton.disabled ? 0.7 : 1,
":hover": { bg: "bgSecondary" },
}}
>
{props.negativeButton.text || "Cancel"}
</RebassButton>
@@ -137,15 +131,9 @@ function Dialog(props) {
{props.positiveButton && (
<RebassButton
{...props.positiveButton.props}
variant="primary"
color="primary"
fontWeight="bold"
bg={"transparent"}
variant="dialog"
data-test-id="dialog-yes"
sx={{
opacity: props.positiveButton.disabled ? 0.7 : 1,
":hover": { bg: "bgSecondary" },
}}
autoFocus={props.positiveButton.autoFocus}
disabled={props.positiveButton.disabled || false}
onClick={
!props.positiveButton.disabled

View File

@@ -40,31 +40,14 @@ const features: Record<FeatureKeys, Feature> = {
subtitle: `Welcome to v${appVersion.clean}`,
subFeatures: [
{
title: "Duplicate notes",
title: "Two-factor authentication",
subtitle: (
<>
Tired of copy-pasting notes? Wish there were templates? Not anymore.{" "}
<Code text="Right click > Duplicate" /> on a note to create its
duplicate.
Add an additional layer of security for your notes by enabling 2FA
from <Code text="Settings" />.
</>
),
icon: Icon.Duplicate,
},
{
title: "Disable sync for specific notes",
subtitle: (
<>
Make any note 100% offline by{" "}
<Code text="Right click > Disable sync" /> on a note.
</>
),
icon: Icon.Sync,
},
{
title: "Improved pasting of code",
subtitle:
"Pasting code from anywhere (GitHub, VSCode etc.) will now keep the highlighting & formatting intact.",
icon: Icon.Codeblock,
icon: Icon.MFAAuthenticator,
},
],
cta: {

View File

@@ -14,9 +14,9 @@ import TrackingDetailsDialog from "./trackingdetailsdialog";
import ReminderDialog from "./reminderdialog";
import AnnouncementDialog from "./announcementdialog";
import IssueDialog from "./issuedialog";
import { MultifactorDialog, RecoveryCodesDialog } from "./multi-factor-dialog";
import OnboardingDialog from "./onboarding-dialog";
import AttachmentsDialog from "./attachmentsdialog";
import BackupDialog from "./backupdialog";
const Dialogs = {
AddNotebookDialog,
@@ -35,8 +35,9 @@ const Dialogs = {
AnnouncementDialog,
IssueDialog,
ImportDialog,
MultifactorDialog,
RecoveryCodesDialog,
OnboardingDialog,
AttachmentsDialog,
BackupDialog,
};
export default Dialogs;

View File

@@ -0,0 +1,852 @@
import React, {
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Text, Flex, Button, Box, BoxProps } from "rebass";
import Dialog from "./dialog";
import { db } from "../../common/db";
import { ReactComponent as MFA } from "../../assets/mfa.svg";
import { ReactComponent as Fallback2FA } from "../../assets/fallback2fa.svg";
import * as clipboard from "clipboard-polyfill/text";
import { Suspense } from "react";
import FileSaver from "file-saver";
import {
Loading,
MFAAuthenticator,
MFAEmail,
MFASMS,
Download,
Print,
Copy,
Refresh,
} from "../icons";
import Field from "../field";
import { useSessionState } from "../../utils/hooks";
import { exportToPDF } from "../../common/export";
import { useTimer } from "../../hooks/use-timer";
import { phone } from "phone";
import { showMultifactorDialog } from "../../common/dialog-controller";
const QRCode = React.lazy(() => import("../../re-exports/react-qrcode-logo"));
export type AuthenticatorType = "app" | "sms" | "email";
type StepKeys = keyof Steps; // "choose" | "setup" | "recoveryCodes" | "finish";
type FallbackStepKeys = keyof FallbackSteps;
type Steps = typeof steps;
type FallbackSteps = typeof fallbackSteps;
type Authenticator = {
type: AuthenticatorType;
title: string;
subtitle: string;
icon: React.FunctionComponent<BoxProps>;
recommended?: boolean;
};
type StepComponentProps = {
onNext: (...args: any[]) => void;
onClose?: () => void;
onError?: (error: string) => void;
};
type StepComponent = React.FunctionComponent<StepComponentProps>;
type Step = {
title?: string;
description?: string;
component?: StepComponent;
next?: StepKeys;
cancellable?: boolean;
};
type FallbackStep = Step & {
next?: FallbackStepKeys;
};
type SubmitCodeFunction = (code: string) => void;
type AuthenticatorSelectorProps = StepComponentProps & {
authenticator: AuthenticatorType;
isFallback?: boolean;
};
type VerifyAuthenticatorFormProps = PropsWithChildren<{
codeHelpText: string;
onSubmitCode: SubmitCodeFunction;
}>;
type SetupAuthenticatorProps = { onSubmitCode: SubmitCodeFunction };
type MultifactorDialogProps = {
onClose: () => void;
primaryMethod?: AuthenticatorType;
};
type RecoveryCodesDialogProps = {
onClose: () => void;
primaryMethod: AuthenticatorType;
};
const defaultAuthenticators: AuthenticatorType[] = ["app", "sms", "email"];
const Authenticators: Authenticator[] = [
{
type: "app",
title: "Set up using an Authenticator app",
subtitle:
"Use an authenticator app like Aegis or Raivo Authenticator to get the authentication codes.",
icon: MFAAuthenticator,
recommended: true,
},
{
type: "sms",
title: "Set up using SMS",
subtitle: "Notesnook will send you an SMS text with the 2FA code at login.",
icon: MFASMS,
},
{
type: "email",
title: "Set up using Email",
subtitle: "Notesnook will send you the 2FA code on your email at login.",
icon: MFAEmail,
},
];
const steps = {
choose: (): Step => ({
title: "Protect your notes by enabling 2FA",
description: "Choose how you want to receive your authentication codes.",
component: ({ onNext }) => (
<ChooseAuthenticator
onNext={onNext}
authenticators={defaultAuthenticators}
/>
),
next: "setup",
cancellable: true,
}),
setup: (authenticator: Authenticator): Step => ({
title: authenticator.title,
description: authenticator.subtitle,
next: "recoveryCodes",
component: ({ onNext }) => (
<AuthenticatorSelector
onNext={onNext}
authenticator={authenticator.type}
/>
),
}),
recoveryCodes: (authenticatorType: AuthenticatorType): Step => ({
title: "Save your recovery codes",
description: `If you lose access to your ${
authenticatorType === "email"
? "email"
: authenticatorType === "sms"
? "phone"
: "auth app"
}, you can login to Notesnook using your recovery codes. Each code can only be used once!`,
component: ({ onNext, onClose, onError }) => (
<BackupRecoveryCodes
onClose={onClose}
onNext={onNext}
onError={onError}
authenticatorType={authenticatorType}
/>
),
next: "finish",
}),
finish: (authenticatorType: AuthenticatorType): Step => ({
component: ({ onNext, onClose, onError }) => (
<TwoFactorEnabled
onClose={onClose}
onNext={onNext}
onError={onError}
authenticatorType={authenticatorType}
/>
),
}),
} as const;
const fallbackSteps = {
choose: (primaryMethod: AuthenticatorType): FallbackStep => ({
title: "Add a fallback 2FA method",
description:
"A fallback method helps you get your 2FA codes on an alternative device in case you lose your primary device.",
component: ({ onNext }) => (
<ChooseAuthenticator
onNext={onNext}
authenticators={defaultAuthenticators.filter(
(i) => i !== primaryMethod
)}
/>
),
next: "setup",
cancellable: true,
}),
setup: (authenticator: Authenticator): FallbackStep => ({
title: authenticator.title,
description: authenticator.subtitle,
next: "finish",
cancellable: true,
component: ({ onNext }) => (
<AuthenticatorSelector
onNext={onNext}
authenticator={authenticator.type}
isFallback
/>
),
}),
finish: (
fallbackMethod: AuthenticatorType,
primaryMethod: AuthenticatorType
): FallbackStep => ({
component: ({ onNext, onClose }) => (
<Fallback2FAEnabled
onNext={onNext}
onClose={onClose}
primaryMethod={primaryMethod}
fallbackMethod={fallbackMethod}
/>
),
}),
} as const;
export function MultifactorDialog(props: MultifactorDialogProps) {
const { onClose, primaryMethod } = props;
const [step, setStep] = useState<FallbackStep | Step>(
primaryMethod ? fallbackSteps.choose(primaryMethod) : steps.choose()
);
const [error, setError] = useState<string>();
return (
<Dialog
isOpen={true}
title={step.title}
description={step.description}
width={500}
positiveButton={
step.next
? {
text: "Continue",
props: { form: "2faForm" },
}
: null
}
negativeButton={
step.cancellable
? {
text: "Cancel",
onClick: onClose,
}
: null
}
>
{step.component && (
<step.component
onNext={(...args) => {
if (!step.next) return onClose();
const nextStepCreator: Function =
step.next !== "recoveryCodes" && primaryMethod
? fallbackSteps[step.next]
: steps[step.next];
const nextStep = primaryMethod
? nextStepCreator(...args, primaryMethod)
: nextStepCreator(...args);
setStep(nextStep);
}}
onError={setError}
onClose={onClose}
/>
)}
{error && (
<Text variant={"error"} bg="errorBg" p={1} mt={2}>
{error}
</Text>
)}
</Dialog>
);
}
export function RecoveryCodesDialog(props: RecoveryCodesDialogProps) {
const { onClose, primaryMethod } = props;
const [error, setError] = useState<string>();
const step = steps.recoveryCodes(primaryMethod);
return (
<Dialog
isOpen={true}
title={step.title}
description={step.description}
width={500}
positiveButton={{
text: "Okay",
onClick: onClose,
}}
>
{step.component && (
<step.component onNext={() => {}} onError={setError} />
)}
{error && (
<Text variant={"error"} bg="errorBg" p={1} mt={2}>
{error}
</Text>
)}
</Dialog>
);
}
type ChooseAuthenticatorProps = StepComponentProps & {
authenticators: AuthenticatorType[];
};
function ChooseAuthenticator(props: ChooseAuthenticatorProps) {
const [selected, setSelected] = useSessionState("selectedAuthenticator", 0);
const { authenticators, onNext } = props;
const filteredAuthenticators = authenticators.map(
(a) => Authenticators.find((auth) => auth.type === a)!
);
return (
<Flex
as="form"
id="2faForm"
flexDirection="column"
flex={1}
sx={{ overflow: "hidden" }}
onSubmit={(e) => {
e.preventDefault();
const authenticator = filteredAuthenticators[selected];
onNext(authenticator);
}}
>
{filteredAuthenticators.map((auth, index) => (
<Button
type="button"
variant={"secondary"}
mt={2}
sx={{
":first-of-type": { mt: 2 },
display: "flex",
justifyContent: "start",
alignItems: "start",
textAlign: "left",
bg: "transparent",
px: 0,
}}
onClick={() => setSelected(index)}
>
<auth.icon
className="2fa-icon"
sx={{
bg: selected === index ? "shade" : "bgSecondary",
borderRadius: 100,
width: 35,
height: 35,
mr: 2,
}}
size={16}
color={selected === index ? "primary" : "text"}
/>
<Text variant={"title"} fontWeight="body">
{auth.title}{" "}
{auth.recommended ? (
<Text
as="span"
variant={"subBody"}
color="primary"
bg="shade"
px={1}
sx={{ borderRadius: "default" }}
>
Recommended
</Text>
) : (
false
)}
<Text variant="body" fontWeight="normal" mt={1}>
{auth.subtitle}
</Text>
</Text>
</Button>
))}
</Flex>
);
}
function AuthenticatorSelector(props: AuthenticatorSelectorProps) {
const { authenticator, isFallback, onNext, onError } = props;
const onSubmitCode: SubmitCodeFunction = useCallback(
async (code) => {
try {
if (isFallback) await db.mfa?.enableFallback(authenticator, code);
else await db.mfa!.enable(authenticator, code);
onNext(authenticator);
} catch (e) {
const error = e as Error;
onError && onError(error.message);
}
},
[authenticator, onError, onNext, isFallback]
);
return authenticator === "app" ? (
<SetupAuthenticatorApp onSubmitCode={onSubmitCode} />
) : authenticator === "email" ? (
<SetupEmail onSubmitCode={onSubmitCode} />
) : authenticator === "sms" ? (
<SetupSMS onSubmitCode={onSubmitCode} />
) : null;
}
function SetupAuthenticatorApp(props: SetupAuthenticatorProps) {
const { onSubmitCode } = props;
const [authenticatorDetails, setAuthenticatorDetails] = useState({
sharedKey: null,
authenticatorUri: null,
});
useEffect(() => {
(async function () {
setAuthenticatorDetails(await db.mfa!.setup("app"));
})();
}, []);
return (
<VerifyAuthenticatorForm
codeHelpText={
"After scanning the QR code image, the app will display a code that you can enter below."
}
onSubmitCode={onSubmitCode}
>
<Text variant={"body"}>
Scan the QR code below with your authenticator app.
</Text>
<Box alignSelf={"center"}>
{authenticatorDetails.authenticatorUri ? (
<Suspense fallback={<Loading />}>
<QRCode
value={authenticatorDetails.authenticatorUri}
ecLevel={"M"}
size={150}
/>
</Suspense>
) : (
<Loading />
)}
</Box>
<Text variant={"subBody"}>
If you can't scan the QR code above, enter this text instead (spaces
don't matter):
</Text>
<Text
mt={2}
bg="bgSecondary"
p={2}
fontFamily="monospace"
fontSize="body"
sx={{ borderRadius: "default", overflowWrap: "anywhere" }}
>
{authenticatorDetails.sharedKey ? (
authenticatorDetails.sharedKey
) : (
<Loading />
)}
</Text>
</VerifyAuthenticatorForm>
);
}
function SetupEmail(props: SetupAuthenticatorProps) {
const { onSubmitCode } = props;
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string>();
const { elapsed, enabled, setEnabled } = useTimer(`2fa.email`, 60);
const [email, setEmail] = useState<string | undefined>();
useEffect(() => {
(async () => {
const { email } = await db.user!.getUser();
setEmail(email);
})();
}, []);
return (
<VerifyAuthenticatorForm
codeHelpText={
"You will receive a 2FA code on your email address which you can enter below"
}
onSubmitCode={onSubmitCode}
>
<Flex
mt={2}
bg="bgSecondary"
alignItems={"center"}
sx={{ borderRadius: "default", overflowWrap: "anywhere" }}
>
<Text ml={2} fontFamily="monospace" fontSize="subtitle" flex={1}>
{email}
</Text>
<Button
type="button"
variant={"secondary"}
alignSelf={"center"}
sx={{ p: 2, m: 0 }}
disabled={isSending || !enabled}
onClick={async () => {
setIsSending(true);
try {
await db.mfa!.setup("email");
setEnabled(false);
} catch (e) {
const error = e as Error;
console.error(error);
setError(error.message);
} finally {
setIsSending(false);
}
}}
>
{isSending ? (
<Loading size={18} />
) : enabled ? (
`Send code`
) : (
`Resend (${elapsed})`
)}
</Button>
</Flex>
{error ? (
<Text
variant={"error"}
bg="errorBg"
p={1}
sx={{ borderRadius: "default" }}
mt={1}
>
{error}
</Text>
) : null}
</VerifyAuthenticatorForm>
);
}
function SetupSMS(props: SetupAuthenticatorProps) {
const { onSubmitCode } = props;
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string>();
const [phoneNumber, setPhoneNumber] = useState<string>();
const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60);
const inputRef = useRef<HTMLInputElement>();
return (
<VerifyAuthenticatorForm
codeHelpText={
"You will receive a 2FA code on your phone number which you can enter below"
}
onSubmitCode={onSubmitCode}
>
<Field
inputRef={inputRef}
id="phone-number"
name="phone-number"
helpText="Authentication codes will be sent to this number"
label="Phone number"
sx={{ mt: 2 }}
autoFocus
required
styles={{
input: { flex: 1 },
}}
placeholder={"+1234567890"}
onChange={() => {
const number = inputRef.current?.value;
if (!number) return setError("");
const validationResult = phone(number);
if (validationResult.isValid) {
setPhoneNumber(validationResult.phoneNumber);
setError("");
} else {
setPhoneNumber("");
setError("Please enter a valid phone number with country code.");
}
}}
action={{
disabled: error || isSending || !enabled,
component: (
<Text variant={"body"}>
{isSending ? (
<Loading size={18} />
) : enabled ? (
`Send code`
) : (
`Resend (${elapsed})`
)}
</Text>
),
onClick: async () => {
if (!phoneNumber) {
setError("Please provide a phone number.");
return;
}
setIsSending(true);
try {
await db.mfa!.setup("sms", phoneNumber);
setEnabled(false);
} catch (e) {
const error = e as Error;
console.error(error);
setError(error.message);
} finally {
setIsSending(false);
}
},
}}
/>
{error ? (
<Text
variant={"error"}
bg="errorBg"
p={1}
sx={{ borderRadius: "default" }}
mt={1}
>
{error}
</Text>
) : null}
</VerifyAuthenticatorForm>
);
}
function BackupRecoveryCodes(props: TwoFactorEnabledProps) {
const { onNext, onError } = props;
const [codes, setCodes] = useState<string[]>([]);
const recoveryCodesRef = useRef<HTMLDivElement>();
const generate = useCallback(async () => {
onError && onError("");
try {
const codes = await db.mfa?.codes();
if (codes) setCodes(codes);
} catch (e) {
const error = e as Error;
onError && onError(error.message);
}
}, [onError]);
useEffect(() => {
(async function () {
await generate();
})();
}, [generate]);
const actions = useMemo(
() => [
{
title: "Print",
icon: Print,
action: async () => {
if (!recoveryCodesRef.current) return;
await exportToPDF(
"Notesnook 2FA Recovery Codes",
recoveryCodesRef.current.outerHTML
);
},
},
{
title: "Copy",
icon: Copy,
action: async () => {
await clipboard.writeText(codes.join("\n"));
},
},
{
title: "Download",
icon: Download,
action: () => {
FileSaver.saveAs(
new Blob([Buffer.from(codes.join("\n"))]),
`notesnook-recovery-codes.txt`
);
},
},
{ title: "Regenerate", icon: Refresh, action: generate },
],
[codes, generate]
);
return (
<Flex
flexDirection={"column"}
as="form"
id="2faForm"
onSubmit={(e) => {
e.preventDefault();
onNext(props.authenticatorType);
}}
>
<Box
className="selectable"
ref={recoveryCodesRef}
sx={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr 1fr",
bg: "bgSecondary",
p: 2,
borderRadius: "default",
}}
>
{codes.map((code) => (
<Text
className="selectable"
as="code"
variant={"subheading"}
textAlign="center"
fontWeight="body"
fontFamily={"monospace"}
>
{code}
</Text>
))}
</Box>
<Flex sx={{ justifyContent: "start", alignItems: "center", mt: 2 }}>
{actions.map((action) => (
<Button
type="button"
variant="secondary"
mr={1}
py={1}
sx={{ display: "flex", alignItems: "center" }}
onClick={action.action}
>
<action.icon size={15} sx={{ mr: "2px" }} />
{action.title}
</Button>
))}
</Flex>
</Flex>
);
}
type TwoFactorEnabledProps = StepComponentProps & {
authenticatorType: AuthenticatorType;
};
function TwoFactorEnabled(props: TwoFactorEnabledProps) {
return (
<Flex
flexDirection={"column"}
justifyContent="center"
alignItems={"center"}
mb={2}
>
<MFA width={120} />
<Text variant={"heading"} fontSize="subheading" mt={2} textAlign="center">
Two-factor authentication enabled!
</Text>
<Text variant={"body"} color="fontTertiary" mt={1} textAlign="center">
Your account is now 100% secure against unauthorized logins.
</Text>
<Button mt={2} sx={{ borderRadius: 100, px: 6 }} onClick={props.onClose}>
Done
</Button>
<Button
variant={"anchor"}
mt={2}
onClick={() => {
props.onClose && props.onClose();
setTimeout(async () => {
await showMultifactorDialog(props.authenticatorType);
}, 100);
}}
>
Setup a fallback 2FA method
</Button>
</Flex>
);
}
type Fallback2FAEnabledProps = StepComponentProps & {
fallbackMethod: AuthenticatorType;
primaryMethod: AuthenticatorType;
};
function Fallback2FAEnabled(props: Fallback2FAEnabledProps) {
const { fallbackMethod, primaryMethod, onClose } = props;
return (
<Flex
flexDirection={"column"}
justifyContent="center"
alignItems={"center"}
mb={2}
>
<Fallback2FA width={200} />
<Text variant={"heading"} fontSize="subheading" mt={2} textAlign="center">
Fallback 2FA method enabled!
</Text>
<Text variant={"body"} color="fontTertiary" mt={1} textAlign="center">
You will now receive your 2FA codes on your{" "}
{mfaMethodToPhrase(fallbackMethod)} in case you lose access to your{" "}
{mfaMethodToPhrase(primaryMethod)}.
</Text>
<Button mt={2} sx={{ borderRadius: 100, px: 6 }} onClick={onClose}>
Done
</Button>
</Flex>
);
}
function VerifyAuthenticatorForm(props: VerifyAuthenticatorFormProps) {
const { codeHelpText, onSubmitCode, children } = props;
const formRef = useRef<HTMLFormElement>();
return (
<Flex
ref={formRef}
as="form"
id="2faForm"
flexDirection="column"
flex={1}
sx={{ overflow: "hidden" }}
onSubmit={async (e) => {
e.preventDefault();
const form = new FormData(formRef.current);
const code = form.get("code");
if (!code || code.toString().length !== 6) return;
onSubmitCode(code.toString());
}}
>
{children}
<Field
id="code"
name="code"
helpText={codeHelpText}
label="Enter the 6-digit code"
sx={{ alignItems: "center", mt: 2 }}
required
placeholder="010101"
min={99999}
max={999999}
type="number"
variant="clean"
styles={{
input: {
width: "100%",
fontSize: 38,
fontFamily: "monospace",
textAlign: "center",
},
}}
/>
</Flex>
);
}
export function mfaMethodToPhrase(method: AuthenticatorType): string {
return method === "email"
? "email"
: method === "app"
? "authentication app"
: "phone number";
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Flex, Text } from "rebass";
import { Button, Flex, Text } from "rebass";
import { Input, Label } from "@rebass/forms";
import * as Icon from "../icons";
@@ -48,6 +48,8 @@ function Field(props) {
placeholder,
validatePassword,
onError,
inputMode,
pattern,
variant = "input",
as = "input",
} = props;
@@ -96,6 +98,8 @@ function Field(props) {
disabled={disabled}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
pattern={pattern}
type={type || "text"}
sx={{
...styles.input,
@@ -148,10 +152,11 @@ function Field(props) {
</Flex>
)}
{action && (
<Flex
<Button
type="button"
variant={"secondary"}
data-test-id={action.testId}
onClick={action.onClick}
variant="rowCenter"
sx={{
position: "absolute",
right: "2px",
@@ -162,9 +167,10 @@ function Field(props) {
borderRadius: "default",
":hover": { bg: "border" },
}}
disabled={action.disabled}
>
<action.icon size={20} />
</Flex>
{action.component ? action.component : <action.icon size={20} />}
</Button>
)}
</Flex>
{validatePassword && (

View File

@@ -136,6 +136,10 @@ import {
mdiContentSaveCheckOutline,
mdiContentSaveAlertOutline,
mdiCurrencyUsd,
mdiCellphoneKey,
mdiEmailOutline,
mdiMessageLockOutline,
mdiShieldCheckOutline,
mdiAlertOctagonOutline,
mdiGithub,
mdiAlertCircleOutline,
@@ -144,8 +148,10 @@ import {
mdiCheckAll,
mdiCloudOffOutline,
mdiContentDuplicate,
mdiPrinterOutline,
mdiRefresh,
mdiRestore,
mdiVectorLink,
mdiCodeString,
mdiCodeBraces,
} from "@mdi/js";
import { useTheme } from "emotion-theming";
@@ -175,6 +181,7 @@ function createIcon(name, rotate = false) {
<AnimatedFlex
flexShrink={0}
id={props.id}
className={props.className}
title={props.title}
variant={props.variant}
whileHover={{ scale: 1.1 }}
@@ -295,6 +302,7 @@ export const Publish = createIcon(mdiCloudUploadOutline);
export const Colors = createIcon(mdiPaletteOutline);
export const Published = createIcon(mdiCloudCheckOutline);
export const Copy = createIcon(mdiContentCopy);
export const Refresh = createIcon(mdiRefresh);
export const Duplicate = createIcon(mdiContentDuplicate);
export const Select = createIcon(mdiCheckboxMultipleMarkedCircleOutline);
export const NotebookEdit = createIcon(mdiBookEditOutline);
@@ -314,6 +322,7 @@ export const Reddit = createIcon(mdiReddit);
export const Dismiss = createIcon(mdiClose);
export const File = createIcon(mdiFileOutline);
export const Download = createIcon(mdiArrowDown);
export const Print = createIcon(mdiPrinterOutline);
export const ImageDownload = createIcon(mdiImage);
export const Billboard = createIcon(mdiBillboard);
export const Cellphone = createIcon(mdiCellphone);
@@ -360,6 +369,11 @@ export const OrderNewestOldest = createIcon(mdiOrderNumericAscending);
export const Saved = createIcon(mdiContentSaveCheckOutline);
export const NotSaved = createIcon(mdiContentSaveAlertOutline);
export const MFAAuthenticator = createIcon(mdiCellphoneKey);
export const MFAEmail = createIcon(mdiEmailOutline);
export const MFARecoveryCode = createIcon(mdiRestore);
export const MFASMS = createIcon(mdiMessageLockOutline);
export const MFAEnabled = createIcon(mdiShieldCheckOutline);
export const Reupload = createIcon(mdiProgressUpload);
export const Rename = createIcon(mdiFormTextbox);
export const Upload = createIcon(mdiCloudOffOutline);

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Flex, Button } from "rebass";
import * as Icon from "../icons";
import { Virtuoso } from "react-virtuoso";
@@ -29,6 +29,12 @@ function ListContainer(props) {
[props.items]
);
useEffect(() => {
return () => {
selectionStore.toggleSelectionMode(false);
};
}, []);
return (
<Flex variant="columnFill">
{!props.items.length && props.placeholder ? (

View File

@@ -58,12 +58,6 @@ function ListItem(props) {
const selectItem = useSelectionStore((store) => store.selectItem);
useEffect(() => {
return () => {
selectionStore.toggleSelectionMode(false);
};
}, []);
return (
<Flex
ref={listItemRef}

View File

@@ -15,6 +15,7 @@ import IconTag from "../icon-tag";
import { COLORS } from "../../common/constants";
import { exportNotes } from "../../common/export";
import { Multiselect } from "../../common/multi-select";
import { store as selectionStore } from "../../stores/selection-store";
function Note(props) {
const { tags, notebook, item, index, context, date } = props;
@@ -46,14 +47,10 @@ function Note(props) {
index={index}
onKeyPress={async (e) => {
if (e.key === "Delete") {
await confirm({
title: "Delete note?",
message:
"This item will be kept in your Trash for 7 days after which it will be permanently removed",
noText: "No",
yesText: "Yes",
yesAction: () => Multiselect.moveNotesToTrash([item]),
});
let selectedItems = selectionStore
.get()
.selectedItems.filter((i) => i.type === item.type && i !== item);
await Multiselect.moveNotesToTrash([item, ...selectedItems]);
}
}}
colors={{
@@ -380,7 +377,7 @@ const menuItems = [
items.length === 1 && db.monographs.isPublished(items[0].id),
disableReason: "Please unpublish this note to move it to trash",
onClick: async ({ items }) => {
await Multiselect.moveNotesToTrash(items);
await Multiselect.moveNotesToTrash(items, items.length > 1);
},
multiSelect: true,
},

View File

@@ -0,0 +1,29 @@
import { useEffect, useRef } from "react";
import { useSessionState } from "../utils/hooks";
export function useTimer(id: string, duration: number) {
const [seconds, setSeconds] = useSessionState(id, duration);
const [enabled, setEnabled] = useSessionState(`${id}.canSendAgain`, true);
const interval = useRef<NodeJS.Timeout>();
useEffect(() => {
if (!enabled) {
interval.current = setInterval(() => {
setSeconds((seconds: number) => {
--seconds;
if (seconds <= 0) {
setEnabled(true);
if (interval.current) clearInterval(interval.current);
return duration;
}
return seconds;
});
}, 1000);
}
return () => {
if (interval.current) clearInterval(interval.current);
};
}, [enabled, setEnabled, setSeconds, duration]);
return { elapsed: seconds, enabled, setEnabled };
}

View File

@@ -1,3 +1,4 @@
import "notes-core/types";
import { EVENTS } from "@notesnook/desktop/events";
import { render } from "react-dom";
import { AppEventManager } from "./common/app-events";
@@ -21,19 +22,27 @@ const ROUTES = {
},
"/signup": {
component: () => import("./views/auth"),
props: { type: "signup" },
props: { route: "signup" },
},
"/sessionexpired": {
component: () => import("./views/auth"),
props: { type: "sessionexpired" },
props: { route: "sessionExpiry" },
},
"/login": {
component: () => import("./views/auth"),
props: { type: "login" },
props: { route: "login" },
},
"/recover": {
component: () => import("./views/auth"),
props: { type: "recover" },
props: { route: "recover" },
},
"/mfa/code": {
component: () => import("./views/auth"),
props: { route: "login" },
},
"/mfa/select": {
component: () => import("./views/auth"),
props: { route: "login" },
},
default: { component: () => import("./app"), props: {} },
};

View File

@@ -549,7 +549,7 @@ function parseS3Error(data) {
}
function handleS3Error(e, message) {
if (axios.isAxiosError(e)) {
if (axios.isAxiosError(e) && e.response?.data) {
const error = parseS3Error(e.response.data);
showToast("error", `${message}: [${error.Code}] ${error.Message}`);
} else {

View File

@@ -104,8 +104,9 @@ export function hardNavigate(route) {
window.open(makeURL(route, getCurrentHash()), "_self");
}
export function makeURL(route, hash) {
export function makeURL(route, hash, search) {
const url = new URL(route, window.location.origin);
if (!url.hash) url.hash = hash || getCurrentHash();
url.search = search || getQueryString();
return url;
}

View File

@@ -231,7 +231,8 @@ class EditorStore extends BaseStore {
});
noteStore.setSelectedNote(0);
this.toggleProperties(false);
if (shouldNavigate) hashNavigate(`/`, { replace: true });
if (shouldNavigate)
hashNavigate(`/notes/create`, { replace: true, addNonce: true });
};
setTitle = (sessionId, title) => {

View File

@@ -16,6 +16,7 @@ class NoteStore extends BaseStore {
notes = [];
context = undefined;
selectedNote = 0;
nonce = 0;
viewMode = Config.get("notes:viewMode", "detailed");
setViewMode = (viewMode) => {
@@ -43,6 +44,7 @@ class NoteStore extends BaseStore {
db.notes.all,
db.settings.getGroupOptions("home")
);
state.nonce = Math.random();
});
this.refreshContext();
};

View File

@@ -18,6 +18,9 @@ class UserStore extends BaseStore {
isLoggedIn = false;
isLoggingIn = false;
isSigningIn = false;
/**
* @type {User}
*/
user = undefined;
init = () => {
@@ -85,17 +88,25 @@ class UserStore extends BaseStore {
});
};
login = (form, skipInit = false) => {
refreshUser = async () => {
return db.user.fetchUser().then(async (user) => {
this.set((state) => (state.user = user));
});
};
login = async (form, skipInit = false) => {
this.set((state) => (state.isLoggingIn = true));
return db.user
.login(form.email.toLowerCase(), form.password)
.then(() => {
if (skipInit) return true;
return this.init();
})
.finally(() => {
this.set((state) => (state.isLoggingIn = false));
});
const { email, password, code, method } = form;
try {
if (code) await db.user.mfaLogin(email, password, { code, method });
else await db.user.login(email, password);
if (skipInit) return true;
return this.init();
} finally {
this.set((state) => (state.isLoggingIn = false));
}
};
signup = (form) => {

View File

@@ -9,7 +9,7 @@ class ButtonFactory {
anchor: new Anchor(),
tool: new Tool(),
icon: new Icon(),
shade: new Shade(),
dialog: new Dialog(),
statusitem: new StatusItem(),
menuitem: new MenuItem(),
};
@@ -37,7 +37,8 @@ class Default {
},
outline: "none",
":focus-visible:not(:active)": {
boxShadow: "0px 0px 0px 2px var(--text)",
filter: "brightness(90%)",
bg: "bgSecondary",
},
":disabled": {
opacity: 0.5,
@@ -57,9 +58,24 @@ class Primary {
}
}
class Shade {
class Dialog {
constructor() {
return { variant: "buttons.primary", color: "primary", bg: "shade" };
return {
variant: "buttons.primary",
color: "primary",
fontWeight: "bold",
bg: "transparent",
":hover": { bg: "bgSecondary" },
":focus:not(:active), :focus-within:not(:active), :focus-visible:not(:active)":
{
bg: "hover",
filter: "brightness(90%)",
},
":disabled": {
opacity: 0.7,
cursor: "not-allowed",
},
};
}
}

View File

@@ -2,6 +2,20 @@ import Config from "./config";
import { getPlatform } from "./platform";
import { appVersion } from "./version";
declare global {
interface Window {
umami?: {
trackEvent: (
value: string,
type: string,
url?: string,
websiteId?: string
) => void;
trackView: (url: string, referrer?: string, websiteId?: string) => void;
};
}
}
export function loadTrackerScript() {
if (Config.get("telemetry") === "false") return;
var script = document.createElement("script");
@@ -18,9 +32,15 @@ export function loadTrackerScript() {
script.onload = function () {
trackVisit();
};
firstScriptElement.parentNode.insertBefore(script, firstScriptElement);
firstScriptElement.parentNode?.insertBefore(script, firstScriptElement);
}
type TrackerEvent = {
name: string;
description: string;
type?: "event" | "view";
};
export const ANALYTICS_EVENTS = {
version: {
name: "version",
@@ -53,22 +73,30 @@ export const ANALYTICS_EVENTS = {
name: "announcement:cta",
description: "Sent whenever you an announcement CTA is invoked.",
},
};
accountCreated: {
name: "/account/created",
description: "Sent when you create an account.",
type: "view",
},
} as const;
export function trackEvent(event, eventMessage) {
export function trackEvent(event: TrackerEvent, eventMessage?: string) {
if (Config.get("telemetry") === "false") return;
if (window.umami) {
if (!window.umami) return;
if (event.type === "event" && eventMessage)
window.umami.trackEvent(eventMessage, event.name);
}
else trackVisit(event.name);
}
export function trackVisit() {
export function trackVisit(url: string = "/") {
if (Config.get("telemetry") === "false") return;
if (window.umami) {
window.umami.trackView("/");
const platform = getPlatform();
if (!window.umami || !platform) return;
window.umami.trackView(url);
if (url === "/")
trackEvent(
ANALYTICS_EVENTS.version,
`${appVersion.formatted}-${getPlatform().toLowerCase()}`
`${appVersion.formatted}-${platform.toLowerCase()}`
);
}
}

View File

@@ -1,70 +0,0 @@
import React from "react";
import CogoToast from "cogo-toast";
import { Button, Flex, Text } from "rebass";
import ThemeProvider from "../components/theme-provider";
import * as Icon from "../components/icons";
import { toTitleCase } from "./string";
import { store as appstore } from "../stores/app-store";
/**
*
* @returns {import("cogo-toast").CTReturn}
*/
function showToast(type, message, actions) {
if (appstore.get().isFocusMode) return null;
const IconComponent = Icon[toTitleCase(type)];
const toast = CogoToast[type];
if (!toast) return;
return toast(
<ToastContainer type={type} message={message} actions={actions} />,
{
position: "top-right",
hideAfter: actions ? 5 : type === "error" ? 5 : 3,
bar: { size: "0px" },
renderIcon: () => {
return (
<ThemeProvider>
<IconComponent size={28} color={type} />
</ThemeProvider>
);
},
}
);
}
function ToastContainer(props) {
const { type, message, actions } = props;
return (
<ThemeProvider>
<Flex
data-test-id="toast"
justifyContent="center"
alignContent="center"
my={2}
>
<Text
data-test-id="toast-message"
variant="body"
fontSize="body"
color="text"
mr={2}
>
{message}
</Text>
{actions?.map((action) => (
<Button
flexShrink={0}
variant="anchor"
fontSize="body"
color={type}
key={action.text}
onClick={action.onClick}
>
{action.text}
</Button>
))}
</Flex>
</ThemeProvider>
);
}
export { showToast };

View File

@@ -0,0 +1,96 @@
import CogoToast, { CTReturn } from "cogo-toast";
import { Button, Flex, Text } from "rebass";
import ThemeProvider from "../components/theme-provider";
import { Error, Warn, Success } from "../components/icons";
import { store as appstore } from "../stores/app-store";
type ToastType = "success" | "error" | "warn" | "info";
type ToastAction = {
text: string;
onClick: () => void;
type: "primary" | "text";
};
function showToast(
type: ToastType,
message: string,
actions?: ToastAction[],
hideAfter?: number
): CTReturn | null | undefined {
if (appstore.get().isFocusMode) return null;
const IconComponent =
type === "error" ? Error : type === "success" ? Success : Warn;
const toast = CogoToast[type];
if (!toast) return;
const t = toast(<ToastContainer message={message} actions={actions} />, {
position: "top-right",
hideAfter:
hideAfter === undefined
? actions
? 5
: type === "error"
? 5
: 3
: hideAfter,
bar: { size: "0px" },
renderIcon: () => {
return (
<ThemeProvider>
<IconComponent size={28} color={type} />
</ThemeProvider>
);
},
});
return t;
}
type ToastContainerProps = {
message: string;
actions?: ToastAction[];
};
function ToastContainer(props: ToastContainerProps) {
const { message, actions } = props;
return (
<ThemeProvider>
<Flex
data-test-id="toast"
justifyContent="center"
alignItems="center"
my={2}
sx={{ borderRadius: "default" }}
>
<Text
data-test-id="toast-message"
variant="body"
fontSize="body"
color="text"
mr={2}
>
{message}
</Text>
{actions?.map((action) => (
<Button
flexShrink={0}
variant="primary"
color={action.type}
fontWeight="bold"
bg={"transparent"}
fontSize="body"
sx={{
py: "7px",
":hover": { bg: "bgSecondary" },
m: 0,
}}
key={action.text}
onClick={action.onClick}
>
{action.text}
</Button>
))}
</Flex>
</ThemeProvider>
);
}
export { showToast };

View File

@@ -1,517 +0,0 @@
import { useEffect, useState } from "react";
import { Button, Flex, Text } from "rebass";
import {
CheckCircle,
Loading,
Error,
ArrowRight,
ArrowLeft,
} from "../components/icons";
import Field from "../components/field";
import { getQueryParams, hardNavigate, useQueryParams } from "../navigation";
import { store as userstore } from "../stores/user-store";
import { db } from "../common/db";
import Config from "../utils/config";
import useDatabase from "../hooks/use-database";
import Loader from "../components/loader";
import {
showLoadingDialog,
showLogoutConfirmation,
} from "../common/dialog-controller";
import { showToast } from "../utils/toast";
import AuthContainer from "../components/auth-container";
const authTypes = {
sessionexpired: {
title: "Your session has expired",
subtitle: {
text: (
<Flex bg="shade" p={1} sx={{ borderRadius: "default" }}>
<Text as="span" fontSize="body" color="primary">
<b>
All your local changes are safe and will be synced after you
relogin.
</b>{" "}
Please enter your password to continue.
</Text>
</Flex>
),
},
fields: [
{
id: "email",
name: "email",
label: "Your account email",
defaultValue: (user) => maskEmail(user?.email),
disabled: true,
autoComplete: "false",
type: "email",
},
{
id: "password",
name: "password",
label: "Enter your password",
autoComplete: "current-password",
type: "password",
autoFocus: true,
},
],
primaryAction: {
text: "Relogin to your account",
},
secondaryAction: {
text: <Text color="error">Logout permanently</Text>,
onClick: async () => {
if (await showLogoutConfirmation()) {
await showLoadingDialog({
title: "You are being logged out",
action: () => db.user.logout(true),
});
showToast("success", "You have been logged out.");
Config.set("sessionExpired", false);
window.location.replace("/login");
}
},
},
loading: {
title: "Logging you in",
text: "Please wait while you are authenticated.",
},
supportsPasswordRecovery: true,
onSubmit: async (form, onError) => {
return await userstore
.login(form)
.then(async () => {
Config.set("sessionExpired", false);
redirectToURL(form.redirect || "/");
})
.catch((e) => onError(e.message));
},
},
signup: {
title: "Create an account",
subtitle: {
text: "Already have an account?",
action: {
text: "Log in",
onClick: () => hardNavigate("/login", getQueryParams()),
},
},
fields: [
{
id: "email",
name: "email",
label: "Enter email",
autoComplete: "email",
type: "email",
autoFocus: true,
},
{
id: "password",
name: "password",
label: "Set password",
autoComplete: "new-password",
type: "password",
},
{
id: "confirm-password",
name: "confirmPassword",
label: "Confirm password",
autoComplete: "confirm-password",
type: "password",
},
],
primaryAction: {
text: "Agree & continue",
},
secondaryAction: {
text: "Continue without creating an account",
icon: <ArrowRight size={18} />,
onClick: () => {
redirectToURL("/");
},
},
loading: {
title: "Creating your account",
text: "Please wait while we finalize your account.",
},
footer: (
<>
By pressing "Create account" button, you agree to our{" "}
<Text
as="a"
color="text"
target="_blank"
rel="noreferrer"
href="https://notesnook.com/tos"
>
Terms of Service
</Text>{" "}
&amp;{" "}
<Text
as="a"
color="text"
rel="noreferrer"
href="https://notesnook.com/privacy"
>
Privacy Policy
</Text>
.
</>
),
onSubmit: async (form, onError) => {
if (form.password !== form.confirmPassword) {
onError("Passwords do not match.");
return;
}
return await userstore
.signup(form)
.then(() => {
redirectToURL("/notes/#/welcome");
})
.catch((e) => onError(e.message));
},
},
login: {
title: "Welcome back!",
subtitle: {
text: "Don't have an account?",
action: {
text: "Sign up!",
onClick: () => hardNavigate("/signup", getQueryParams()),
},
},
fields: [
{
type: "email",
id: "email",
name: "email",
label: "Enter email",
autoComplete: "email",
autoFocus: true,
defaultValue: (_user, form) => form.email,
},
{
type: "password",
id: "password",
name: "password",
label: "Enter password",
autoComplete: "current-password",
defaultValue: (_user, form) => form.password,
},
],
primaryAction: {
text: "Login to your account",
},
loading: {
title: "Logging you in",
text: "Please wait while you are authenticated.",
},
supportsPasswordRecovery: true,
onSubmit: async (form, onError) => {
return await userstore
.login(form)
.then(async () => {
redirectToURL(form.redirect || "/");
})
.catch((e) => onError(e.message));
},
},
recover: {
resetOnNavigate: false,
title: "Recover your account",
subtitle: {
text: "Remembered your password?",
action: {
text: "Log in",
onClick: () => hardNavigate("/login", getQueryParams()),
},
},
fields: [
{
type: "email",
id: "email",
name: "email",
label: "Enter your account email",
autoComplete: "email",
helpText:
"You will receive instructions on how to recover your account on this email",
autoFocus: true,
defaultValue: (user, form) => form?.email || user?.email,
},
],
primaryAction: {
text: "Send recovery email",
},
loading: {
title: "Sending recovery email",
text: "Please wait while we send you recovery instructions",
},
onSubmit: async (form, onError, onSuccess) => {
return await db.user
.recoverAccount(form.email.toLowerCase())
.then(async (url) => {
return redirectToURL(url);
// onSuccess(
// "Recovery email sent. Please check your inbox (and spam folder)."
// );
})
.catch((e) => onError(e.message));
},
},
};
function Auth(props) {
const { type } = props;
const [{ redirect }] = useQueryParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState();
const [success, setSuccess] = useState();
const [isAppLoaded] = useDatabase();
const [form, setForm] = useState({});
const [user, setUser] = useState();
const data = authTypes[type];
useEffect(() => {
if (isSubmitting) {
setError();
setSuccess();
}
}, [isSubmitting]);
useEffect(() => {
if (!isAppLoaded) return;
(async () => {
const user = await db.user.getUser();
const isSessionExpired = Config.get("sessionExpired", false);
if (user) {
if (
(type === "recover" || type === "sessionexpired") &&
isSessionExpired
)
setUser(user);
else redirectToURL("/");
} else if (type === "sessionexpired") {
redirectToURL("/");
}
})();
}, [isAppLoaded, type]);
return (
<AuthContainer>
{isSubmitting ? (
<>
<Loader title={data.loading.title} text={data.loading.text} />
</>
) : (
<Flex
flexDirection={"column"}
sx={{
zIndex: 1,
flex: 1,
overflowY: "auto",
}}
>
{data.secondaryAction ? (
<>
<Button
type="button"
variant="icon"
mr={[2, 2, 4]}
mt={[2, 2, 4]}
alignSelf="end"
onClick={data.secondaryAction.onClick}
sx={{
display: "flex",
alignItems: "center",
borderRadius: "default",
color: "icon",
}}
>
<Text mr={1}>{data.secondaryAction.text}</Text>
{data.secondaryAction.icon}
</Button>
</>
) : (
<Button
type="button"
variant="icon"
ml={[2, 2, 4]}
mt={[2, 2, 4]}
alignSelf="start"
title="Go to app"
onClick={() => hardNavigate("/")}
sx={{
display: "flex",
alignItems: "center",
borderRadius: "default",
color: "icon",
}}
>
<ArrowLeft />
</Button>
)}
<Flex
as="form"
id="authForm"
flexDirection="column"
alignSelf="center"
justifyContent={"center"}
alignItems="center"
flex={1}
onSubmit={async (e) => {
console.log(e);
e.preventDefault();
setIsSubmitting(true);
const formData = new FormData(e.target);
const form = Object.fromEntries(formData.entries());
form.redirect = redirect;
if (user) form.email = user.email;
setForm(form);
await data.onSubmit(
form,
(error) => {
setIsSubmitting(false);
setError(error);
},
(message) => {
setSuccess(message);
setIsSubmitting(false);
}
);
}}
>
<Text variant={"heading"} fontSize={32} textAlign="center">
{data.title}
</Text>
<Text
variant="body"
fontSize={"title"}
textAlign="center"
mt={2}
mb={35}
color="fontTertiary"
>
{data.subtitle.text}{" "}
{data.subtitle.action && (
<Text
sx={{
textDecoration: "underline",
":hover": { color: "dimPrimary" },
cursor: "pointer",
}}
as="b"
color="text"
onClick={data.subtitle.action.onClick}
>
{data.subtitle.action.text}
</Text>
)}
</Text>
{success && (
<Flex bg="shade" p={1} mt={2} sx={{ borderRadius: "default" }}>
<CheckCircle size={15} color="primary" />
<Text variant="error" color="primary" ml={1}>
{success}
</Text>
</Flex>
)}
{data.fields?.map(({ defaultValue, id, autoFocus, ...rest }) => (
<Field
{...rest}
id={id}
key={id}
required
styles={{
container: { mt: 2, width: 400 },
label: { fontWeight: "normal" },
input: {
p: "12px",
borderRadius: "default",
bg: "background",
boxShadow: "0px 0px 5px 0px #00000019",
},
}}
data-test-id={id}
autoFocus={autoFocus}
defaultValue={defaultValue && defaultValue(user, form)}
/>
))}
{data.supportsPasswordRecovery && (
<Button
type="button"
alignSelf="end"
data-test-id="auth-forgot-password"
mt={2}
variant="anchor"
color="text"
onClick={() => hardNavigate("/recover", getQueryParams())}
>
Forgot password?
</Button>
)}
<Button
data-test-id="submitButton"
display="flex"
type="submit"
mt={50}
variant="primary"
alignSelf={"center"}
px={50}
sx={{ borderRadius: 50 }}
justifyContent="center"
alignItems="center"
disabled={!isAppLoaded}
>
{isAppLoaded ? (
data.primaryAction.text
) : (
<Loading color="static" />
)}
</Button>
{error && (
<Flex bg="errorBg" p={1} mt={2} sx={{ borderRadius: "default" }}>
<Error size={15} color="error" />
<Text variant="error" ml={1}>
{error}
</Text>
</Flex>
)}
{data.footer && (
<Text
mt={4}
maxWidth={350}
variant="subBody"
fontSize={13}
textAlign="center"
>
{data.footer}
</Text>
)}
</Flex>
</Flex>
)}
</AuthContainer>
);
}
export default Auth;
function redirectToURL(url) {
Config.set("skipInitiation", true);
hardNavigate(url);
}
function maskEmail(email) {
if (!email) return "";
const [username, domain] = email.split("@");
const maskChars = "*".repeat(
username.substring(2, username.length - 2).length
);
return `${username.substring(0, 2)}${maskChars}${username.substring(
username.length - 2
)}@${domain}`;
}

938
apps/web/src/views/auth.tsx Normal file
View File

@@ -0,0 +1,938 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Flex, Text } from "rebass";
import {
CheckCircle,
Loading,
Error as ErrorIcon,
MFAAuthenticator,
MFASMS,
MFAEmail,
MFARecoveryCode,
ArrowRight,
Logout,
} from "../components/icons";
import Field from "../components/field";
import { getQueryParams, hardNavigate, makeURL } from "../navigation";
import { store as userstore } from "../stores/user-store";
import { db } from "../common/db";
import Config from "../utils/config";
import useDatabase from "../hooks/use-database";
import Loader from "../components/loader";
import { showToast } from "../utils/toast";
import AuthContainer from "../components/auth-container";
import { isTesting } from "../utils/platform";
import { AuthenticatorType } from "../components/dialogs/multi-factor-dialog";
import { RequestError } from "notes-core/utils/http";
import { useTimer } from "../hooks/use-timer";
import { ANALYTICS_EVENTS, trackEvent } from "../utils/analytics";
type LoginFormData = {
email: string;
password: string;
};
type MFALoginFormData = LoginFormData & {
code?: string;
method?: MFAMethodType;
};
type SignupFormData = LoginFormData & {
confirmPassword: string;
};
type AccountRecoveryFormData = {
email: string;
};
type MFAFormData = LoginFormData & {
selectedMethod: MFAMethodType;
primaryMethod: MFAMethodType;
code?: string;
token: string;
secondaryMethod?: MFAMethodType;
phoneNumber?: string;
};
type MFAErrorData = {
primaryMethod: MFAMethodType;
token: string;
secondaryMethod?: MFAMethodType;
phoneNumber?: string;
};
type AuthFormData = {
login: LoginFormData;
signup: SignupFormData;
sessionExpiry: LoginFormData;
recover: AccountRecoveryFormData;
"mfa:code": MFAFormData;
"mfa:select": MFAFormData;
};
type BaseFormData =
| MFAFormData
| LoginFormData
| AccountRecoveryFormData
| SignupFormData;
type NavigateFunction = <TRoute extends AuthRoutes>(
route: TRoute,
formData?: AuthFormData[TRoute]
) => void;
type BaseAuthComponentProps<TRoute extends AuthRoutes> = {
navigate: NavigateFunction;
formData?: AuthFormData[TRoute];
};
type AuthRoutes =
| "sessionExpiry"
| "login"
| "signup"
| "recover"
| "mfa:code"
| "mfa:select";
type AuthProps = { route: AuthRoutes };
type AuthComponent<TRoute extends AuthRoutes> = (
props: BaseAuthComponentProps<TRoute>
) => JSX.Element;
function getRouteComponent<TRoute extends AuthRoutes>(
route: TRoute
): AuthComponent<TRoute> | undefined {
switch (route) {
case "login":
return Login as AuthComponent<TRoute>;
case "signup":
return Signup as AuthComponent<TRoute>;
case "sessionExpiry":
return SessionExpiry as AuthComponent<TRoute>;
case "recover":
return AccountRecovery as AuthComponent<TRoute>;
case "mfa:code":
return MFACode as AuthComponent<TRoute>;
case "mfa:select":
return MFASelector as AuthComponent<TRoute>;
}
return undefined;
}
const routePaths: Record<AuthRoutes, string> = {
login: "/login",
recover: "/recover",
sessionExpiry: "/sessionexpired",
signup: "/signup",
"mfa:code": "/mfa/code",
"mfa:select": "/mfa/select",
};
function Auth(props: AuthProps) {
const [route, setRoute] = useState(props.route);
const [storedFormData, setStoredFormData] = useState<
BaseFormData | undefined
>();
const Route = useMemo(() => getRouteComponent(route), [route]);
useEffect(() => {
window.history.replaceState({}, "", makeURL(routePaths[route]));
}, [route]);
return (
<AuthContainer>
<Flex
flexDirection={"column"}
sx={{
zIndex: 1,
flex: 1,
overflowY: "auto",
}}
>
{route === "login" || route === "signup" || route === "recover" ? (
<Button
sx={{
display: "flex",
mt: 2,
mr: 2,
alignSelf: "end",
alignItems: "center",
}}
variant={"secondary"}
onClick={() => openURL("/notes/")}
>
Jump to app <ArrowRight size={18} sx={{ ml: 1 }} />
</Button>
) : route === "sessionExpiry" ? (
<>
<Button
variant={"secondary"}
sx={{
display: "flex",
mt: 2,
mr: 2,
alignSelf: "end",
alignItems: "center",
}}
onClick={() => db.user?.logout()}
color="error"
>
<Logout size={16} sx={{ mr: 1 }} color="error" /> Logout
permanently
</Button>
</>
) : null}
{Route && (
<Route
navigate={(route, formData) => {
setStoredFormData(formData);
setRoute(route);
}}
formData={storedFormData}
/>
)}
</Flex>
</AuthContainer>
);
}
export default Auth;
function Login(props: BaseAuthComponentProps<"login">) {
const { navigate } = props;
const [isAppLoaded] = useDatabase();
return (
<AuthForm
type="login"
title="Welcome back!"
subtitle={
<SubtitleWithAction
text="Don't have an account?"
action={{ text: "Sign up", onClick: () => navigate("signup") }}
/>
}
loading={{
title: "Logging you in",
subtitle: "Please wait while you are authenticated.",
}}
onSubmit={(form) => login(form, navigate)}
>
{(form?: LoginFormData) => (
<>
<AuthField
id="email"
type="email"
autoComplete="email"
label="Enter email"
autoFocus={!form?.password}
defaultValue={form?.email}
/>
<AuthField
id="password"
type="password"
autoComplete="current-password"
label="Enter password"
autoFocus={!!form?.password}
/>
<Button
data-test-id="auth-forgot-password"
type="button"
alignSelf="end"
mt={2}
variant="anchor"
color="text"
onClick={() => navigate("recover")}
>
Forgot password?
</Button>
<SubmitButton
text="Login to your account"
disabled={!isAppLoaded}
loading={!isAppLoaded}
/>
</>
)}
</AuthForm>
);
}
function Signup(props: BaseAuthComponentProps<"signup">) {
const { navigate } = props;
const [isAppLoaded] = useDatabase();
return (
<AuthForm
type="signup"
title="Create an account"
subtitle={
<SubtitleWithAction
text="Already have an account?"
action={{ text: "Log in", onClick: () => navigate("login") }}
/>
}
loading={{
title: "Creating your account",
subtitle: "Please wait while we finalize your account.",
}}
onSubmit={async (form) => {
if (form.password !== form.confirmPassword) {
throw new Error("Passwords do not match.");
}
await userstore.signup(form);
trackEvent(ANALYTICS_EVENTS.accountCreated);
openURL("/notes/#/welcome");
}}
>
{(form?: SignupFormData) => (
<>
<AuthField
id="email"
type="email"
autoComplete="email"
label="Enter email"
autoFocus
defaultValue={form?.email}
/>
<AuthField
id="password"
type="password"
autoComplete="current-password"
label="Set password"
defaultValue={form?.password}
/>
<AuthField
id="confirm-password"
type="password"
autoComplete="confirm-password"
label="Confirm password"
defaultValue={form?.confirmPassword}
/>
<SubmitButton
text="Create account"
disabled={!isAppLoaded}
loading={!isAppLoaded}
/>
<Text mt={4} variant="subBody" fontSize={13} textAlign="center">
By pressing "Create account" button, you agree to our{" "}
<Text
as="a"
color="primary"
target="_blank"
rel="noreferrer"
href="https://notesnook.com/tos"
>
Terms of Service
</Text>{" "}
&amp;{" "}
<Text
as="a"
color="primary"
rel="noreferrer"
href="https://notesnook.com/privacy"
>
Privacy Policy
</Text>
.
</Text>
</>
)}
</AuthForm>
);
}
function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
const { navigate } = props;
const [isAppLoaded] = useDatabase();
const [user, setUser] = useState<User | undefined>();
useEffect(() => {
if (!isAppLoaded) return;
(async () => {
const user = await db.user?.getUser();
const isSessionExpired = Config.get("sessionExpired", false);
if (user && isSessionExpired) {
setUser(user);
} else openURL("/");
})();
}, [isAppLoaded]);
return (
<AuthForm
type="sessionExpiry"
title="Your session has expired"
subtitle={
<Flex bg="shade" p={1} sx={{ borderRadius: "default" }}>
<Text as="span" fontSize="body" color="primary">
<b>
All your local changes are safe and will be synced after you
login.
</b>{" "}
Please enter your password to continue.
</Text>
</Flex>
}
loading={{
title: "Logging you in",
subtitle: "Please wait while you are authenticated.",
}}
onSubmit={async (form) => {
if (!user) return;
await login({ email: user.email, password: form.password }, navigate);
}}
>
<AuthField
id="email"
type="email"
autoComplete={"false"}
label="Enter email"
defaultValue={user ? maskEmail(user.email) : undefined}
autoFocus
disabled
/>
<AuthField
id="password"
type="password"
autoComplete="current-password"
label="Enter password"
/>
<Button
data-test-id="auth-forgot-password"
type="button"
alignSelf="end"
mt={2}
variant="anchor"
color="text"
onClick={() => navigate("recover", { email: user!.email })}
>
Forgot password?
</Button>
<SubmitButton
text="Relogin to your account"
disabled={!isAppLoaded}
loading={!isAppLoaded}
/>
</AuthForm>
);
}
function AccountRecovery(props: BaseAuthComponentProps<"recover">) {
const { navigate, formData } = props;
const [isAppLoaded] = useDatabase();
const [success, setSuccess] = useState<string>();
return (
<AuthForm
type="recover"
title="Recover your account"
subtitle={
<SubtitleWithAction
text="Remembered your password?"
action={{ text: "Log in", onClick: () => navigate("login") }}
/>
}
loading={{
title: "Sending recovery email",
subtitle: "Please wait while we send you recovery instructions.",
}}
onSubmit={async (form) => {
const url = await db.user?.recoverAccount(form.email.toLowerCase());
if (isTesting()) return openURL(url);
setSuccess(
`Recovery email sent. Please check your inbox (and spam folder) for further instructions.`
);
}}
>
{success ? (
<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>
) : (
<>
<AuthField
id="email"
type="email"
autoComplete={"email"}
label="Enter your account email"
helpText="You will receive instructions on how to recover your account on this email"
defaultValue={formData ? formData.email : ""}
autoFocus
/>
<SubmitButton
text="Send recovery email"
disabled={!isAppLoaded}
loading={!isAppLoaded}
/>
</>
)}
</AuthForm>
);
}
function getTexts(formData: MFAFormData) {
return {
app: {
subtitle:
"Please confirm your identity by entering the authentication code from your authenticator app.",
instructions: `Open the two-factor authentication (TOTP) app to view your authentication code.`,
selector: `Don't have access to your authenticator app?`,
label: "Enter 6-digit code",
},
email: {
subtitle:
"Please confirm your identity by entering the authentication code sent to your email address.",
instructions: `It may take a minute to receive your code.`,
selector: `Don't have access to your email address?`,
label: "Enter 6-digit code",
},
sms: {
subtitle: `Please confirm your identity by entering the authentication code sent to ${
formData.phoneNumber || "your registered phone number."
}.`,
instructions: `It may take a minute to receive your code.`,
selector: `Don't have access to your phone number?`,
label: "Enter 6-digit code",
},
recoveryCode: {
subtitle: `Please confirm your identity by entering a recovery code.`,
instructions: "",
selector: `Don't have your recovery codes?`,
label: "Enter recovery code",
},
};
}
function MFACode(props: BaseAuthComponentProps<"mfa:code">) {
const { navigate, formData } = props;
const [isAppLoaded] = useDatabase();
const [isSending, setIsSending] = useState(false);
const { elapsed, enabled, setEnabled } = useTimer(
`2fa.${formData?.primaryMethod}`,
60
);
const sendCode = useCallback(
async (selectedMethod, token) => {
setIsSending(true);
try {
await db.mfa!.sendCode(selectedMethod, token);
setEnabled(false);
} catch (e) {
const error = e as Error;
console.error(error);
showToast("error", error.message);
} finally {
setIsSending(false);
}
},
[setEnabled]
);
useEffect(() => {
if (
!formData ||
formData.selectedMethod === "recoveryCode" ||
formData.selectedMethod === "app"
)
return;
(async function () {
await sendCode(formData.selectedMethod, formData.token);
})();
}, [formData, sendCode]);
if (!formData) {
openURL("/");
return null;
}
const { selectedMethod, token } = formData;
const texts = getTexts(formData)[selectedMethod];
return (
<AuthForm
type="mfa:code"
title="Two-factor authentication"
subtitle={texts.subtitle}
loading={{
title: "Logging you in",
subtitle: "Please wait while you are authenticated.",
}}
onSubmit={async (form) => {
const loginForm: MFALoginFormData = {
email: formData.email,
password: formData.password,
code: form.code,
method: formData.selectedMethod,
};
await login(loginForm, navigate);
}}
>
<AuthField
id="code"
type="number"
autoComplete={"one-time-code"}
label={texts.label}
autoFocus
pattern="[0-9]*"
inputMode="numeric"
helpText={texts.instructions}
action={
selectedMethod === "sms" || selectedMethod === "email"
? {
disabled: isSending || !enabled,
component: (
<Text variant={"body"}>
{isSending ? (
<Loading size={18} />
) : enabled ? (
`Resend code`
) : (
`Resend in ${elapsed}`
)}
</Text>
),
onClick: async () => {
await sendCode(selectedMethod, token);
},
}
: undefined
}
/>
<SubmitButton
text="Submit"
disabled={!isAppLoaded}
loading={!isAppLoaded}
/>
<Button
type="button"
mt={4}
variant={"anchor"}
color="text"
onClick={() => navigate("mfa:select", formData)}
>
{texts.selector}
</Button>
</AuthForm>
);
}
type MFAMethodType = AuthenticatorType | "recoveryCode";
type MFAMethod = {
type: MFAMethodType;
title: string;
icon: (props: any) => JSX.Element;
};
const MFAMethods: MFAMethod[] = [
{ type: "app", title: "Use an authenticator app", icon: MFAAuthenticator },
{ type: "sms", title: "Send code to your phone number", icon: MFASMS },
{ type: "email", title: "Send code to your email address", icon: MFAEmail },
{ type: "recoveryCode", title: "Use a recovery code", icon: MFARecoveryCode },
];
function MFASelector(props: BaseAuthComponentProps<"mfa:select">) {
const { navigate, formData } = props;
const [selected, setSelected] = useState(0);
const isValidMethod = useCallback(
(method: MFAMethodType) => {
return (
method === formData?.primaryMethod ||
method === formData?.secondaryMethod ||
method === "recoveryCode"
);
},
[formData]
);
if (!formData) {
openURL("/");
return null;
}
return (
<AuthForm
type="mfa:select"
title="Select two-factor authentication method"
subtitle={`Where should we send you the authentication code?`}
loading={{
title: "Logging you in",
subtitle: "Please wait while you are authenticated.",
}}
onSubmit={async (form) => {
const selectedType = MFAMethods[selected];
formData.selectedMethod = selectedType.type;
navigate("mfa:code", formData);
}}
>
{MFAMethods.map(
(method, index) =>
isValidMethod(method.type) && (
<Button
type="submit"
variant={"secondary"}
mt={2}
sx={{
":first-of-type": { mt: 2 },
display: "flex",
bg: "bgSecondary",
alignSelf: "stretch",
alignItems: "center",
textAlign: "left",
px: 2,
}}
onClick={() => setSelected(index)}
>
<method.icon
sx={{
bg: selected === index ? "shade" : "border",
borderRadius: 100,
width: 35,
height: 35,
mr: 2,
}}
size={16}
color={selected === index ? "primary" : "text"}
/>
<Text variant={"title"} fontWeight="body">
{method.title}
</Text>
</Button>
)
)}
{/* <SubmitButton
text="Submit"
disabled={!isAppLoaded}
loading={!isAppLoaded}
/> */}
{/* <Button type="button" mt={4} variant={"anchor"} color="text">
Don't have access to your {mfaMethodToPhrase(formData.primaryMethod)}?
</Button> */}
</AuthForm>
);
}
// function MFAMethodSelector(params) {}
type AuthFormProps<TType extends AuthRoutes> = {
title: string;
subtitle: string | JSX.Element;
loading: { title: string; subtitle: string };
type: TType;
onSubmit: (form: AuthFormData[TType]) => Promise<void>;
children?:
| React.ReactNode
| ((form?: AuthFormData[TType]) => React.ReactNode);
};
function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
const { title, subtitle, children } = props;
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>();
const formRef = useRef<HTMLFormElement>();
const [form, setForm] = useState<AuthFormData[T] | undefined>();
if (isSubmitting)
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 AuthFormData[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>
);
}
type SubtitleWithActionProps = {
text: string;
action: {
text: string;
onClick: () => void;
};
};
function SubtitleWithAction(props: SubtitleWithActionProps) {
return (
<>
{props.text}{" "}
<Text
sx={{
textDecoration: "underline",
":hover": { color: "dimPrimary" },
cursor: "pointer",
}}
as="b"
color="text"
onClick={props.action.onClick}
>
{props.action.text}
</Text>
</>
);
}
type AuthFieldProps = {
id: string;
type: string;
autoFocus?: boolean;
autoComplete: string;
label?: string;
placeholder?: string;
helpText?: string;
defaultValue?: string;
disabled?: boolean;
inputMode?: string;
pattern?: string;
action?: {
disabled?: boolean;
component?: JSX.Element;
onClick?: () => void | Promise<void>;
};
};
function AuthField(props: AuthFieldProps) {
return (
<Field
type={props.type}
id={props.id}
name={props.id}
data-test-id={props.id}
autoComplete={props.autoComplete}
label={props.label}
autoFocus={props.autoFocus}
defaultValue={props.defaultValue}
helpText={props.helpText}
disabled={props.disabled}
pattern={props.pattern}
inputMode={props.inputMode}
placeholder={props.placeholder}
required
action={props.action}
styles={{
container: { mt: 2, width: "100%" },
// label: { fontWeight: "normal" },
input: {
p: "12px",
borderRadius: "default",
bg: "background",
boxShadow: "0px 0px 5px 0px #00000019",
},
}}
/>
);
}
type SubmitButtonProps = {
text: string;
disabled?: boolean;
loading?: boolean;
};
function SubmitButton(props: SubmitButtonProps) {
return (
<Button
data-test-id="submitButton"
display="flex"
type="submit"
mt={50}
variant="primary"
alignSelf={"center"}
px={50}
sx={{ borderRadius: 50 }}
justifyContent="center"
alignItems="center"
disabled={props.disabled}
>
{props.loading ? <Loading color="static" /> : props.text}
</Button>
);
}
async function login(
form: LoginFormData | MFALoginFormData,
navigate: NavigateFunction
) {
try {
await userstore.login(form);
Config.set("sessionExpired", false);
openURL("/");
} catch (e) {
if (e instanceof RequestError && e.code === "mfa_required") {
const { primaryMethod, phoneNumber, secondaryMethod, token } =
e.data as MFAErrorData;
if (!primaryMethod)
throw new Error(
"Multi-factor is required but the server didn't send a primary MFA method."
);
navigate("mfa:code", {
...form,
token,
selectedMethod: primaryMethod,
primaryMethod,
phoneNumber,
secondaryMethod,
});
} else throw e;
}
}
function openURL(url: string, force?: boolean) {
const queryParams = getQueryParams();
const redirect = queryParams?.redirect;
Config.set("skipInitiation", true);
hardNavigate(force ? url : redirect || url);
}
function maskEmail(email: string) {
if (!email) return "";
const [username, domain] = email.split("@");
const maskChars = "*".repeat(
username.substring(2, username.length - 2).length
);
return `${username.substring(0, 2)}${maskChars}${username.substring(
username.length - 2
)}@${domain}`;
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ListContainer from "../components/list-container";
import SearchPlaceholder from "../components/placeholders/search-placeholder";
import { db } from "../common/db";
@@ -40,6 +40,31 @@ function Search({ type }) {
});
const [results, setResults] = useState([]);
const context = useNoteStore((store) => store.context);
const nonce = useNoteStore((store) => store.nonce);
const cachedQuery = useRef();
const onSearch = useCallback(
async (query) => {
if (!query) return;
cachedQuery.current = query;
const [lookupType, items] = await typeToItems(type, context);
setResults([]);
if (items.length <= 0) {
showToast("error", `There are no items to search in.`);
return;
}
setSearchState({ isSearching: true, totalItems: items.length });
const results = await db.lookup[lookupType](items, query);
setResults(results);
setSearchState({ isSearching: false, totalItems: 0 });
if (!results.length) {
showToast("error", `Nothing found for "${query}".`);
}
},
[context, type]
);
const title = useMemo(() => {
switch (type) {
@@ -77,6 +102,10 @@ function Search({ type }) {
}
}, [type, context]);
useEffect(() => {
onSearch(cachedQuery.current);
}, [nonce, onSearch]);
if (!title) return hardNavigate("/");
return (
@@ -84,25 +113,7 @@ function Search({ type }) {
<Text variant="subtitle" mx={2}>
Searching {title}
</Text>
<SearchBox
onSearch={async (query) => {
if (!query) return;
const [lookupType, items] = await typeToItems(type, context);
setResults([]);
if (items.length <= 0) {
showToast("error", `There are no items to search in.`);
return;
}
setSearchState({ isSearching: true, totalItems: items.length });
const results = await db.lookup[lookupType](items, query);
setResults(results);
setSearchState({ isSearching: false, totalItems: 0 });
if (!results.length) {
showToast("error", `Nothing found for "${query}".`);
}
}}
/>
<SearchBox onSearch={onSearch} />
{searchState.isSearching ? (
<Flex
flex="1"

View File

@@ -19,7 +19,9 @@ import {
showLoadingDialog,
showBuyDialog,
showPasswordDialog,
showMultifactorDialog,
showAttachmentsDialog,
show2FARecoveryCodesDialog,
} from "../common/dialog-controller";
import { SUBSCRIPTION_STATUS } from "../common/constants";
import { createBackup, verifyAccount } from "../common";
@@ -129,6 +131,7 @@ const otherItems = [
function Settings(props) {
const [groups, setGroups] = useState({
appearance: false,
mfa: false,
backup: false,
importer: false,
privacy: false,
@@ -153,6 +156,7 @@ function Settings(props) {
(store) => store.toggleEncryptBackups
);
const user = useUserStore((store) => store.user);
const refreshUser = useUserStore((store) => store.refreshUser);
const isLoggedIn = useUserStore((store) => store.isLoggedIn);
const [backupReminderOffset, setBackupReminderOffset] = usePersistentState(
"backupReminderOffset",
@@ -284,6 +288,93 @@ function Settings(props) {
</Button>
</>
)}
{isLoggedIn && user.mfa && (
<>
<Header
title="Two-factor authentication"
isOpen={groups.mfa}
onClick={() => {
setGroups((g) => ({ ...g, mfa: !g.mfa }));
}}
/>
{groups.mfa &&
(user.mfa.isEnabled ? (
<>
<Button
variant="list"
onClick={async () => {
if (await verifyAccount()) {
await showMultifactorDialog(user.mfa.primaryMethod);
await refreshUser();
}
}}
>
<Tip
text={
user.mfa.secondaryMethod
? "Reconfigure fallback 2FA method"
: "Add fallback 2FA method"
}
tip="You can use the fallback 2FA method in case you are unable to login via the primary method."
/>
</Button>
<Button
variant="list"
onClick={async () => {
if (await verifyAccount()) {
await show2FARecoveryCodesDialog(
user.mfa.primaryMethod
);
await refreshUser();
}
}}
>
<Tip
text="View recovery codes"
tip={`Recovery codes can be used to login in case you cannot use any of the other 2FA methods. You have ${user.mfa.remainingValidCodes} recovery codes left.`}
/>
</Button>
<Button
variant="list"
onClick={async () => {
if (await verifyAccount()) {
await db.mfa.disable();
showToast(
"success",
"Two-factor authentication disabled."
);
await refreshUser();
}
}}
>
<Tip
text="Disable two-factor authentication"
tip="You can disable 2FA if you want to reset or change 2FA settings."
/>
</Button>
</>
) : (
<>
<Button
variant="list"
onClick={async () => {
if (await verifyAccount()) {
await showMultifactorDialog();
await refreshUser();
}
}}
>
<Tip
text="Enable two-factor authentication"
tip="Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to sign in."
/>
</Button>
</>
))}
</>
)}
<Header
title="Appearance"
isOpen={groups.appearance}
@@ -364,6 +455,7 @@ function Settings(props) {
setGroups((g) => ({ ...g, backup: !g.backup }));
}}
/>
{groups.backup && (
<>
<Button

View File

@@ -3,6 +3,8 @@
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"downlevelIteration": true,
"maxNodeModuleJsDepth": 1,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,