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 4d74e7a89..49eb88a7d 100644 --- a/app/assets/javascripts/preferences/panes/AccountPreferences.tsx +++ b/app/assets/javascripts/preferences/panes/AccountPreferences.tsx @@ -1,11 +1,15 @@ -import { Sync, SubscriptionWrapper } from '@/preferences/panes/account'; +import { Sync, SubscriptionWrapper, Credentials } 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/Credentials.tsx b/app/assets/javascripts/preferences/panes/account/Credentials.tsx new file mode 100644 index 000000000..793e6849e --- /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/changePassword/ChangePasswordForm.tsx b/app/assets/javascripts/preferences/panes/account/changePassword/ChangePasswordForm.tsx new file mode 100644 index 000000000..86a641908 --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/changePassword/ChangePasswordForm.tsx @@ -0,0 +1,45 @@ +import { DecoratedInput } from '@/components/DecoratedInput'; +import { StateUpdater } from 'preact/hooks'; +import { FunctionalComponent } from 'preact'; +import { HtmlInputTypes } from '@/enums'; + +type Props = { + setCurrentPassword: StateUpdater + setNewPassword: StateUpdater + setNewPasswordConfirmation: StateUpdater +} +export const ChangePasswordForm: FunctionalComponent = ({ + setCurrentPassword, + setNewPassword, + setNewPasswordConfirmation +}) => { + return ( + ( + <> +
+ { + setCurrentPassword(currentPassword); + }} + placeholder={'Current Password'} + /> +
+
+ setNewPassword(newPassword)} + /> +
+
+ setNewPasswordConfirmation(newPasswordConfirmation)} + /> +
+ + ) + ); +}; diff --git a/app/assets/javascripts/preferences/panes/account/changePassword/ChangePasswordSuccess.tsx b/app/assets/javascripts/preferences/panes/account/changePassword/ChangePasswordSuccess.tsx new file mode 100644 index 000000000..c35e18e5e --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/changePassword/ChangePasswordSuccess.tsx @@ -0,0 +1,12 @@ +import { FunctionalComponent } from 'preact'; + +export const ChangePasswordSuccess: FunctionalComponent = () => { + return ( + <> +
Your password 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/changePassword/index.tsx b/app/assets/javascripts/preferences/panes/account/changePassword/index.tsx new file mode 100644 index 000000000..1691537b3 --- /dev/null +++ b/app/assets/javascripts/preferences/panes/account/changePassword/index.tsx @@ -0,0 +1,191 @@ +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 { ChangePasswordSuccess } from '@/preferences/panes/account/changePassword/ChangePasswordSuccess'; +import { ChangePasswordForm } from '@/preferences/panes/account/changePassword/ChangePasswordForm'; +import { useBeforeUnload } from '@/hooks/useBeforeUnload'; + +enum SubmitButtonTitles { + Default = 'Continue', + GeneratingKeys = 'Generating Keys...', + Finish = 'Finish' +} + +enum Steps { + InitialStep, + FinishStep +} + +type Props = { + onCloseDialog: () => void; + application: WebApplication; +} + +export const ChangePassword: FunctionalComponent = ({ + onCloseDialog, + application +}) => { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newPasswordConfirmation, setNewPasswordConfirmation] = 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; + } + + if (!newPassword || newPassword.length === 0) { + applicationAlertService.alert( + 'Please enter a new password.' + ); + return false; + } + if (newPassword !== newPasswordConfirmation) { + applicationAlertService.alert( + 'Your new password does not match its confirmation.' + ); + return false; + } + + if (!application.getUser()?.email) { + applicationAlertService.alert( + 'We don\'t have your email stored. Please log out then log back in to fix this issue.' + ); + return false; + } + + /** Validate current password */ + const success = await application.validateAccountPassword(currentPassword); + if (!success) { + applicationAlertService.alert( + 'The current password you entered is not correct. Please try again.' + ); + } + return success; + }; + + const resetProgressState = () => { + setSubmitButtonTitle(SubmitButtonTitles.Default); + setIsContinuing(false); + }; + + const processPasswordChange = async () => { + await application.downloadBackup(); + + setLockContinue(true); + + const response = await application.changePassword( + currentPassword, + newPassword + ); + + 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(); + + if (!valid) { + resetProgressState(); + return; + } + + const success = await processPasswordChange(); + 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 Password + + + {currentStep === Steps.InitialStep && ( + + )} + {currentStep === Steps.FinishStep && } + + + {currentStep === Steps.InitialStep && ( +
+ ); +}; diff --git a/app/assets/javascripts/preferences/panes/account/index.ts b/app/assets/javascripts/preferences/panes/account/index.ts index 063947150..933896877 100644 --- a/app/assets/javascripts/preferences/panes/account/index.ts +++ b/app/assets/javascripts/preferences/panes/account/index.ts @@ -1,2 +1,3 @@ -export { default as Sync } from './Sync'; export { SubscriptionWrapper } from './subscription/SubscriptionWrapper'; +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<{
- - + +
-
- + +