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 && ( <>