From eed1a8565d4a7ec2435cb73b3d91e23d0e670659 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Wed, 23 Mar 2022 09:15:49 +0500 Subject: [PATCH 01/19] feat: add 2fa support (#221) * feat: add mfa configuration ui * feat: add 2fa login step * feat: improve 2fa enabling ux * feat: finalize 2fa setup * refactor: move useTimer to its own hook * feat: finalize 2fa settings * feat: add 2fa on auth * chore: update packages * chore: replace Google Auth with Aegis & Raivo --- apps/web/package.json | 1 + apps/web/src/assets/fallback2fa.svg | 7 + apps/web/src/assets/mfa.svg | 6 + apps/web/src/common/dialogcontroller.js | 18 + apps/web/src/common/export.ts | 9 +- apps/web/src/components/dialogs/index.js | 3 + .../components/dialogs/multifactordialog.tsx | 806 ++++++++++++++++ apps/web/src/components/field/index.js | 16 +- apps/web/src/components/icons/index.js | 16 +- apps/web/src/hooks/use-timer.ts | 29 + apps/web/src/index.js | 16 +- apps/web/src/navigation/index.js | 3 +- apps/web/src/stores/user-store.js | 31 +- apps/web/src/views/auth.js | 517 ----------- apps/web/src/views/auth.tsx | 877 ++++++++++++++++++ apps/web/src/views/settings.js | 92 ++ apps/web/tsconfig.json | 2 + 17 files changed, 1909 insertions(+), 540 deletions(-) create mode 100644 apps/web/src/assets/fallback2fa.svg create mode 100644 apps/web/src/assets/mfa.svg create mode 100644 apps/web/src/components/dialogs/multifactordialog.tsx create mode 100644 apps/web/src/hooks/use-timer.ts delete mode 100644 apps/web/src/views/auth.js create mode 100644 apps/web/src/views/auth.tsx diff --git a/apps/web/package.json b/apps/web/package.json index c56ecb753..20f03fe2d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/assets/fallback2fa.svg b/apps/web/src/assets/fallback2fa.svg new file mode 100644 index 000000000..906092ea5 --- /dev/null +++ b/apps/web/src/assets/fallback2fa.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/mfa.svg b/apps/web/src/assets/mfa.svg new file mode 100644 index 000000000..8df864e0c --- /dev/null +++ b/apps/web/src/assets/mfa.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/web/src/common/dialogcontroller.js b/apps/web/src/common/dialogcontroller.js index 7558cc885..e19b83478 100644 --- a/apps/web/src/common/dialogcontroller.js +++ b/apps/web/src/common/dialogcontroller.js @@ -607,6 +607,24 @@ export function showImportDialog() { )); } +export function showMultifactorDialog(primaryMethod = undefined) { + return showDialog((Dialogs, perform) => ( + perform(res)} + primaryMethod={primaryMethod} + /> + )); +} + +export function show2FARecoveryCodesDialog(primaryMethod) { + return showDialog((Dialogs, perform) => ( + perform(res)} + primaryMethod={primaryMethod} + /> + )); +} + export function showAttachmentsDialog() { return showDialog((Dialogs, perform) => ( perform(res)} /> diff --git a/apps/web/src/common/export.ts b/apps/web/src/common/export.ts index a2add2cb7..bc1d8b318 100644 --- a/apps/web/src/common/export.ts +++ b/apps/web/src/common/export.ts @@ -3,13 +3,18 @@ import { TaskManager } from "./task-manager"; import { zip } from "../utils/zip"; import { saveAs } from "file-saver"; -async function exportToPDF(content: string): Promise { +export async function exportToPDF( + title: string, + content: string +): Promise { if (!content) return false; const { default: printjs } = await import("print-js"); return new Promise(async (resolve) => { printjs({ printable: content, type: "raw-html", + documentTitle: title, + header: '

My custom header

', onPrintDialogClose: () => { resolve(false); }, @@ -30,7 +35,7 @@ export async function exportNotes( action: async (report) => { if (format === "pdf") { const note = db.notes!.note(noteIds[0]); - return await exportToPDF(await note.export("html", null)); + return await exportToPDF(note.title, await note.export("html", null)); } var files = []; diff --git a/apps/web/src/components/dialogs/index.js b/apps/web/src/components/dialogs/index.js index fdd593256..fae02e01e 100644 --- a/apps/web/src/components/dialogs/index.js +++ b/apps/web/src/components/dialogs/index.js @@ -14,6 +14,7 @@ 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"; @@ -35,6 +36,8 @@ const Dialogs = { AnnouncementDialog, IssueDialog, ImportDialog, + MultifactorDialog, + RecoveryCodesDialog, OnboardingDialog, AttachmentsDialog, BackupDialog, diff --git a/apps/web/src/components/dialogs/multifactordialog.tsx b/apps/web/src/components/dialogs/multifactordialog.tsx new file mode 100644 index 000000000..9c328e463 --- /dev/null +++ b/apps/web/src/components/dialogs/multifactordialog.tsx @@ -0,0 +1,806 @@ +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"; +const QRCode = React.lazy(() => import("../../re-exports/react-qrcode-logo")); + +export type AuthenticatorType = "app" | "sms" | "email"; +type StepKeys = keyof Steps; // "choose" | "setup" | "recoveryCodes" | "finish"; +type FallbackStepKeys = keyof FallbackSteps; +type Steps = typeof steps; +type FallbackSteps = typeof fallbackSteps; + +type Authenticator = { + type: AuthenticatorType; + title: string; + subtitle: string; + icon: React.FunctionComponent; + recommended?: boolean; +}; + +type StepComponentProps = { + onNext: (...args: any[]) => void; + onClose?: () => void; + onError?: (error: string) => void; +}; + +type StepComponent = React.FunctionComponent; + +type Step = { + title?: string; + description?: string; + component?: StepComponent; + next?: StepKeys; + cancellable?: boolean; +}; +type FallbackStep = Step & { + next?: FallbackStepKeys; +}; + +type SubmitCodeFunction = (code: string) => void; + +type AuthenticatorSelectorProps = StepComponentProps & { + authenticator: AuthenticatorType; + isFallback?: boolean; +}; + +type VerifyAuthenticatorFormProps = PropsWithChildren<{ + codeHelpText: string; + onSubmitCode: SubmitCodeFunction; +}>; + +type SetupAuthenticatorProps = { onSubmitCode: SubmitCodeFunction }; + +type MultifactorDialogProps = { + onClose: () => void; + primaryMethod?: AuthenticatorType; +}; + +type RecoveryCodesDialogProps = { + onClose: () => void; + primaryMethod: AuthenticatorType; +}; + +const defaultAuthenticators: AuthenticatorType[] = ["app", "sms", "email"]; +const Authenticators: Authenticator[] = [ + { + type: "app", + title: "Set up using an Authenticator app", + subtitle: + "Use an authenticator app like Aegis or Raivo Authenticator to get the authentication codes.", + icon: MFAAuthenticator, + recommended: true, + }, + { + type: "sms", + title: "Set up using SMS", + subtitle: "Notesnook will send you an SMS text with the 2FA code at login.", + icon: MFASMS, + }, + { + type: "email", + title: "Set up using Email", + subtitle: "Notesnook will send you the 2FA code on your email at login.", + icon: MFAEmail, + }, +]; + +const steps = { + choose: (): Step => ({ + title: "Protect your notes by enabling 2FA", + description: "Choose how you want to receive your authentication codes.", + component: ({ onNext }) => ( + + ), + next: "setup", + cancellable: true, + }), + setup: (authenticator: Authenticator): Step => ({ + title: authenticator.title, + description: authenticator.subtitle, + next: "recoveryCodes", + component: ({ onNext }) => ( + + ), + }), + recoveryCodes: (authenticatorType: AuthenticatorType): Step => ({ + title: "Save your recovery codes", + description: `If you lose access to your ${ + authenticatorType === "email" + ? "email" + : authenticatorType === "sms" + ? "phone" + : "auth app" + }, you can login to Notesnook using your recovery codes. Each code can only be used once!`, + component: BackupRecoveryCodes, + next: "finish", + }), + finish: (): Step => ({ + component: TwoFactorEnabled, + }), +} as const; + +const fallbackSteps = { + choose: (primaryMethod: AuthenticatorType): FallbackStep => ({ + title: "Add a fallback 2FA method", + description: + "A fallback method helps you get your 2FA codes on an alternative device in case you lose your primary device.", + component: ({ onNext }) => ( + i !== primaryMethod + )} + /> + ), + next: "setup", + cancellable: true, + }), + setup: (authenticator: Authenticator): FallbackStep => ({ + title: authenticator.title, + description: authenticator.subtitle, + next: "finish", + cancellable: true, + component: ({ onNext }) => ( + + ), + }), + finish: ( + fallbackMethod: AuthenticatorType, + primaryMethod: AuthenticatorType + ): FallbackStep => ({ + component: ({ onNext, onClose }) => ( + + ), + }), +} as const; + +export function MultifactorDialog(props: MultifactorDialogProps) { + const { onClose, primaryMethod } = props; + const [step, setStep] = useState( + primaryMethod ? fallbackSteps.choose(primaryMethod) : steps.choose() + ); + const [error, setError] = useState(); + + return ( + + {step.component && ( + { + 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 && ( + + {error} + + )} + + ); +} + +export function RecoveryCodesDialog(props: RecoveryCodesDialogProps) { + const { onClose, primaryMethod } = props; + const [error, setError] = useState(); + const step = steps.recoveryCodes(primaryMethod); + + return ( + + {step.component && ( + {}} onError={setError} /> + )} + {error && ( + + {error} + + )} + + ); +} + +type ChooseAuthenticatorProps = StepComponentProps & { + authenticators: AuthenticatorType[]; +}; + +function ChooseAuthenticator(props: ChooseAuthenticatorProps) { + const [selected, setSelected] = useSessionState("selectedAuthenticator", 0); + const { authenticators, onNext } = props; + const filteredAuthenticators = authenticators.map( + (a) => Authenticators.find((auth) => auth.type === a)! + ); + return ( + { + e.preventDefault(); + const authenticator = filteredAuthenticators[selected]; + onNext(authenticator); + }} + > + {filteredAuthenticators.map((auth, index) => ( + + ))} + + ); +} + +function AuthenticatorSelector(props: AuthenticatorSelectorProps) { + const { authenticator, isFallback, onNext, onError } = props; + const onSubmitCode: SubmitCodeFunction = useCallback( + async (code) => { + try { + if (isFallback) await db.mfa?.enableFallback(authenticator, code); + else await db.mfa!.enable(authenticator, code); + onNext(authenticator); + } catch (e) { + const error = e as Error; + onError && onError(error.message); + } + }, + [authenticator, onError, onNext, isFallback] + ); + + return authenticator === "app" ? ( + + ) : authenticator === "email" ? ( + + ) : authenticator === "sms" ? ( + + ) : null; +} + +function SetupAuthenticatorApp(props: SetupAuthenticatorProps) { + const { onSubmitCode } = props; + const [authenticatorDetails, setAuthenticatorDetails] = useState({ + sharedKey: null, + authenticatorUri: null, + }); + + useEffect(() => { + (async function () { + setAuthenticatorDetails(await db.mfa!.setup("app")); + })(); + }, []); + + return ( + + + Scan the QR code below with your authenticator app. + + + {authenticatorDetails.authenticatorUri ? ( + }> + + + ) : ( + + )} + + + If you can't scan the QR code above, enter this text instead (spaces + don't matter): + + + {authenticatorDetails.sharedKey ? ( + authenticatorDetails.sharedKey + ) : ( + + )} + + + ); +} + +function SetupEmail(props: SetupAuthenticatorProps) { + const { onSubmitCode } = props; + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(); + const { elapsed, enabled, setEnabled } = useTimer(`2fa.email`, 60); + const [email, setEmail] = useState(); + + useEffect(() => { + (async () => { + const { email } = await db.user!.getUser(); + setEmail(email); + })(); + }, []); + + return ( + + + + {email} + + + + {error ? ( + + {error} + + ) : null} + + ); +} + +function SetupSMS(props: SetupAuthenticatorProps) { + const { onSubmitCode } = props; + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(); + const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60); + const inputRef = useRef(); + + return ( + + + {isSending ? ( + + ) : enabled ? ( + `Send code` + ) : ( + `Resend (${elapsed})` + )} + + ), + onClick: async () => { + if (!inputRef.current?.value) { + setError("Please provide a phone number."); + return; + } + setIsSending(true); + try { + await db.mfa!.setup("sms", inputRef.current?.value); + setEnabled(false); + } catch (e) { + const error = e as Error; + console.error(error); + setError(error.message); + } finally { + setIsSending(false); + } + }, + }} + /> + {error ? ( + + {error} + + ) : null} + + ); +} + +function BackupRecoveryCodes(props: StepComponentProps) { + const { onNext, onError } = props; + const [codes, setCodes] = useState([]); + const recoveryCodesRef = useRef(); + const generate = useCallback(async () => { + onError && onError(""); + try { + const codes = await db.mfa?.codes(); + if (codes) setCodes(codes); + } catch (e) { + const error = e as Error; + onError && onError(error.message); + } + }, [onError]); + + useEffect(() => { + (async function () { + await generate(); + })(); + }, [generate]); + + const actions = useMemo( + () => [ + { + title: "Print", + icon: Print, + action: async () => { + if (!recoveryCodesRef.current) return; + await exportToPDF( + "Notesnook 2FA Recovery Codes", + recoveryCodesRef.current.outerHTML + ); + }, + }, + { + title: "Copy", + icon: Copy, + action: async () => { + await clipboard.writeText(codes.join("\n")); + }, + }, + { + title: "Download", + icon: Download, + action: () => { + FileSaver.saveAs( + new Blob([Buffer.from(codes.join("\n"))]), + `notesnook-recovery-codes.txt` + ); + }, + }, + { title: "Regenerate", icon: Refresh, action: generate }, + ], + [codes, generate] + ); + + return ( + { + e.preventDefault(); + onNext(); + }} + > + + {codes.map((code) => ( + + {code} + + ))} + + + {actions.map((action) => ( + + ))} + + + ); +} + +function TwoFactorEnabled(props: StepComponentProps) { + return ( + + + + Two-factor authentication enabled! + + + Your account is now 100% secure against unauthorized logins. + + + + ); +} + +type Fallback2FAEnabledProps = StepComponentProps & { + fallbackMethod: AuthenticatorType; + primaryMethod: AuthenticatorType; +}; +function Fallback2FAEnabled(props: Fallback2FAEnabledProps) { + const { fallbackMethod, primaryMethod, onClose } = props; + return ( + + + + Fallback 2FA method enabled! + + + You will now receive your 2FA codes on your{" "} + {mfaMethodToPhrase(fallbackMethod)} in case you lose access to your{" "} + {mfaMethodToPhrase(primaryMethod)}. + + + + ); +} + +function VerifyAuthenticatorForm(props: VerifyAuthenticatorFormProps) { + const { codeHelpText, onSubmitCode, children } = props; + const formRef = useRef(); + return ( + { + e.preventDefault(); + const form = new FormData(formRef.current); + const code = form.get("code"); + if (!code || code.toString().length !== 6) return; + onSubmitCode(code.toString()); + }} + > + {children} + + + ); +} + +export function mfaMethodToPhrase(method: AuthenticatorType): string { + return method === "email" + ? "email" + : method === "app" + ? "authentication app" + : "phone number"; +} diff --git a/apps/web/src/components/field/index.js b/apps/web/src/components/field/index.js index 8f64f1a2c..5bbc44513 100644 --- a/apps/web/src/components/field/index.js +++ b/apps/web/src/components/field/index.js @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Flex, Text } from "rebass"; +import { Button, Flex, Text } from "rebass"; import { Input, Label } from "@rebass/forms"; import * as Icon from "../icons"; @@ -48,6 +48,8 @@ function Field(props) { placeholder, validatePassword, onError, + inputMode, + pattern, variant = "input", as = "input", } = props; @@ -96,6 +98,8 @@ function Field(props) { disabled={disabled} placeholder={placeholder} autoComplete={autoComplete} + inputMode={inputMode} + pattern={pattern} type={type || "text"} sx={{ ...styles.input, @@ -148,10 +152,11 @@ function Field(props) { )} {action && ( - - - + {action.component ? action.component : } + )} {validatePassword && ( diff --git a/apps/web/src/components/icons/index.js b/apps/web/src/components/icons/index.js index b7b86ff37..9b93262ec 100644 --- a/apps/web/src/components/icons/index.js +++ b/apps/web/src/components/icons/index.js @@ -136,6 +136,10 @@ import { mdiContentSaveCheckOutline, mdiContentSaveAlertOutline, mdiCurrencyUsd, + mdiCellphoneKey, + mdiEmailOutline, + mdiMessageLockOutline, + mdiShieldCheckOutline, mdiAlertOctagonOutline, mdiGithub, mdiAlertCircleOutline, @@ -144,8 +148,10 @@ import { mdiCheckAll, mdiCloudOffOutline, mdiContentDuplicate, + mdiPrinterOutline, + mdiRefresh, + mdiRestore, mdiVectorLink, - mdiCodeString, mdiCodeBraces, } from "@mdi/js"; import { useTheme } from "emotion-theming"; @@ -175,6 +181,7 @@ function createIcon(name, rotate = false) { (); + + useEffect(() => { + if (!enabled) { + interval.current = setInterval(() => { + setSeconds((seconds: number) => { + --seconds; + if (seconds <= 0) { + setEnabled(true); + if (interval.current) clearInterval(interval.current); + return duration; + } + return seconds; + }); + }, 1000); + } + return () => { + if (interval.current) clearInterval(interval.current); + }; + }, [enabled, setEnabled, setSeconds, duration]); + + return { elapsed: seconds, enabled, setEnabled }; +} diff --git a/apps/web/src/index.js b/apps/web/src/index.js index b91b51cf1..faaa9ea6b 100644 --- a/apps/web/src/index.js +++ b/apps/web/src/index.js @@ -21,19 +21,27 @@ const ROUTES = { }, "/signup": { component: () => import("./views/auth"), - props: { type: "signup" }, + props: { route: "signup" }, }, "/sessionexpired": { component: () => import("./views/auth"), - props: { type: "sessionexpired" }, + props: { route: "sessionExpiry" }, }, "/login": { component: () => import("./views/auth"), - props: { type: "login" }, + props: { route: "login" }, }, "/recover": { component: () => import("./views/auth"), - props: { type: "recover" }, + props: { route: "recover" }, + }, + "/mfa/code": { + component: () => import("./views/auth"), + props: { route: "login" }, + }, + "/mfa/select": { + component: () => import("./views/auth"), + props: { route: "login" }, }, default: { component: () => import("./app"), props: {} }, }; diff --git a/apps/web/src/navigation/index.js b/apps/web/src/navigation/index.js index 11f39971c..92969c721 100644 --- a/apps/web/src/navigation/index.js +++ b/apps/web/src/navigation/index.js @@ -104,8 +104,9 @@ export function hardNavigate(route) { window.open(makeURL(route, getCurrentHash()), "_self"); } -export function makeURL(route, hash) { +export function makeURL(route, hash, search) { const url = new URL(route, window.location.origin); if (!url.hash) url.hash = hash || getCurrentHash(); + url.search = search || getQueryString(); return url; } diff --git a/apps/web/src/stores/user-store.js b/apps/web/src/stores/user-store.js index 2c316f7c7..c679ed9ca 100644 --- a/apps/web/src/stores/user-store.js +++ b/apps/web/src/stores/user-store.js @@ -18,6 +18,9 @@ class UserStore extends BaseStore { isLoggedIn = false; isLoggingIn = false; isSigningIn = false; + /** + * @type {User} + */ user = undefined; init = () => { @@ -85,17 +88,25 @@ class UserStore extends BaseStore { }); }; - login = (form, skipInit = false) => { + refreshUser = async () => { + return db.user.fetchUser().then(async (user) => { + this.set((state) => (state.user = user)); + }); + }; + + login = async (form, skipInit = false) => { this.set((state) => (state.isLoggingIn = true)); - return db.user - .login(form.email.toLowerCase(), form.password) - .then(() => { - if (skipInit) return true; - return this.init(); - }) - .finally(() => { - this.set((state) => (state.isLoggingIn = false)); - }); + const { email, password, code, method } = form; + + try { + if (code) await db.user.mfaLogin(email, password, { code, method }); + else await db.user.login(email, password); + + if (skipInit) return true; + return this.init(); + } finally { + this.set((state) => (state.isLoggingIn = false)); + } }; signup = (form) => { diff --git a/apps/web/src/views/auth.js b/apps/web/src/views/auth.js deleted file mode 100644 index ad5a0e981..000000000 --- a/apps/web/src/views/auth.js +++ /dev/null @@ -1,517 +0,0 @@ -import { useEffect, useState } from "react"; -import { Button, Flex, Text } from "rebass"; -import { - CheckCircle, - Loading, - Error, - ArrowRight, - ArrowLeft, -} from "../components/icons"; -import Field from "../components/field"; -import { getQueryParams, hardNavigate, useQueryParams } from "../navigation"; -import { store as userstore } from "../stores/user-store"; -import { db } from "../common/db"; -import Config from "../utils/config"; -import useDatabase from "../hooks/use-database"; -import Loader from "../components/loader"; -import { - showLoadingDialog, - showLogoutConfirmation, -} from "../common/dialog-controller"; -import { showToast } from "../utils/toast"; -import AuthContainer from "../components/auth-container"; - -const authTypes = { - sessionexpired: { - title: "Your session has expired", - subtitle: { - text: ( - - - - All your local changes are safe and will be synced after you - relogin. - {" "} - Please enter your password to continue. - - - ), - }, - fields: [ - { - id: "email", - name: "email", - label: "Your account email", - defaultValue: (user) => maskEmail(user?.email), - disabled: true, - autoComplete: "false", - type: "email", - }, - { - id: "password", - name: "password", - label: "Enter your password", - autoComplete: "current-password", - type: "password", - autoFocus: true, - }, - ], - primaryAction: { - text: "Relogin to your account", - }, - secondaryAction: { - text: Logout permanently, - onClick: async () => { - if (await showLogoutConfirmation()) { - await showLoadingDialog({ - title: "You are being logged out", - action: () => db.user.logout(true), - }); - showToast("success", "You have been logged out."); - Config.set("sessionExpired", false); - window.location.replace("/login"); - } - }, - }, - loading: { - title: "Logging you in", - text: "Please wait while you are authenticated.", - }, - supportsPasswordRecovery: true, - onSubmit: async (form, onError) => { - return await userstore - .login(form) - .then(async () => { - Config.set("sessionExpired", false); - redirectToURL(form.redirect || "/"); - }) - .catch((e) => onError(e.message)); - }, - }, - signup: { - title: "Create an account", - subtitle: { - text: "Already have an account?", - action: { - text: "Log in", - onClick: () => hardNavigate("/login", getQueryParams()), - }, - }, - fields: [ - { - id: "email", - name: "email", - label: "Enter email", - autoComplete: "email", - type: "email", - autoFocus: true, - }, - { - id: "password", - name: "password", - label: "Set password", - autoComplete: "new-password", - type: "password", - }, - { - id: "confirm-password", - name: "confirmPassword", - label: "Confirm password", - autoComplete: "confirm-password", - type: "password", - }, - ], - primaryAction: { - text: "Agree & continue", - }, - secondaryAction: { - text: "Continue without creating an account", - icon: , - onClick: () => { - redirectToURL("/"); - }, - }, - loading: { - title: "Creating your account", - text: "Please wait while we finalize your account.", - }, - footer: ( - <> - By pressing "Create account" button, you agree to our{" "} - - Terms of Service - {" "} - &{" "} - - Privacy Policy - - . - - ), - onSubmit: async (form, onError) => { - if (form.password !== form.confirmPassword) { - onError("Passwords do not match."); - return; - } - return await userstore - .signup(form) - .then(() => { - redirectToURL("/notes/#/welcome"); - }) - .catch((e) => onError(e.message)); - }, - }, - login: { - title: "Welcome back!", - subtitle: { - text: "Don't have an account?", - action: { - text: "Sign up!", - onClick: () => hardNavigate("/signup", getQueryParams()), - }, - }, - fields: [ - { - type: "email", - id: "email", - name: "email", - label: "Enter email", - autoComplete: "email", - autoFocus: true, - defaultValue: (_user, form) => form.email, - }, - { - type: "password", - id: "password", - name: "password", - label: "Enter password", - autoComplete: "current-password", - defaultValue: (_user, form) => form.password, - }, - ], - primaryAction: { - text: "Login to your account", - }, - loading: { - title: "Logging you in", - text: "Please wait while you are authenticated.", - }, - supportsPasswordRecovery: true, - onSubmit: async (form, onError) => { - return await userstore - .login(form) - .then(async () => { - redirectToURL(form.redirect || "/"); - }) - .catch((e) => onError(e.message)); - }, - }, - recover: { - resetOnNavigate: false, - title: "Recover your account", - subtitle: { - text: "Remembered your password?", - action: { - text: "Log in", - onClick: () => hardNavigate("/login", getQueryParams()), - }, - }, - fields: [ - { - type: "email", - id: "email", - name: "email", - label: "Enter your account email", - autoComplete: "email", - helpText: - "You will receive instructions on how to recover your account on this email", - autoFocus: true, - defaultValue: (user, form) => form?.email || user?.email, - }, - ], - primaryAction: { - text: "Send recovery email", - }, - loading: { - title: "Sending recovery email", - text: "Please wait while we send you recovery instructions", - }, - onSubmit: async (form, onError, onSuccess) => { - return await db.user - .recoverAccount(form.email.toLowerCase()) - .then(async (url) => { - return redirectToURL(url); - - // onSuccess( - // "Recovery email sent. Please check your inbox (and spam folder)." - // ); - }) - .catch((e) => onError(e.message)); - }, - }, -}; - -function Auth(props) { - const { type } = props; - const [{ redirect }] = useQueryParams(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(); - const [success, setSuccess] = useState(); - const [isAppLoaded] = useDatabase(); - const [form, setForm] = useState({}); - const [user, setUser] = useState(); - - const data = authTypes[type]; - - useEffect(() => { - if (isSubmitting) { - setError(); - setSuccess(); - } - }, [isSubmitting]); - - useEffect(() => { - if (!isAppLoaded) return; - (async () => { - const user = await db.user.getUser(); - const isSessionExpired = Config.get("sessionExpired", false); - if (user) { - if ( - (type === "recover" || type === "sessionexpired") && - isSessionExpired - ) - setUser(user); - else redirectToURL("/"); - } else if (type === "sessionexpired") { - redirectToURL("/"); - } - })(); - }, [isAppLoaded, type]); - - return ( - - {isSubmitting ? ( - <> - - - ) : ( - - {data.secondaryAction ? ( - <> - - - ) : ( - - )} - - { - console.log(e); - e.preventDefault(); - setIsSubmitting(true); - const formData = new FormData(e.target); - const form = Object.fromEntries(formData.entries()); - form.redirect = redirect; - if (user) form.email = user.email; - setForm(form); - await data.onSubmit( - form, - (error) => { - setIsSubmitting(false); - setError(error); - }, - (message) => { - setSuccess(message); - setIsSubmitting(false); - } - ); - }} - > - - {data.title} - - - {data.subtitle.text}{" "} - {data.subtitle.action && ( - - {data.subtitle.action.text} - - )} - - {success && ( - - - - {success} - - - )} - {data.fields?.map(({ defaultValue, id, autoFocus, ...rest }) => ( - - ))} - {data.supportsPasswordRecovery && ( - - )} - - {error && ( - - - - {error} - - - )} - - {data.footer && ( - - {data.footer} - - )} - - - )} - - ); -} -export default Auth; - -function redirectToURL(url) { - Config.set("skipInitiation", true); - hardNavigate(url); -} - -function maskEmail(email) { - if (!email) return ""; - const [username, domain] = email.split("@"); - const maskChars = "*".repeat( - username.substring(2, username.length - 2).length - ); - return `${username.substring(0, 2)}${maskChars}${username.substring( - username.length - 2 - )}@${domain}`; -} diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx new file mode 100644 index 000000000..ef20cf443 --- /dev/null +++ b/apps/web/src/views/auth.tsx @@ -0,0 +1,877 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button, Flex, Text } from "rebass"; +import { + CheckCircle, + Loading, + Error as ErrorIcon, + MFAAuthenticator, + MFASMS, + MFAEmail, + MFARecoveryCode, +} 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"; + +type LoginFormData = { + email: string; + password: string; +}; + +type MFALoginFormData = LoginFormData & { + code?: string; + method?: MFAMethodType; +}; + +type SignupFormData = LoginFormData & { + confirmPassword: string; +}; + +type AccountRecoveryFormData = { + email: string; +}; + +type MFAFormData = LoginFormData & { + selectedMethod: MFAMethodType; + primaryMethod: MFAMethodType; + code?: string; + token: string; + secondaryMethod?: MFAMethodType; + phoneNumber?: string; +}; + +type MFAErrorData = { + primaryMethod: MFAMethodType; + token: string; + secondaryMethod?: MFAMethodType; + phoneNumber?: string; +}; + +type AuthFormData = { + login: LoginFormData; + signup: SignupFormData; + sessionExpiry: LoginFormData; + recover: AccountRecoveryFormData; + "mfa:code": MFAFormData; + "mfa:select": MFAFormData; +}; + +type BaseFormData = + | MFAFormData + | LoginFormData + | AccountRecoveryFormData + | SignupFormData; + +type NavigateFunction = ( + route: TRoute, + formData?: AuthFormData[TRoute] +) => void; +type BaseAuthComponentProps = { + navigate: NavigateFunction; + formData?: AuthFormData[TRoute]; +}; +type AuthRoutes = + | "sessionExpiry" + | "login" + | "signup" + | "recover" + | "mfa:code" + | "mfa:select"; +type AuthProps = { route: AuthRoutes }; + +type AuthComponent = ( + props: BaseAuthComponentProps +) => JSX.Element; + +function getRouteComponent( + route: TRoute +): AuthComponent | undefined { + switch (route) { + case "login": + return Login as AuthComponent; + case "signup": + return Signup as AuthComponent; + case "sessionExpiry": + return SessionExpiry as AuthComponent; + case "recover": + return AccountRecovery as AuthComponent; + case "mfa:code": + return MFACode as AuthComponent; + case "mfa:select": + return MFASelector as AuthComponent; + } + return undefined; +} + +const routePaths: Record = { + login: "/login", + recover: "/recover", + sessionExpiry: "/sessionexpired", + signup: "/signup", + "mfa:code": "/mfa/code", + "mfa:select": "/mfa/select", +}; + +function Auth(props: AuthProps) { + const [route, setRoute] = useState(props.route); + const [storedFormData, setStoredFormData] = useState< + BaseFormData | undefined + >(); + const Route = useMemo(() => getRouteComponent(route), [route]); + useEffect(() => { + window.history.replaceState({}, "", makeURL(routePaths[route])); + }, [route]); + + return ( + + + {Route && ( + { + setStoredFormData(formData); + setRoute(route); + }} + formData={storedFormData} + /> + )} + + + ); +} +export default Auth; + +function Login(props: BaseAuthComponentProps<"login">) { + const { navigate } = props; + const [isAppLoaded] = useDatabase(); + + return ( + navigate("signup") }} + /> + } + loading={{ + title: "Logging you in", + subtitle: "Please wait while you are authenticated.", + }} + onSubmit={(form) => login(form, navigate)} + > + {(form?: LoginFormData) => ( + <> + + + + + + )} + + ); +} + +function Signup(props: BaseAuthComponentProps<"signup">) { + const { navigate } = props; + const [isAppLoaded] = useDatabase(); + + return ( + navigate("login") }} + /> + } + loading={{ + title: "Creating your account", + subtitle: "Please wait while we finalize your account.", + }} + onSubmit={async (form) => { + if (form.password !== form.confirmPassword) { + throw new Error("Passwords do not match."); + } + + await userstore.signup(form); + openURL("/notes/#/welcome"); + }} + > + {(form?: SignupFormData) => ( + <> + + + + + + By pressing "Create account" button, you agree to our{" "} + + Terms of Service + {" "} + &{" "} + + Privacy Policy + + . + + + )} + + ); +} + +function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) { + const { navigate } = props; + const [isAppLoaded] = useDatabase(); + const [user, setUser] = useState(); + + useEffect(() => { + if (!isAppLoaded) return; + (async () => { + const user = await db.user?.getUser(); + const isSessionExpired = Config.get("sessionExpired", false); + if (user && isSessionExpired) { + setUser(user); + } else openURL("/"); + })(); + }, [isAppLoaded]); + + return ( + + + + All your local changes are safe and will be synced after you + login. + {" "} + Please enter your password to continue. + + + } + loading={{ + title: "Logging you in", + subtitle: "Please wait while you are authenticated.", + }} + onSubmit={(form) => login(form, navigate)} + > + + + + + + ); +} + +function AccountRecovery(props: BaseAuthComponentProps<"recover">) { + const { navigate, formData } = props; + const [isAppLoaded] = useDatabase(); + const [success, setSuccess] = useState(); + + return ( + navigate("login") }} + /> + } + loading={{ + title: "Sending recovery email", + subtitle: "Please wait while we send you recovery instructions.", + }} + onSubmit={async (form) => { + const url = await db.user?.recoverAccount(form.email.toLowerCase()); + if (isTesting()) return openURL(url); + setSuccess( + `Recovery email sent. Please check your inbox (and spam folder) for further instructions.` + ); + }} + > + {success ? ( + + + + {success} + + + ) : ( + <> + + + + )} + + ); +} + +function getTexts(formData: MFAFormData) { + return { + app: { + subtitle: + "Please confirm your identity by entering the authentication code from your authenticator app.", + instructions: `Open the two-factor authentication (TOTP) app to view your authentication code.`, + selector: `Don't have access to your authenticator app?`, + label: "Enter 6-digit code", + }, + email: { + subtitle: + "Please confirm your identity by entering the authentication code sent to your email address.", + instructions: `It may take a minute to receive your code.`, + selector: `Don't have access to your email address?`, + label: "Enter 6-digit code", + }, + sms: { + subtitle: `Please confirm your identity by entering the authentication code sent to ${ + formData.phoneNumber || "your registered phone number." + }.`, + instructions: `It may take a minute to receive your code.`, + selector: `Don't have access to your phone number?`, + label: "Enter 6-digit code", + }, + recoveryCode: { + subtitle: `Please confirm your identity by entering a recovery code.`, + instructions: "", + selector: `Don't have your recovery codes?`, + label: "Enter recovery code", + }, + }; +} + +function MFACode(props: BaseAuthComponentProps<"mfa:code">) { + const { navigate, formData } = props; + const [isAppLoaded] = useDatabase(); + const [isSending, setIsSending] = useState(false); + const { elapsed, enabled, setEnabled } = useTimer( + `2fa.${formData?.primaryMethod}`, + 60 + ); + + if (!formData) { + openURL("/"); + return null; + } + + const { selectedMethod, token } = formData; + const texts = getTexts(formData)[selectedMethod]; + + return ( + { + const loginForm: MFALoginFormData = { + email: formData.email, + password: formData.password, + code: form.code, + method: formData.selectedMethod, + }; + await login(loginForm, navigate); + }} + > + + {isSending ? ( + + ) : enabled ? ( + `Send code` + ) : ( + `Resend (${elapsed})` + )} + + ), + onClick: async () => { + 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); + } + }, + } + : undefined + } + /> + + + + ); +} + +type MFAMethodType = AuthenticatorType | "recoveryCode"; +type MFAMethod = { + type: MFAMethodType; + title: string; + icon: (props: any) => JSX.Element; +}; +const MFAMethods: MFAMethod[] = [ + { type: "app", title: "Use an authenticator app", icon: MFAAuthenticator }, + { type: "sms", title: "Send code to your phone number", icon: MFASMS }, + { type: "email", title: "Send code to your email address", icon: MFAEmail }, + { type: "recoveryCode", title: "Use a recovery code", icon: MFARecoveryCode }, +]; +function MFASelector(props: BaseAuthComponentProps<"mfa:select">) { + const { navigate, formData } = props; + const [selected, setSelected] = useState(0); + const isValidMethod = useCallback( + (method: MFAMethodType) => { + return ( + method === formData?.primaryMethod || + method === formData?.secondaryMethod || + method === "recoveryCode" + ); + }, + [formData] + ); + if (!formData) { + openURL("/"); + return null; + } + + return ( + { + const selectedType = MFAMethods[selected]; + formData.selectedMethod = selectedType.type; + navigate("mfa:code", formData); + }} + > + {MFAMethods.map( + (method, index) => + isValidMethod(method.type) && ( + + ) + )} + {/* */} + {/* */} + + ); +} + +// function MFAMethodSelector(params) {} + +type AuthFormProps = { + title: string; + subtitle: string | JSX.Element; + loading: { title: string; subtitle: string }; + type: TType; + onSubmit: (form: AuthFormData[TType]) => Promise; + children?: + | React.ReactNode + | ((form?: AuthFormData[TType]) => React.ReactNode); +}; + +function AuthForm(props: AuthFormProps) { + const { title, subtitle, children } = props; + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + const formRef = useRef(); + const [form, setForm] = useState(); + + if (isSubmitting) + return ; + + return ( + { + e.preventDefault(); + + setError(""); + setIsSubmitting(true); + const formData = new FormData(formRef.current); + const form = Object.fromEntries(formData.entries()) as AuthFormData[T]; + try { + setForm(form); + await props.onSubmit(form); + } catch (e) { + const error = e as Error; + setError(error.message); + } finally { + setIsSubmitting(false); + } + }} + > + + {title} + + + {subtitle} + + {typeof children === "function" ? children(form) : children} + {error && ( + + + + {error} + + + )} + + ); +} + +type SubtitleWithActionProps = { + text: string; + action: { + text: string; + onClick: () => void; + }; +}; +function SubtitleWithAction(props: SubtitleWithActionProps) { + return ( + <> + {props.text}{" "} + + {props.action.text} + + + ); +} + +type AuthFieldProps = { + id: string; + type: string; + autoFocus?: boolean; + autoComplete: string; + label?: string; + placeholder?: string; + helpText?: string; + defaultValue?: string; + disabled?: boolean; + inputMode?: string; + pattern?: string; + action?: { + disabled?: boolean; + component?: JSX.Element; + onClick?: () => void | Promise; + }; +}; +function AuthField(props: AuthFieldProps) { + return ( + + ); +} + +type SubmitButtonProps = { + text: string; + disabled?: boolean; + loading?: boolean; +}; +function SubmitButton(props: SubmitButtonProps) { + return ( + + ); +} + +async function login( + form: LoginFormData | MFALoginFormData, + navigate: NavigateFunction +) { + try { + await userstore.login(form); + Config.set("sessionExpired", false); + openURL("/"); + } catch (e) { + if (e instanceof RequestError && e.code === "mfa_required") { + const { primaryMethod, phoneNumber, secondaryMethod, token } = + e.data as MFAErrorData; + + if (!primaryMethod) + throw new Error( + "Multi-factor is required but the server didn't send a primary MFA method." + ); + + navigate("mfa:code", { + ...form, + token, + selectedMethod: primaryMethod, + primaryMethod, + phoneNumber, + secondaryMethod, + }); + } else throw e; + } +} + +function openURL(url: string, force?: boolean) { + const queryParams = getQueryParams(); + const redirect = queryParams?.redirect; + Config.set("skipInitiation", true); + hardNavigate(force ? url : redirect || url); +} + +function maskEmail(email: string) { + if (!email) return ""; + const [username, domain] = email.split("@"); + const maskChars = "*".repeat( + username.substring(2, username.length - 2).length + ); + return `${username.substring(0, 2)}${maskChars}${username.substring( + username.length - 2 + )}@${domain}`; +} diff --git a/apps/web/src/views/settings.js b/apps/web/src/views/settings.js index dc12457f2..04c7f4ae2 100644 --- a/apps/web/src/views/settings.js +++ b/apps/web/src/views/settings.js @@ -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) { )} + + {isLoggedIn && ( + <> +
{ + setGroups((g) => ({ ...g, mfa: !g.mfa })); + }} + /> + {groups.mfa && + (user.mfa.isEnabled ? ( + <> + + + + + + ) : ( + <> + + + ))} + + )}
({ ...g, backup: !g.backup })); }} /> + {groups.backup && ( <> - ))} - - - ); -} - -export { showToast }; diff --git a/apps/web/src/utils/toast.tsx b/apps/web/src/utils/toast.tsx new file mode 100644 index 000000000..17eaa765c --- /dev/null +++ b/apps/web/src/utils/toast.tsx @@ -0,0 +1,96 @@ +import CogoToast, { CTReturn } from "cogo-toast"; +import { Button, Flex, Text } from "rebass"; +import ThemeProvider from "../components/theme-provider"; +import { Error, Warn, Success } from "../components/icons"; +import { store as appstore } from "../stores/app-store"; + +type ToastType = "success" | "error" | "warn" | "info"; +type ToastAction = { + text: string; + onClick: () => void; + type: "primary" | "text"; +}; + +function showToast( + type: ToastType, + message: string, + actions?: ToastAction[], + hideAfter?: number +): CTReturn | null | undefined { + if (appstore.get().isFocusMode) return null; + const IconComponent = + type === "error" ? Error : type === "success" ? Success : Warn; + const toast = CogoToast[type]; + if (!toast) return; + const t = toast(, { + position: "top-right", + hideAfter: + hideAfter === undefined + ? actions + ? 5 + : type === "error" + ? 5 + : 3 + : hideAfter, + bar: { size: "0px" }, + renderIcon: () => { + return ( + + + + ); + }, + }); + return t; +} + +type ToastContainerProps = { + message: string; + actions?: ToastAction[]; +}; + +function ToastContainer(props: ToastContainerProps) { + const { message, actions } = props; + return ( + + + + {message} + + {actions?.map((action) => ( + + ))} + + + ); +} + +export { showToast }; diff --git a/apps/web/src/views/settings.js b/apps/web/src/views/settings.js index 04c7f4ae2..ae88c373e 100644 --- a/apps/web/src/views/settings.js +++ b/apps/web/src/views/settings.js @@ -316,7 +316,7 @@ function Settings(props) { ? "Reconfigure fallback 2FA method" : "Add fallback 2FA method" } - tip="You can use the fallback 2FA method if you cannot login via the primary method." + tip="You can use the fallback 2FA method in case you are unable to login via the primary method." /> From d509defe552d0aaeb93b1229eaee4942fc867d1e Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 09:49:24 +0500 Subject: [PATCH 03/19] fix: unable to delete multiple items with del key (fixes streetwriters/notesnook#429) --- apps/web/src/common/multi-select.ts | 13 ++++--------- apps/web/src/components/note/index.js | 13 +++++-------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/apps/web/src/common/multi-select.ts b/apps/web/src/common/multi-select.ts index 2447d604c..a364f768a 100644 --- a/apps/web/src/common/multi-select.ts +++ b/apps/web/src/common/multi-select.ts @@ -10,10 +10,9 @@ import { TaskManager } from "./task-manager"; async function moveNotesToTrash(notes: any[]) { const item = notes[0]; - const isMultiselect = notes.length > 1; - if (isMultiselect) { - if (!(await showMultiDeleteConfirmation(notes.length))) return; - } else { + if (!(await showMultiDeleteConfirmation(notes.length))) return; + + if (notes.length === 1) { if ( item.locked && !(await Vault.unlockNote(item.id, "unlock_and_delete_note")) @@ -38,11 +37,7 @@ async function moveNotesToTrash(notes: any[]) { }, }); - if (isMultiselect) { - showToast("success", `${items.length} notes moved to trash`); - } else { - showItemDeletedToast(item); - } + showToast("success", `${items.length} notes moved to trash`); } async function moveNotebooksToTrash(notebooks: any[]) { diff --git a/apps/web/src/components/note/index.js b/apps/web/src/components/note/index.js index 1fa6b8f11..b1c96aecf 100644 --- a/apps/web/src/components/note/index.js +++ b/apps/web/src/components/note/index.js @@ -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={{ From 922af0c40488fab713daa5b8ac345d62af8f19ca Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 09:59:30 +0500 Subject: [PATCH 04/19] fix: Selected notes become deselected on scroll (fixes streetwriters/notesnook#427) --- apps/web/src/components/list-container/index.js | 8 +++++++- apps/web/src/components/list-item/index.js | 6 ------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/list-container/index.js b/apps/web/src/components/list-container/index.js index 6793d24ee..784746f00 100644 --- a/apps/web/src/components/list-container/index.js +++ b/apps/web/src/components/list-container/index.js @@ -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 ( {!props.items.length && props.placeholder ? ( diff --git a/apps/web/src/components/list-item/index.js b/apps/web/src/components/list-item/index.js index 3a7c14240..90b468819 100644 --- a/apps/web/src/components/list-item/index.js +++ b/apps/web/src/components/list-item/index.js @@ -58,12 +58,6 @@ function ListItem(props) { const selectItem = useSelectionStore((store) => store.selectItem); - useEffect(() => { - return () => { - selectionStore.toggleSelectionMode(false); - }; - }, []); - return ( Date: Wed, 23 Mar 2022 15:35:55 +0500 Subject: [PATCH 05/19] feat: auto send code on mfa error --- apps/web/src/views/auth.tsx | 70 +++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx index ef20cf443..0cfa04195 100644 --- a/apps/web/src/views/auth.tsx +++ b/apps/web/src/views/auth.tsx @@ -8,6 +8,7 @@ import { MFASMS, MFAEmail, MFARecoveryCode, + ArrowRight, } from "../components/icons"; import Field from "../components/field"; import { getQueryParams, hardNavigate, makeURL } from "../navigation"; @@ -142,6 +143,22 @@ function Auth(props: AuthProps) { overflowY: "auto", }} > + {route === "login" || route === "signup" || route === "recover" ? ( + + ) : null} + {Route && ( { @@ -335,14 +352,17 @@ function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) { title: "Logging you in", subtitle: "Please wait while you are authenticated.", }} - onSubmit={(form) => login(form, navigate)} + onSubmit={async (form) => { + if (!user) return; + await login({ email: user.email, password: form.password }, navigate); + }} > @@ -470,6 +490,36 @@ function MFACode(props: BaseAuthComponentProps<"mfa:code">) { 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; @@ -515,24 +565,14 @@ function MFACode(props: BaseAuthComponentProps<"mfa:code">) { {isSending ? ( ) : enabled ? ( - `Send code` + `Resend code` ) : ( - `Resend (${elapsed})` + `Resend in ${elapsed}` )} ), onClick: async () => { - 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); - } + await sendCode(selectedMethod, token); }, } : undefined From 1b44de4ed1afeec1306dcc883cad4d30183913d1 Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 15:36:52 +0500 Subject: [PATCH 06/19] fix: unable to view note after restoring it from trash (fixes streetwriters/notesnook#424) --- apps/web/src/stores/editor-store.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/stores/editor-store.js b/apps/web/src/stores/editor-store.js index 864a8192f..9a3713ff1 100644 --- a/apps/web/src/stores/editor-store.js +++ b/apps/web/src/stores/editor-store.js @@ -231,7 +231,8 @@ class EditorStore extends BaseStore { }); noteStore.setSelectedNote(0); this.toggleProperties(false); - if (shouldNavigate) hashNavigate(`/`, { replace: true }); + if (shouldNavigate) + hashNavigate(`/notes/create`, { replace: true, addNonce: true }); }; setTitle = (sessionId, title) => { From 02a42661b02a8bfe888479302ef11ff22b7bbc2e Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 15:37:14 +0500 Subject: [PATCH 07/19] fix: don't show confirm dialog if delete note count is 1 --- apps/web/src/common/multi-select.ts | 4 ++-- apps/web/src/components/note/index.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/common/multi-select.ts b/apps/web/src/common/multi-select.ts index a364f768a..e737064d5 100644 --- a/apps/web/src/common/multi-select.ts +++ b/apps/web/src/common/multi-select.ts @@ -8,9 +8,9 @@ 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]; - if (!(await showMultiDeleteConfirmation(notes.length))) return; + if (confirm && !(await showMultiDeleteConfirmation(notes.length))) return; if (notes.length === 1) { if ( diff --git a/apps/web/src/components/note/index.js b/apps/web/src/components/note/index.js index b1c96aecf..3ab58d6a6 100644 --- a/apps/web/src/components/note/index.js +++ b/apps/web/src/components/note/index.js @@ -377,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, }, From 50be31729efcd5085ef72f12f6a67117489d3ec5 Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 15:37:34 +0500 Subject: [PATCH 08/19] fix: focus dialog primary button on open --- apps/web/src/components/dialogs/dialog.js | 18 +++----------- apps/web/src/theme/variants/button.js | 29 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/dialogs/dialog.js b/apps/web/src/components/dialogs/dialog.js index 4f420cad8..e93d21678 100644 --- a/apps/web/src/components/dialogs/dialog.js +++ b/apps/web/src/components/dialogs/dialog.js @@ -120,16 +120,10 @@ function Dialog(props) { > {props.negativeButton && ( {props.negativeButton.text || "Cancel"} @@ -137,15 +131,9 @@ function Dialog(props) { {props.positiveButton && ( Date: Wed, 23 Mar 2022 15:38:07 +0500 Subject: [PATCH 09/19] fix: do not show backup toast twice --- apps/web/src/common/reminders.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/common/reminders.js b/apps/web/src/common/reminders.js index 5a94d3912..30c93b552 100644 --- a/apps/web/src/common/reminders.js +++ b/apps/web/src/common/reminders.js @@ -88,6 +88,7 @@ export const Reminders = { }, }; +var openedToast = null; export async function resetReminders() { const reminders = []; @@ -102,12 +103,25 @@ export async function resetReminders() { saveFile(filePath, data); showToast("success", `Backup saved at ${filePath}.`); } else if (isUserPremium() && !isTesting()) { - const toast = showToast( + if (openedToast !== null) return; + openedToast = showToast( "success", "Your backup is ready for download.", [ - { text: "Later", onClick: () => toast?.hide(), type: "text" }, - { text: "Download", onClick: () => createBackup(), type: "primary" }, + { + text: "Later", + onClick: () => { + createBackup(false); + openedToast?.hide(); + openedToast = null; + }, + type: "text", + }, + { + text: "Download", + onClick: () => createBackup(true), + type: "primary", + }, ], 0 ); From d7ffeca32e9e13964ce9dfa132d386ea324ae65c Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 15:38:22 +0500 Subject: [PATCH 10/19] fix: e.response is not defined --- apps/web/src/interfaces/fs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/interfaces/fs.js b/apps/web/src/interfaces/fs.js index 42c2431e7..08dea456d 100644 --- a/apps/web/src/interfaces/fs.js +++ b/apps/web/src/interfaces/fs.js @@ -549,7 +549,7 @@ function parseS3Error(data) { } function handleS3Error(e, message) { - if (axios.isAxiosError(e)) { + if (axios.isAxiosError(e) && e.response?.data) { const error = parseS3Error(e.response.data); showToast("error", `${message}: [${error.Code}] ${error.Message}`); } else { From 84ace37897f480fde28f58574c1de7d76d23d840 Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 16:06:03 +0500 Subject: [PATCH 11/19] fix: refresh search on notes refresh (fixes streetwriters/notesnook#416) --- apps/web/src/stores/note-store.js | 2 ++ apps/web/src/views/search.js | 51 +++++++++++++++++++------------ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/web/src/stores/note-store.js b/apps/web/src/stores/note-store.js index 805736c64..314d0f3a7 100644 --- a/apps/web/src/stores/note-store.js +++ b/apps/web/src/stores/note-store.js @@ -16,6 +16,7 @@ class NoteStore extends BaseStore { notes = []; context = undefined; selectedNote = 0; + nonce = 0; viewMode = Config.get("notes:viewMode", "detailed"); setViewMode = (viewMode) => { @@ -43,6 +44,7 @@ class NoteStore extends BaseStore { db.notes.all, db.settings.getGroupOptions("home") ); + state.nonce = Math.random(); }); this.refreshContext(); }; diff --git a/apps/web/src/views/search.js b/apps/web/src/views/search.js index 186792d61..29bd254a8 100644 --- a/apps/web/src/views/search.js +++ b/apps/web/src/views/search.js @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ListContainer from "../components/list-container"; import SearchPlaceholder from "../components/placeholders/search-placeholder"; import { db } from "../common/db"; @@ -40,6 +40,31 @@ function Search({ type }) { }); const [results, setResults] = useState([]); const context = useNoteStore((store) => store.context); + const nonce = useNoteStore((store) => store.nonce); + const cachedQuery = useRef(); + + const onSearch = useCallback( + async (query) => { + if (!query) return; + cachedQuery.current = query; + + const [lookupType, items] = await typeToItems(type, context); + setResults([]); + + if (items.length <= 0) { + showToast("error", `There are no items to search in.`); + return; + } + setSearchState({ isSearching: true, totalItems: items.length }); + const results = await db.lookup[lookupType](items, query); + setResults(results); + setSearchState({ isSearching: false, totalItems: 0 }); + if (!results.length) { + showToast("error", `Nothing found for "${query}".`); + } + }, + [context, type] + ); const title = useMemo(() => { switch (type) { @@ -77,6 +102,10 @@ function Search({ type }) { } }, [type, context]); + useEffect(() => { + onSearch(cachedQuery.current); + }, [nonce, onSearch]); + if (!title) return hardNavigate("/"); return ( @@ -84,25 +113,7 @@ function Search({ type }) { Searching {title} - { - if (!query) return; - const [lookupType, items] = await typeToItems(type, context); - setResults([]); - - if (items.length <= 0) { - showToast("error", `There are no items to search in.`); - return; - } - setSearchState({ isSearching: true, totalItems: items.length }); - const results = await db.lookup[lookupType](items, query); - setResults(results); - setSearchState({ isSearching: false, totalItems: 0 }); - if (!results.length) { - showToast("error", `Nothing found for "${query}".`); - } - }} - /> + {searchState.isSearching ? ( Date: Wed, 23 Mar 2022 17:48:12 +0500 Subject: [PATCH 12/19] fix: 2-factor -> two-factor --- apps/web/src/views/settings.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/views/settings.js b/apps/web/src/views/settings.js index ae88c373e..662e9dd5d 100644 --- a/apps/web/src/views/settings.js +++ b/apps/web/src/views/settings.js @@ -292,7 +292,7 @@ function Settings(props) { {isLoggedIn && ( <>
{ setGroups((g) => ({ ...g, mfa: !g.mfa })); @@ -343,14 +343,14 @@ function Settings(props) { await db.mfa.disable(); showToast( "success", - "2-factor authentication disabled." + "Two-factor authentication disabled." ); await refreshUser(); } }} > @@ -367,7 +367,7 @@ function Settings(props) { }} > From 15992ade7c6982c79acd094039ee798ce315334a Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 19:42:57 +0500 Subject: [PATCH 13/19] feat: add phone number validation --- .../components/dialogs/multifactordialog.tsx | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/dialogs/multifactordialog.tsx b/apps/web/src/components/dialogs/multifactordialog.tsx index 9c328e463..4c8e64c65 100644 --- a/apps/web/src/components/dialogs/multifactordialog.tsx +++ b/apps/web/src/components/dialogs/multifactordialog.tsx @@ -28,6 +28,8 @@ 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"; @@ -144,11 +146,25 @@ const steps = { ? "phone" : "auth app" }, you can login to Notesnook using your recovery codes. Each code can only be used once!`, - component: BackupRecoveryCodes, + component: ({ onNext, onClose, onError }) => ( + + ), next: "finish", }), - finish: (): Step => ({ - component: TwoFactorEnabled, + finish: (authenticatorType: AuthenticatorType): Step => ({ + component: ({ onNext, onClose, onError }) => ( + + ), }), } as const; @@ -521,6 +537,7 @@ function SetupSMS(props: SetupAuthenticatorProps) { const { onSubmitCode } = props; const [isSending, setIsSending] = useState(false); const [error, setError] = useState(); + const [phoneNumber, setPhoneNumber] = useState(); const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60); const inputRef = useRef(); @@ -544,8 +561,21 @@ function SetupSMS(props: SetupAuthenticatorProps) { 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: isSending || !enabled, + disabled: error || isSending || !enabled, component: ( {isSending ? ( @@ -558,13 +588,14 @@ function SetupSMS(props: SetupAuthenticatorProps) { ), onClick: async () => { - if (!inputRef.current?.value) { + if (!phoneNumber) { setError("Please provide a phone number."); return; } + setIsSending(true); try { - await db.mfa!.setup("sms", inputRef.current?.value); + await db.mfa!.setup("sms", phoneNumber); setEnabled(false); } catch (e) { const error = e as Error; @@ -591,7 +622,7 @@ function SetupSMS(props: SetupAuthenticatorProps) { ); } -function BackupRecoveryCodes(props: StepComponentProps) { +function BackupRecoveryCodes(props: TwoFactorEnabledProps) { const { onNext, onError } = props; const [codes, setCodes] = useState([]); const recoveryCodesRef = useRef(); @@ -654,7 +685,7 @@ function BackupRecoveryCodes(props: StepComponentProps) { id="2faForm" onSubmit={(e) => { e.preventDefault(); - onNext(); + onNext(props.authenticatorType); }} > Done + + ); } @@ -777,7 +824,6 @@ function VerifyAuthenticatorForm(props: VerifyAuthenticatorFormProps) { helpText={codeHelpText} label="Enter the 6-digit code" sx={{ alignItems: "center", mt: 2 }} - autoFocus required placeholder="010101" min={99999} From 37e1bfb799d6bba8e782c256dced87e833bae1a6 Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 19:43:15 +0500 Subject: [PATCH 14/19] feat: add logout button on session expiry view --- apps/web/src/views/auth.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx index 0cfa04195..79c95e15b 100644 --- a/apps/web/src/views/auth.tsx +++ b/apps/web/src/views/auth.tsx @@ -9,6 +9,7 @@ import { MFAEmail, MFARecoveryCode, ArrowRight, + Logout, } from "../components/icons"; import Field from "../components/field"; import { getQueryParams, hardNavigate, makeURL } from "../navigation"; @@ -157,6 +158,24 @@ function Auth(props: AuthProps) { > Jump to app + ) : route === "sessionExpiry" ? ( + <> + + ) : null} {Route && ( From 0885d22caa3e99f5b4b66243a4fd8e13eb58d3cc Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 19:44:33 +0500 Subject: [PATCH 15/19] fix: make dialog autofocus configurable --- apps/web/src/common/dialogcontroller.js | 2 +- apps/web/src/components/dialogs/dialog.js | 2 +- apps/web/src/index.js | 1 + apps/web/src/theme/variants/button.js | 9 ++++----- apps/web/src/views/auth.tsx | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/common/dialogcontroller.js b/apps/web/src/common/dialogcontroller.js index e19b83478..fb424ef7d 100644 --- a/apps/web/src/common/dialogcontroller.js +++ b/apps/web/src/common/dialogcontroller.js @@ -607,7 +607,7 @@ export function showImportDialog() { )); } -export function showMultifactorDialog(primaryMethod = undefined) { +export function showMultifactorDialog(primaryMethod = "") { return showDialog((Dialogs, perform) => ( perform(res)} diff --git a/apps/web/src/components/dialogs/dialog.js b/apps/web/src/components/dialogs/dialog.js index e93d21678..58fb60538 100644 --- a/apps/web/src/components/dialogs/dialog.js +++ b/apps/web/src/components/dialogs/dialog.js @@ -133,7 +133,7 @@ function Dialog(props) { {...props.positiveButton.props} variant="dialog" data-test-id="dialog-yes" - autoFocus + autoFocus={props.positiveButton.autoFocus} disabled={props.positiveButton.disabled || false} onClick={ !props.positiveButton.disabled diff --git a/apps/web/src/index.js b/apps/web/src/index.js index faaa9ea6b..f77980958 100644 --- a/apps/web/src/index.js +++ b/apps/web/src/index.js @@ -1,3 +1,4 @@ +import "notes-core/types"; import { EVENTS } from "@notesnook/desktop/events"; import { render } from "react-dom"; import { AppEventManager } from "./common/app-events"; diff --git a/apps/web/src/theme/variants/button.js b/apps/web/src/theme/variants/button.js index f45f69cee..155c61a06 100644 --- a/apps/web/src/theme/variants/button.js +++ b/apps/web/src/theme/variants/button.js @@ -36,11 +36,10 @@ class Default { filter: "brightness(98%)", }, outline: "none", - ":focus:not(:active), :focus-within:not(:active), :focus-visible:not(:active)": - { - filter: "brightness(90%)", - bg: "bgSecondary", - }, + ":focus-visible:not(:active)": { + filter: "brightness(90%)", + bg: "bgSecondary", + }, ":disabled": { opacity: 0.5, cursor: "not-allowed", diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx index 79c95e15b..22fa4d741 100644 --- a/apps/web/src/views/auth.tsx +++ b/apps/web/src/views/auth.tsx @@ -169,7 +169,7 @@ function Auth(props: AuthProps) { alignSelf: "end", alignItems: "center", }} - onClick={() => db.user.logout()} + onClick={() => db.user?.logout()} color="error" > Logout From be7e49328633507fb7089805d4aaa77eb09671b1 Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 19:45:04 +0500 Subject: [PATCH 16/19] feat: add phone package --- apps/web/package.json | 1 + apps/web/src/common/db.js | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 20f03fe2d..58f33c371 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git", "nncryptoworker": "file:packages/nncryptoworker", "notes-core": "npm:@streetwriters/notesnook-core@latest", + "phone": "^3.1.14", "platform": "^1.3.6", "print-js": "^1.6.0", "qclone": "^1.0.4", diff --git a/apps/web/src/common/db.js b/apps/web/src/common/db.js index 233ef5e5d..5457face5 100644 --- a/apps/web/src/common/db.js +++ b/apps/web/src/common/db.js @@ -18,24 +18,24 @@ async function initializeDatabase() { db = new Database(Storage, EventSource, FS); // if (isTesting()) { - db.host({ - API_HOST: "https://api.notesnook.com", - AUTH_HOST: "https://auth.streetwriters.co", - SSE_HOST: "https://events.streetwriters.co", - }); + // db.host({ + // API_HOST: "https://api.notesnook.com", + // AUTH_HOST: "https://auth.streetwriters.co", + // SSE_HOST: "https://events.streetwriters.co", + // }); // } else { // db.host({ // API_HOST: "http://localhost:5264", // AUTH_HOST: "http://localhost:8264", // SSE_HOST: "http://localhost:7264", // }); - // db.host({ - // API_HOST: "http://192.168.10.29:5264", - // AUTH_HOST: "http://192.168.10.29:8264", - // SSE_HOST: "http://192.168.10.29:7264", - // ISSUES_HOST: "http://192.168.10.29:2624", - // SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264", - // }); + db.host({ + API_HOST: "http://192.168.10.29:5264", + AUTH_HOST: "http://192.168.10.29:8264", + SSE_HOST: "http://192.168.10.29:7264", + ISSUES_HOST: "http://192.168.10.29:2624", + SUBSCRIPTIONS_HOST: "http://192.168.10.29:9264", + }); // } await db.init(); From 3d5e40e15502c938c191c3c63012bc363356490c Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 20:14:43 +0500 Subject: [PATCH 17/19] fix: cannot read property isEnabled of undefined --- apps/web/src/views/settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/views/settings.js b/apps/web/src/views/settings.js index 662e9dd5d..baff6f0d5 100644 --- a/apps/web/src/views/settings.js +++ b/apps/web/src/views/settings.js @@ -289,7 +289,7 @@ function Settings(props) { )} - {isLoggedIn && ( + {isLoggedIn && user.mfa && ( <>
Date: Wed, 23 Mar 2022 20:32:11 +0500 Subject: [PATCH 18/19] feat: track sign ups --- .../src/utils/{analytics.js => analytics.ts} | 48 +++++++++++++++---- apps/web/src/views/auth.tsx | 2 + 2 files changed, 40 insertions(+), 10 deletions(-) rename apps/web/src/utils/{analytics.js => analytics.ts} (66%) diff --git a/apps/web/src/utils/analytics.js b/apps/web/src/utils/analytics.ts similarity index 66% rename from apps/web/src/utils/analytics.js rename to apps/web/src/utils/analytics.ts index 29f25f39c..ba0c52a84 100644 --- a/apps/web/src/utils/analytics.js +++ b/apps/web/src/utils/analytics.ts @@ -2,6 +2,20 @@ import Config from "./config"; import { getPlatform } from "./platform"; import { appVersion } from "./version"; +declare global { + interface Window { + umami?: { + trackEvent: ( + value: string, + type: string, + url?: string, + websiteId?: string + ) => void; + trackView: (url: string, referrer?: string, websiteId?: string) => void; + }; + } +} + export function loadTrackerScript() { if (Config.get("telemetry") === "false") return; var script = document.createElement("script"); @@ -18,9 +32,15 @@ export function loadTrackerScript() { script.onload = function () { trackVisit(); }; - firstScriptElement.parentNode.insertBefore(script, firstScriptElement); + firstScriptElement.parentNode?.insertBefore(script, firstScriptElement); } +type TrackerEvent = { + name: string; + description: string; + type?: "event" | "view"; +}; + export const ANALYTICS_EVENTS = { version: { name: "version", @@ -53,22 +73,30 @@ export const ANALYTICS_EVENTS = { name: "announcement:cta", description: "Sent whenever you an announcement CTA is invoked.", }, -}; + accountCreated: { + name: "/account/created", + description: "Sent when you create an account.", + type: "view", + }, +} as const; -export function trackEvent(event, eventMessage) { +export function trackEvent(event: TrackerEvent, eventMessage?: string) { if (Config.get("telemetry") === "false") return; - if (window.umami) { + if (!window.umami) return; + if (event.type === "event" && eventMessage) window.umami.trackEvent(eventMessage, event.name); - } + else trackVisit(event.name); } -export function trackVisit() { +export function trackVisit(url: string = "/") { if (Config.get("telemetry") === "false") return; - if (window.umami) { - window.umami.trackView("/"); + const platform = getPlatform(); + if (!window.umami || !platform) return; + + window.umami.trackView(url); + if (url === "/") trackEvent( ANALYTICS_EVENTS.version, - `${appVersion.formatted}-${getPlatform().toLowerCase()}` + `${appVersion.formatted}-${platform.toLowerCase()}` ); - } } diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx index 22fa4d741..c602930f8 100644 --- a/apps/web/src/views/auth.tsx +++ b/apps/web/src/views/auth.tsx @@ -24,6 +24,7 @@ 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; @@ -276,6 +277,7 @@ function Signup(props: BaseAuthComponentProps<"signup">) { } await userstore.signup(form); + trackEvent(ANALYTICS_EVENTS.accountCreated); openURL("/notes/#/welcome"); }} > From a07c180aae35d6dba41febba62be1bdbe8c0f8d9 Mon Sep 17 00:00:00 2001 From: thecodrr Date: Wed, 23 Mar 2022 20:32:21 +0500 Subject: [PATCH 19/19] chore: bump version to 1.8.3 --- apps/web/desktop/package.json | 2 +- apps/web/package.json | 2 +- .../src/components/dialogs/feature-dialog.tsx | 25 +++---------------- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/apps/web/desktop/package.json b/apps/web/desktop/package.json index 7d35c874c..1f4611946 100644 --- a/apps/web/desktop/package.json +++ b/apps/web/desktop/package.json @@ -2,7 +2,7 @@ "name": "@notesnook/desktop", "productName": "Notesnook", "description": "Your private note taking space", - "version": "1.8.2", + "version": "1.8.3", "private": true, "main": "./build/electron.js", "homepage": "https://notesnook.com/", diff --git a/apps/web/package.json b/apps/web/package.json index 58f33c371..819d37c6f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "notesnook", "description": "Your private note taking space", - "version": "1.8.2", + "version": "1.8.3", "private": true, "main": "./src/App.js", "homepage": "https://notesnook.com/", diff --git a/apps/web/src/components/dialogs/feature-dialog.tsx b/apps/web/src/components/dialogs/feature-dialog.tsx index e150a5a36..d41ef4ccf 100644 --- a/apps/web/src/components/dialogs/feature-dialog.tsx +++ b/apps/web/src/components/dialogs/feature-dialog.tsx @@ -40,31 +40,14 @@ const features: Record = { subtitle: `Welcome to v${appVersion.clean}`, subFeatures: [ { - title: "Duplicate notes", + title: "Two-factor authentication", subtitle: ( <> - Tired of copy-pasting notes? Wish there were templates? Not anymore.{" "} - on a note to create its - duplicate. + Add an additional layer of security for your notes by enabling 2FA + from . ), - icon: Icon.Duplicate, - }, - { - title: "Disable sync for specific notes", - subtitle: ( - <> - Make any note 100% offline by{" "} - on a note. - - ), - icon: Icon.Sync, - }, - { - title: "Improved pasting of code", - subtitle: - "Pasting code from anywhere (GitHub, VSCode etc.) will now keep the highlighting & formatting intact.", - icon: Icon.Codeblock, + icon: Icon.MFAAuthenticator, }, ], cta: {