From b0ed19d6a36c6219851d1d5c4e6b9ef4bd70fc59 Mon Sep 17 00:00:00 2001 From: Vardan Hakobyan Date: Thu, 9 Sep 2021 19:23:21 +0400 Subject: [PATCH 1/2] feat: implement credentials information on Prefs -> Account pane (#632) * feat: implement prefs -> credentials section UI (w/o backend integration) * feat: implement credentials information on Prefs -> Account pane - implement email changing UI (w/o backend integration) - implement password changing UI and reuse existing change password logic - replace 2FA dialog with shared one - implement React hook for preventing window refresh * fix: provide correct types * refactor: reuse styles from stylekit, rename components and create enum for input types * refactor: update default exports to named ones, correct texts * chore: remove unnecessary depenedency * chore: yarn.lock without unnecessary packages * Revert "chore: yarn.lock without unnecessary packages" This reverts commit 64aa75e8408b06884d6e7383180292a4a9a3e8ad. --- .../javascripts/components/DecoratedInput.tsx | 8 +- .../components/shared/HorizontalSeparator.tsx | 10 + .../shared/ModalDialog.tsx} | 32 ++- app/assets/javascripts/enums.ts | 24 +++ .../javascripts/hooks/useBeforeUnload.tsx | 11 + .../components/PreferencesPane.tsx | 5 +- .../preferences/panes/AccountPreferences.tsx | 8 +- .../preferences/panes/account/ChangeEmail.tsx | 79 ++++++++ .../preferences/panes/account/Credentials.tsx | 73 +++++++ .../preferences/panes/account/Sync.tsx | 4 +- .../changePassword/ChangePasswordForm.tsx | 45 +++++ .../changePassword/ChangePasswordSuccess.tsx | 12 ++ .../panes/account/changePassword/index.tsx | 191 ++++++++++++++++++ .../preferences/panes/account/index.ts | 3 +- .../panes/two-factor-auth/SaveSecretKey.tsx | 26 +-- .../panes/two-factor-auth/ScanQRCode.tsx | 32 +-- .../panes/two-factor-auth/Verification.tsx | 26 +-- .../javascripts/services/autolock_service.ts | 2 +- .../javascripts/services/themeManager.ts | 2 +- app/assets/javascripts/utils.ts | 5 + app/assets/javascripts/views/constants.ts | 2 + package.json | 3 +- yarn.lock | 56 ++--- 23 files changed, 551 insertions(+), 108 deletions(-) create mode 100644 app/assets/javascripts/components/shared/HorizontalSeparator.tsx rename app/assets/javascripts/{preferences/panes/two-factor-auth/TwoFactorDialog.tsx => components/shared/ModalDialog.tsx} (62%) create mode 100644 app/assets/javascripts/enums.ts create mode 100644 app/assets/javascripts/hooks/useBeforeUnload.tsx create mode 100644 app/assets/javascripts/preferences/panes/account/ChangeEmail.tsx create mode 100644 app/assets/javascripts/preferences/panes/account/Credentials.tsx create mode 100644 app/assets/javascripts/preferences/panes/account/changePassword/ChangePasswordForm.tsx create mode 100644 app/assets/javascripts/preferences/panes/account/changePassword/ChangePasswordSuccess.tsx create mode 100644 app/assets/javascripts/preferences/panes/account/changePassword/index.tsx diff --git a/app/assets/javascripts/components/DecoratedInput.tsx b/app/assets/javascripts/components/DecoratedInput.tsx index 97cccdad2..e9058b6dd 100644 --- a/app/assets/javascripts/components/DecoratedInput.tsx +++ b/app/assets/javascripts/components/DecoratedInput.tsx @@ -1,11 +1,14 @@ import { FunctionalComponent, ComponentChild } from 'preact'; +import { HtmlInputTypes } from '@/enums'; interface Props { + type?: HtmlInputTypes; className?: string; disabled?: boolean; left?: ComponentChild[]; right?: ComponentChild[]; text?: string; + placeholder?: string; onChange?: (text: string) => void; } @@ -13,11 +16,13 @@ interface Props { * Input that can be decorated on the left and right side */ export const DecoratedInput: FunctionalComponent = ({ + type = 'text', className = '', disabled = false, left, right, text, + placeholder = '', onChange, }) => { const base = @@ -32,10 +37,11 @@ export const DecoratedInput: FunctionalComponent = ({ {left}
onChange && onChange((e.target as HTMLInputElement).value) } diff --git a/app/assets/javascripts/components/shared/HorizontalSeparator.tsx b/app/assets/javascripts/components/shared/HorizontalSeparator.tsx new file mode 100644 index 000000000..7e107d75d --- /dev/null +++ b/app/assets/javascripts/components/shared/HorizontalSeparator.tsx @@ -0,0 +1,10 @@ +import { FunctionalComponent } from 'preact'; + +type Props = { + classes?: string; +} +export const HorizontalSeparator: FunctionalComponent = ({ + classes = '' +}) => { + return
; +}; diff --git a/app/assets/javascripts/preferences/panes/two-factor-auth/TwoFactorDialog.tsx b/app/assets/javascripts/components/shared/ModalDialog.tsx similarity index 62% rename from app/assets/javascripts/preferences/panes/two-factor-auth/TwoFactorDialog.tsx rename to app/assets/javascripts/components/shared/ModalDialog.tsx index 8c0b45987..a37ad2822 100644 --- a/app/assets/javascripts/preferences/panes/two-factor-auth/TwoFactorDialog.tsx +++ b/app/assets/javascripts/components/shared/ModalDialog.tsx @@ -1,19 +1,9 @@ -import { ComponentChildren, FunctionComponent } from 'preact'; -import { IconButton } from '../../../components/IconButton'; -import { - AlertDialog, - AlertDialogDescription, - AlertDialogLabel, -} from '@reach/alert-dialog'; -import { useRef } from 'preact/hooks'; +import { FunctionComponent } from 'preact'; +import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@node_modules/@reach/alert-dialog'; +import { useRef } from '@node_modules/preact/hooks'; +import { IconButton } from '@/components/IconButton'; -/** - * TwoFactorDialog is AlertDialog styled for 2FA - * Can be generalized but more use cases are needed - */ -export const TwoFactorDialog: FunctionComponent<{ - children: ComponentChildren; -}> = ({ children }) => { +export const ModalDialog: FunctionComponent = ({ children }) => { const ldRef = useRef(); return ( @@ -34,12 +24,14 @@ export const TwoFactorDialog: FunctionComponent<{ ); }; -export const TwoFactorDialogLabel: FunctionComponent<{ +export const ModalDialogLabel: FunctionComponent<{ closeDialog: () => void; }> = ({ children, closeDialog }) => (
-
{children}
+
+ {children} +
); -export const TwoFactorDialogDescription: FunctionComponent = ({ children }) => ( +export const ModalDialogDescription: FunctionComponent = ({ children }) => ( {children} ); -export const TwoFactorDialogButtons: FunctionComponent = ({ children }) => ( +export const ModalDialogButtons: FunctionComponent = ({ children }) => ( <>
{children}
); + +export default ModalDialog; diff --git a/app/assets/javascripts/enums.ts b/app/assets/javascripts/enums.ts new file mode 100644 index 000000000..199fab2ff --- /dev/null +++ b/app/assets/javascripts/enums.ts @@ -0,0 +1,24 @@ +export enum HtmlInputTypes { + Button = 'button', + Checkbox = 'checkbox', + Color = 'color', + Date = 'date', + DateTimeLocal = 'datetime-local', + Email = 'email', + File = 'file', + Hidden = 'hidden', + Image = 'image', + Month = 'month', + Number = 'number', + Password = 'password', + Radio = 'radio', + Range = 'range', + Reset = 'reset', + Search = 'search', + Submit = 'submit', + Tel = 'tel', + Text = 'text', + Time = 'time', + Url = 'url', + Week = 'week' +} diff --git a/app/assets/javascripts/hooks/useBeforeUnload.tsx b/app/assets/javascripts/hooks/useBeforeUnload.tsx new file mode 100644 index 000000000..23a078bde --- /dev/null +++ b/app/assets/javascripts/hooks/useBeforeUnload.tsx @@ -0,0 +1,11 @@ +import { useEffect } from '@node_modules/preact/hooks'; + +export const useBeforeUnload = (): void => { + useEffect(() => { + window.onbeforeunload = () => true; + + return () => { + window.onbeforeunload = null; + }; + }, []); +}; diff --git a/app/assets/javascripts/preferences/components/PreferencesPane.tsx b/app/assets/javascripts/preferences/components/PreferencesPane.tsx index 6efab9c48..75b794848 100644 --- a/app/assets/javascripts/preferences/components/PreferencesPane.tsx +++ b/app/assets/javascripts/preferences/components/PreferencesPane.tsx @@ -1,11 +1,12 @@ import { FunctionComponent } from 'preact'; +import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator'; const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({ index, length, }) => index < length - 1 ? ( -
+ ) : null; export const PreferencesSegment: FunctionComponent = ({ children }) => ( @@ -30,7 +31,7 @@ export const PreferencesGroup: FunctionComponent = ({ children }) => ( ); export const PreferencesPane: FunctionComponent = ({ children }) => ( -
+
{children}
diff --git a/app/assets/javascripts/preferences/panes/AccountPreferences.tsx b/app/assets/javascripts/preferences/panes/AccountPreferences.tsx index 6b034d7c6..b9ceb494d 100644 --- a/app/assets/javascripts/preferences/panes/AccountPreferences.tsx +++ b/app/assets/javascripts/preferences/panes/AccountPreferences.tsx @@ -1,11 +1,15 @@ -import { Sync } from '@/preferences/panes/account'; +import { Credentials, Sync } from '@/preferences/panes/account'; import { PreferencesPane } from '@/preferences/components'; import { observer } from 'mobx-react-lite'; import { WebApplication } from '@/ui_models/application'; -export const AccountPreferences = observer(({application}: {application: WebApplication}) => { +type Props = { + application: WebApplication; +} +export const AccountPreferences = observer(({application}: Props) => { return ( + ); diff --git a/app/assets/javascripts/preferences/panes/account/ChangeEmail.tsx b/app/assets/javascripts/preferences/panes/account/ChangeEmail.tsx new file mode 100644 index 000000000..3754eb2ee --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/ChangeEmail.tsx @@ -0,0 +1,79 @@ +import { + ModalDialog, + ModalDialogButtons, + ModalDialogDescription, + ModalDialogLabel +} from '@/components/shared/ModalDialog'; +import { FunctionalComponent } from 'preact'; +import { DecoratedInput } from '@/components/DecoratedInput'; +import { Button } from '@/components/Button'; +import { useState } from 'preact/hooks'; +import { SNAlertService } from '@node_modules/@standardnotes/snjs'; +import { HtmlInputTypes } from '@/enums'; +import { isEmailValid } from '@/utils'; + +type Props = { + onCloseDialog: () => void; + snAlert: SNAlertService['alert'] +}; + +export const ChangeEmail: FunctionalComponent = ({ onCloseDialog, snAlert }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = () => { + let errorMessage = ''; + if (email.trim() === '' || password.trim() === '') { + errorMessage = 'Some fields have not been filled out. Please fill out all fields and try again.'; + } else if (!isEmailValid(email)) { + errorMessage = 'The email you entered has an invalid format. Please review your input and try again.'; + } + + if (errorMessage) { + snAlert(errorMessage); + return; + } + }; + + return ( +
+ + + Change Email + + +
+ { + setEmail(newEmail); + }} + text={email} + placeholder={'New Email'} + /> +
+
+ setPassword(password)} + /> +
+
+ +
+ ); +}; diff --git a/app/assets/javascripts/preferences/panes/account/Credentials.tsx b/app/assets/javascripts/preferences/panes/account/Credentials.tsx new file mode 100644 index 000000000..0b3caa736 --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/Credentials.tsx @@ -0,0 +1,73 @@ +import { PreferencesGroup, PreferencesSegment, Text, Title } from '@/preferences/components'; +import { Button } from '@/components/Button'; +import { WebApplication } from '@/ui_models/application'; +import { observer } from '@node_modules/mobx-react-lite'; +import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator'; +import { dateToLocalizedString } from '@/utils'; +import { useState } from 'preact/hooks'; +import { ChangeEmail } from '@/preferences/panes/account/ChangeEmail'; +import { ChangePassword } from '@/preferences/panes/account/changePassword'; + +type Props = { + application: WebApplication; +}; + +export const Credentials = observer(({ application }: Props) => { + const [isChangePasswordDialogOpen, setIsChangePasswordDialogOpen] = useState(false); + const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false); + + const user = application.getUser(); + + const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date; + const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp); + + return ( + + + Credentials +
+ Email +
+ + You're signed in as {user?.email} + +
+ ); +}; diff --git a/app/assets/javascripts/preferences/panes/account/index.ts b/app/assets/javascripts/preferences/panes/account/index.ts index 2225b5881..4910df077 100644 --- a/app/assets/javascripts/preferences/panes/account/index.ts +++ b/app/assets/javascripts/preferences/panes/account/index.ts @@ -1 +1,2 @@ -export { default as Sync } from './Sync'; +export { Sync } from './Sync'; +export { Credentials } from './Credentials'; diff --git a/app/assets/javascripts/preferences/panes/two-factor-auth/SaveSecretKey.tsx b/app/assets/javascripts/preferences/panes/two-factor-auth/SaveSecretKey.tsx index 7244240ea..7a836f8aa 100644 --- a/app/assets/javascripts/preferences/panes/two-factor-auth/SaveSecretKey.tsx +++ b/app/assets/javascripts/preferences/panes/two-factor-auth/SaveSecretKey.tsx @@ -6,11 +6,11 @@ import { FunctionComponent } from 'preact'; import { downloadSecretKey } from './download-secret-key'; import { TwoFactorActivation } from './TwoFactorActivation'; import { - TwoFactorDialog, - TwoFactorDialogLabel, - TwoFactorDialogDescription, - TwoFactorDialogButtons, -} from './TwoFactorDialog'; + ModalDialog, + ModalDialogButtons, + ModalDialogDescription, + ModalDialogLabel +} from '@/components/shared/ModalDialog'; export const SaveSecretKey: FunctionComponent<{ activation: TwoFactorActivation; @@ -32,15 +32,15 @@ export const SaveSecretKey: FunctionComponent<{ /> ); return ( - - + { act.cancelActivation(); }} > Step 2 of 3 - Save secret key - - + +
@@ -70,8 +70,8 @@ export const SaveSecretKey: FunctionComponent<{
- - + +
-
- + +
- ); -}; diff --git a/app/assets/javascripts/preferences/panes/account/Credentials.tsx b/app/assets/javascripts/preferences/panes/account/Credentials.tsx index 0b3caa736..793e6849e 100644 --- a/app/assets/javascripts/preferences/panes/account/Credentials.tsx +++ b/app/assets/javascripts/preferences/panes/account/Credentials.tsx @@ -5,7 +5,7 @@ import { observer } from '@node_modules/mobx-react-lite'; import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator'; import { dateToLocalizedString } from '@/utils'; import { useState } from 'preact/hooks'; -import { ChangeEmail } from '@/preferences/panes/account/ChangeEmail'; +import { ChangeEmail } from '@/preferences/panes/account/changeEmail'; import { ChangePassword } from '@/preferences/panes/account/changePassword'; type Props = { @@ -57,7 +57,7 @@ export const Credentials = observer(({ application }: Props) => { {isChangeEmailDialogOpen && ( setIsChangeEmailDialogOpen(false)} - snAlert={application.alertService.alert} + application={application} /> )} { diff --git a/app/assets/javascripts/preferences/panes/account/changeEmail/ChangeEmailForm.tsx b/app/assets/javascripts/preferences/panes/account/changeEmail/ChangeEmailForm.tsx new file mode 100644 index 000000000..c1f8ed7e3 --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/changeEmail/ChangeEmailForm.tsx @@ -0,0 +1,37 @@ +import { DecoratedInput } from '@/components/DecoratedInput'; +import { StateUpdater } from 'preact/hooks'; +import { FunctionalComponent } from 'preact'; +import { HtmlInputTypes } from '@/enums'; + +type Props = { + setNewEmail: StateUpdater + setCurrentPassword: StateUpdater +} +export const ChangeEmailForm: FunctionalComponent = ({ + setNewEmail, + setCurrentPassword +}) => { + return ( + ( + <> +
+ { + setNewEmail(newEmail); + }} + placeholder={'New Email'} + /> +
+
+ { + setCurrentPassword(currentPassword); + }} + placeholder={'Current Password'} + /> +
+ + ) + ); +}; diff --git a/app/assets/javascripts/preferences/panes/account/changeEmail/ChangeEmailSuccess.tsx b/app/assets/javascripts/preferences/panes/account/changeEmail/ChangeEmailSuccess.tsx new file mode 100644 index 000000000..ff5058ff0 --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/changeEmail/ChangeEmailSuccess.tsx @@ -0,0 +1,12 @@ +import { FunctionalComponent } from 'preact'; + +export const ChangeEmailSuccess: FunctionalComponent = () => { + return ( + <> +
Your email has been successfully changed.
+

+ Please ensure you are running the latest version of Standard Notes on all platforms to ensure maximum compatibility. +

+ + ); +}; diff --git a/app/assets/javascripts/preferences/panes/account/changeEmail/index.tsx b/app/assets/javascripts/preferences/panes/account/changeEmail/index.tsx new file mode 100644 index 000000000..37340e21c --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/changeEmail/index.tsx @@ -0,0 +1,188 @@ +import { useState } from '@node_modules/preact/hooks'; +import { + ModalDialog, + ModalDialogButtons, + ModalDialogDescription, + ModalDialogLabel +} from '@/components/shared/ModalDialog'; +import { Button } from '@/components/Button'; +import { FunctionalComponent } from 'preact'; +import { WebApplication } from '@/ui_models/application'; +import { useBeforeUnload } from '@/hooks/useBeforeUnload'; +import { ChangeEmailForm } from './ChangeEmailForm'; +import { ChangeEmailSuccess } from './ChangeEmailSuccess'; +import { isEmailValid } from '@/utils'; + +enum SubmitButtonTitles { + Default = 'Continue', + GeneratingKeys = 'Generating Keys...', + Finish = 'Finish' +} + +enum Steps { + InitialStep, + FinishStep +} + +type Props = { + onCloseDialog: () => void; + application: WebApplication; +} + +export const ChangeEmail: FunctionalComponent = ({ + onCloseDialog, + application +}) => { + const [currentPassword, setCurrentPassword] = useState(''); + const [newEmail, setNewEmail] = useState(''); + const [isContinuing, setIsContinuing] = useState(false); + const [lockContinue, setLockContinue] = useState(false); + const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default); + const [currentStep, setCurrentStep] = useState(Steps.InitialStep); + + useBeforeUnload(); + + const applicationAlertService = application.alertService; + + const validateCurrentPassword = async () => { + if (!currentPassword || currentPassword.length === 0) { + applicationAlertService.alert( + 'Please enter your current password.' + ); + + return false; + } + + const success = await application.validateAccountPassword(currentPassword); + if (!success) { + applicationAlertService.alert( + 'The current password you entered is not correct. Please try again.' + ); + + return false; + } + + return success; + }; + + const validateNewEmail = async () => { + if (!isEmailValid(newEmail)) { + applicationAlertService.alert('The email you entered has an invalid format. Please review your input and try again.'); + + return false; + } + + return true; + }; + + const resetProgressState = () => { + setSubmitButtonTitle(SubmitButtonTitles.Default); + setIsContinuing(false); + }; + + const processEmailChange = async () => { + await application.downloadBackup(); + + setLockContinue(true); + + const response = await application.changeEmail( + newEmail, + currentPassword, + ); + + const success = !response.error; + + setLockContinue(false); + + return success; + }; + + const dismiss = () => { + if (lockContinue) { + applicationAlertService.alert( + 'Cannot close window until pending tasks are complete.' + ); + } else { + onCloseDialog(); + } + }; + + const handleSubmit = async () => { + if (lockContinue || isContinuing) { + return; + } + + if (currentStep === Steps.FinishStep) { + dismiss(); + + return; + } + + setIsContinuing(true); + setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys); + + const valid = await validateCurrentPassword() && await validateNewEmail(); + + if (!valid) { + resetProgressState(); + + return; + } + + const success = await processEmailChange(); + if (!success) { + resetProgressState(); + + return; + } + + setIsContinuing(false); + setSubmitButtonTitle(SubmitButtonTitles.Finish); + setCurrentStep(Steps.FinishStep); + }; + + const handleDialogClose = () => { + if (lockContinue) { + applicationAlertService.alert( + 'Cannot close window until pending tasks are complete.' + ); + } else { + onCloseDialog(); + } + }; + + return ( +
+ + + Change Email + + + {currentStep === Steps.InitialStep && ( + + )} + {currentStep === Steps.FinishStep && } + + + {currentStep === Steps.InitialStep && ( +
+ ); +};