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 c56ecb753..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/", @@ -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", 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/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(); diff --git a/apps/web/src/common/dialogcontroller.js b/apps/web/src/common/dialogcontroller.js index 7558cc885..fb424ef7d 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 = "") { + 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/common/multi-select.ts b/apps/web/src/common/multi-select.ts index 2447d604c..e737064d5 100644 --- a/apps/web/src/common/multi-select.ts +++ b/apps/web/src/common/multi-select.ts @@ -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[]) { diff --git a/apps/web/src/common/reminders.js b/apps/web/src/common/reminders.js index 37b4f5038..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,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()) { diff --git a/apps/web/src/components/dialogs/backupdialog.tsx b/apps/web/src/components/dialogs/backupdialog.tsx deleted file mode 100644 index dcabccde3..000000000 --- a/apps/web/src/components/dialogs/backupdialog.tsx +++ /dev/null @@ -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 ( - { - await createBackup(true); - props.onClose(); - }, - }} - negativeButton={{ - text: "Remind me later", - onClick: async () => { - await createBackup(false); - props.onClose(); - }, - }} - > - - In case forget your password or something unfortunate happens, you can - restore a backup to recover lost data.{" "} - - - ); -} - -export default BackupDialog; diff --git a/apps/web/src/components/dialogs/dialog.js b/apps/web/src/components/dialogs/dialog.js index 4f420cad8..58fb60538 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 && ( = { 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: { diff --git a/apps/web/src/components/dialogs/index.js b/apps/web/src/components/dialogs/index.js index fdd593256..2ec07b91d 100644 --- a/apps/web/src/components/dialogs/index.js +++ b/apps/web/src/components/dialogs/index.js @@ -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; diff --git a/apps/web/src/components/dialogs/multifactordialog.tsx b/apps/web/src/components/dialogs/multifactordialog.tsx new file mode 100644 index 000000000..4c8e64c65 --- /dev/null +++ b/apps/web/src/components/dialogs/multifactordialog.tsx @@ -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; + 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: ({ onNext, onClose, onError }) => ( + + ), + next: "finish", + }), + finish: (authenticatorType: AuthenticatorType): Step => ({ + component: ({ onNext, onClose, onError }) => ( + + ), + }), +} 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 [phoneNumber, setPhoneNumber] = useState(); + const { elapsed, enabled, setEnabled } = useTimer(`2fa.sms`, 60); + const inputRef = useRef(); + + return ( + + { + 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: ( + + {isSending ? ( + + ) : enabled ? ( + `Send code` + ) : ( + `Resend (${elapsed})` + )} + + ), + 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 ? ( + + {error} + + ) : null} + + ); +} + +function BackupRecoveryCodes(props: TwoFactorEnabledProps) { + 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(props.authenticatorType); + }} + > + + {codes.map((code) => ( + + {code} + + ))} + + + {actions.map((action) => ( + + ))} + + + ); +} + +type TwoFactorEnabledProps = StepComponentProps & { + authenticatorType: AuthenticatorType; +}; +function TwoFactorEnabled(props: TwoFactorEnabledProps) { + 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) { { + 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 ( { 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, }, diff --git a/apps/web/src/hooks/use-timer.ts b/apps/web/src/hooks/use-timer.ts new file mode 100644 index 000000000..ee71ce110 --- /dev/null +++ b/apps/web/src/hooks/use-timer.ts @@ -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(); + + 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..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"; @@ -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: {} }, }; 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 { 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/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) => { 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/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/theme/variants/button.js b/apps/web/src/theme/variants/button.js index 9d7bf1f1b..155c61a06 100644 --- a/apps/web/src/theme/variants/button.js +++ b/apps/web/src/theme/variants/button.js @@ -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", + }, + }; } } 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/utils/toast.js b/apps/web/src/utils/toast.js deleted file mode 100644 index 6800696aa..000000000 --- a/apps/web/src/utils/toast.js +++ /dev/null @@ -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( - , - { - position: "top-right", - hideAfter: actions ? 5 : type === "error" ? 5 : 3, - bar: { size: "0px" }, - renderIcon: () => { - return ( - - - - ); - }, - } - ); -} - -function ToastContainer(props) { - const { type, message, actions } = props; - return ( - - - - {message} - - {actions?.map((action) => ( - - ))} - - - ); -} - -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/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..c602930f8 --- /dev/null +++ b/apps/web/src/views/auth.tsx @@ -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 = ( + 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 === "login" || route === "signup" || route === "recover" ? ( + + ) : route === "sessionExpiry" ? ( + <> + + + ) : null} + + {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); + trackEvent(ANALYTICS_EVENTS.accountCreated); + 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={async (form) => { + if (!user) return; + await login({ email: user.email, password: form.password }, 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 + ); + + 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 ( + { + const loginForm: MFALoginFormData = { + email: formData.email, + password: formData.password, + code: form.code, + method: formData.selectedMethod, + }; + await login(loginForm, navigate); + }} + > + + {isSending ? ( + + ) : enabled ? ( + `Resend code` + ) : ( + `Resend in ${elapsed}` + )} + + ), + onClick: async () => { + await sendCode(selectedMethod, token); + }, + } + : 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/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 ? ( 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 && user.mfa && ( + <> +
{ + setGroups((g) => ({ ...g, mfa: !g.mfa })); + }} + /> + {groups.mfa && + (user.mfa.isEnabled ? ( + <> + + + + + + ) : ( + <> + + + ))} + + )}
({ ...g, backup: !g.backup })); }} /> + {groups.backup && ( <>