diff --git a/app/assets/javascripts/components/ApplicationView.tsx b/app/assets/javascripts/components/ApplicationView.tsx index 9f5191c1c..56c522bf0 100644 --- a/app/assets/javascripts/components/ApplicationView.tsx +++ b/app/assets/javascripts/components/ApplicationView.tsx @@ -17,7 +17,7 @@ import { NoteGroupView } from '@/components/NoteGroupView'; import { Footer } from '@/components/Footer'; import { SessionsModal } from '@/components/SessionsModal'; import { PreferencesViewWrapper } from '@/components/Preferences/PreferencesViewWrapper'; -import { ChallengeModal } from '@/components/ChallengeModal'; +import { ChallengeModal } from '@/components/ChallengeModal/ChallengeModal'; import { NotesContextMenu } from '@/components/NotesContextMenu'; import { PurchaseFlowWrapper } from '@/components/PurchaseFlow/PurchaseFlowWrapper'; import { render } from 'preact'; diff --git a/app/assets/javascripts/components/Button.tsx b/app/assets/javascripts/components/Button.tsx index f91177d0b..1afc15904 100644 --- a/app/assets/javascripts/components/Button.tsx +++ b/app/assets/javascripts/components/Button.tsx @@ -22,8 +22,8 @@ const getClassName = ( let focusHoverStates = variant === 'normal' - ? 'focus:bg-contrast hover:bg-contrast' - : 'hover:brightness-130 focus:brightness-130'; + ? 'focus:bg-contrast focus:outline-none hover:bg-contrast' + : 'hover:brightness-130 focus:outline-none focus:brightness-130'; if (danger) { colors = @@ -39,8 +39,8 @@ const getClassName = ( : 'bg-grey-2 color-info-contrast'; focusHoverStates = variant === 'normal' - ? 'focus:bg-default hover:bg-default' - : 'focus:brightness-default hover:brightness-default'; + ? 'focus:bg-default focus:outline-none hover:bg-default' + : 'focus:brightness-default focus:outline-none hover:brightness-default'; } return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`; diff --git a/app/assets/javascripts/components/ChallengeModal.tsx b/app/assets/javascripts/components/ChallengeModal.tsx deleted file mode 100644 index ec98b8426..000000000 --- a/app/assets/javascripts/components/ChallengeModal.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { WebApplication } from '@/ui_models/application'; -import { Dialog } from '@reach/dialog'; -import { - ChallengeValue, - removeFromArray, - Challenge, - ChallengeReason, - ChallengePrompt, - ChallengeValidation, - ProtectionSessionDurations, -} from '@standardnotes/snjs'; -import { confirmDialog } from '@/services/alertService'; -import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings'; -import { createRef } from 'preact'; -import { PureComponent } from '@/components/Abstract/PureComponent'; - -type InputValue = { - prompt: ChallengePrompt; - value: string | number | boolean; - invalid: boolean; -}; - -type Values = Record; - -type State = { - prompts: ChallengePrompt[]; - values: Partial; - processing: boolean; - forgotPasscode: boolean; - showForgotPasscodeLink: boolean; - processingPrompts: ChallengePrompt[]; - hasAccount: boolean; - protectedNoteAccessDuration: number; -}; - -type Props = { - challenge: Challenge; - application: WebApplication; - onDismiss: (challenge: Challenge) => void; -}; - -export class ChallengeModal extends PureComponent { - submitting = false; - protectionsSessionDurations = ProtectionSessionDurations; - protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration; - private initialFocusRef = createRef(); - - constructor(props: Props) { - super(props, props.application); - - const values = {} as Values; - const prompts = this.props.challenge.prompts; - for (const prompt of prompts) { - values[prompt.id] = { - prompt, - value: prompt.initialValue ?? '', - invalid: false, - }; - } - const showForgotPasscodeLink = [ - ChallengeReason.ApplicationUnlock, - ChallengeReason.Migration, - ].includes(this.props.challenge.reason); - this.state = { - prompts, - values, - processing: false, - forgotPasscode: false, - showForgotPasscodeLink, - hasAccount: this.application.hasAccount(), - processingPrompts: [], - protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds, - }; - } - - componentDidMount(): void { - super.componentDidMount(); - - this.application.addChallengeObserver(this.props.challenge, { - onValidValue: (value) => { - this.state.values[value.prompt.id]!.invalid = false; - removeFromArray(this.state.processingPrompts, value.prompt); - this.reloadProcessingStatus(); - this.afterStateChange(); - }, - onInvalidValue: (value) => { - this.state.values[value.prompt.id]!.invalid = true; - /** If custom validation, treat all values together and not individually */ - if (!value.prompt.validates) { - this.setState({ processingPrompts: [], processing: false }); - } else { - removeFromArray(this.state.processingPrompts, value.prompt); - this.reloadProcessingStatus(); - } - this.afterStateChange(); - }, - onComplete: () => { - this.dismiss(); - }, - onCancel: () => { - this.dismiss(); - }, - }); - } - - deinit() { - (this.application as unknown) = undefined; - (this.props.challenge as unknown) = undefined; - super.deinit(); - } - - reloadProcessingStatus() { - return this.setState({ - processing: this.state.processingPrompts.length > 0, - }); - } - - destroyLocalData = async () => { - if ( - await confirmDialog({ - text: STRING_SIGN_OUT_CONFIRMATION, - confirmButtonStyle: 'danger', - }) - ) { - this.dismiss(); - this.application.user.signOut(); - } - }; - - cancel = () => { - if (this.props.challenge.cancelable) { - this.application.cancelChallenge(this.props.challenge); - } - }; - - onForgotPasscodeClick = () => { - this.setState({ - forgotPasscode: true, - }); - }; - - onTextValueChange = (prompt: ChallengePrompt) => { - const values = this.state.values; - values[prompt.id]!.invalid = false; - this.setState({ values }); - }; - - onNumberValueChange(prompt: ChallengePrompt, value: number) { - const values = this.state.values; - values[prompt.id]!.invalid = false; - values[prompt.id]!.value = value; - this.setState({ values }); - } - - validate() { - let failed = 0; - for (const prompt of this.state.prompts) { - const value = this.state.values[prompt.id]!; - if (typeof value.value === 'string' && value.value.length === 0) { - this.state.values[prompt.id]!.invalid = true; - failed++; - } - } - return failed === 0; - } - - submit = async () => { - if (!this.validate()) { - return; - } - if (this.submitting || this.state.processing) { - return; - } - this.submitting = true; - await this.setState({ processing: true }); - const values: ChallengeValue[] = []; - for (const inputValue of Object.values(this.state.values)) { - const rawValue = inputValue!.value; - const value = new ChallengeValue(inputValue!.prompt, rawValue); - values.push(value); - } - const processingPrompts = values.map((v) => v.prompt); - await this.setState({ - processingPrompts: processingPrompts, - processing: processingPrompts.length > 0, - }); - /** - * Unfortunately neccessary to wait 50ms so that the above setState call completely - * updates the UI to change processing state, before we enter into UI blocking operation - * (crypto key generation) - */ - setTimeout(() => { - if (values.length > 0) { - this.application.submitValuesForChallenge(this.props.challenge, values); - } else { - this.setState({ processing: false }); - } - this.submitting = false; - }, 50); - }; - - afterStateChange() { - this.render(); - } - - dismiss = () => { - this.props.onDismiss(this.props.challenge); - }; - - private renderChallengePrompts() { - return this.state.prompts.map((prompt, index) => ( - <> - {/** ProtectionSessionDuration can't just be an input field */} - {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? ( -
-
-
Allow protected access for
- {ProtectionSessionDurations.map((option) => ( - { - event.preventDefault(); - this.onNumberValueChange(prompt, option.valueInSeconds); - }} - > - {option.label} - - ))} -
-
- ) : ( -
-
{ - event.preventDefault(); - this.submit(); - }} - > - { - const value = (event.target as HTMLInputElement).value; - this.state.values[prompt.id]!.value = value; - this.onTextValueChange(prompt); - }} - ref={index === 0 ? this.initialFocusRef : undefined} - placeholder={prompt.title} - type={prompt.secureTextEntry ? 'password' : 'text'} - /> -
-
- )} - - {this.state.values[prompt.id]!.invalid && ( -
- -
- )} - - )); - } - - render() { - if (!this.state.prompts) { - return <>; - } - return ( - { - if (this.props.challenge.cancelable) { - this.cancel(); - } - }} - > -
-
-
-
-
- {this.props.challenge.modalTitle} -
-
-
-
-
- {this.props.challenge.heading} -
- {this.props.challenge.subheading && ( -
- {this.props.challenge.subheading} -
- )} -
- -
- {this.renderChallengePrompts()} -
-
-
- - {this.props.challenge.cancelable && ( - <> -
- this.cancel()} - > - Cancel - - - )} -
- {this.state.showForgotPasscodeLink && ( -
- {this.state.forgotPasscode ? ( - <> -

- {this.state.hasAccount - ? 'If you forgot your application passcode, your ' + - 'only option is to clear your local data from this ' + - 'device and sign back in to your account.' - : 'If you forgot your application passcode, your ' + - 'only option is to delete your data.'} -

- { - this.destroyLocalData(); - }} - > - Delete Local Data - - - ) : ( - this.onForgotPasscodeClick()} - > - Forgot your passcode? - - )} -
-
- )} -
-
-
-
- ); - } -} diff --git a/app/assets/javascripts/components/ChallengeModal/ChallengeModal.tsx b/app/assets/javascripts/components/ChallengeModal/ChallengeModal.tsx new file mode 100644 index 000000000..4f006c1db --- /dev/null +++ b/app/assets/javascripts/components/ChallengeModal/ChallengeModal.tsx @@ -0,0 +1,270 @@ +import { WebApplication } from '@/ui_models/application'; +import { DialogContent, DialogOverlay } from '@reach/dialog'; +import { + ButtonType, + Challenge, + ChallengePrompt, + ChallengeReason, + ChallengeValue, + removeFromArray, +} from '@standardnotes/snjs'; +import { ProtectedIllustration } from '@standardnotes/stylekit'; +import { FunctionComponent } from 'preact'; +import { useCallback, useEffect, useState } from 'preact/hooks'; +import { Button } from '../Button'; +import { Icon } from '../Icon'; +import { ChallengeModalPrompt } from './ChallengePrompt'; + +type InputValue = { + prompt: ChallengePrompt; + value: string | number | boolean; + invalid: boolean; +}; + +export type ChallengeModalValues = Record; + +type Props = { + application: WebApplication; + challenge: Challenge; + onDismiss: (challenge: Challenge) => Promise; +}; + +const validateValues = ( + values: ChallengeModalValues, + prompts: ChallengePrompt[] +): ChallengeModalValues | undefined => { + let hasInvalidValues = false; + const validatedValues = { ...values }; + for (const prompt of prompts) { + const value = validatedValues[prompt.id]; + if (typeof value.value === 'string' && value.value.length === 0) { + validatedValues[prompt.id].invalid = true; + hasInvalidValues = true; + } + } + if (!hasInvalidValues) { + return validatedValues; + } +}; + +export const ChallengeModal: FunctionComponent = ({ + application, + challenge, + onDismiss, +}) => { + const [values, setValues] = useState(() => { + const values = {} as ChallengeModalValues; + for (const prompt of challenge.prompts) { + values[prompt.id] = { + prompt, + value: prompt.initialValue ?? '', + invalid: false, + }; + } + return values; + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [, setProcessingPrompts] = useState([]); + const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false); + const shouldShowForgotPasscode = [ + ChallengeReason.ApplicationUnlock, + ChallengeReason.Migration, + ].includes(challenge.reason); + + const submit = async () => { + const validatedValues = validateValues(values, challenge.prompts); + if (!validatedValues) { + return; + } + if (isSubmitting || isProcessing) { + return; + } + setIsSubmitting(true); + setIsProcessing(true); + const valuesToProcess: ChallengeValue[] = []; + for (const inputValue of Object.values(validatedValues)) { + const rawValue = inputValue.value; + const value = new ChallengeValue(inputValue.prompt, rawValue); + valuesToProcess.push(value); + } + const processingPrompts = valuesToProcess.map((v) => v.prompt); + setIsProcessing(processingPrompts.length > 0); + setProcessingPrompts(processingPrompts); + /** + * Unfortunately neccessary to wait 50ms so that the above setState call completely + * updates the UI to change processing state, before we enter into UI blocking operation + * (crypto key generation) + */ + setTimeout(() => { + if (valuesToProcess.length > 0) { + application.submitValuesForChallenge(challenge, valuesToProcess); + } else { + setIsProcessing(false); + } + setIsSubmitting(false); + }, 50); + }; + + const onValueChange = useCallback( + (value: string | number, prompt: ChallengePrompt) => { + const newValues = { ...values }; + newValues[prompt.id].invalid = false; + newValues[prompt.id].value = value; + setValues(newValues); + }, + [values] + ); + + const closeModal = () => { + if (challenge.cancelable) { + onDismiss(challenge); + } + }; + + useEffect(() => { + const removeChallengeObserver = application.addChallengeObserver( + challenge, + { + onValidValue: (value) => { + setValues((values) => { + const newValues = { ...values }; + newValues[value.prompt.id].invalid = false; + return newValues; + }); + setProcessingPrompts((currentlyProcessingPrompts) => { + const processingPrompts = currentlyProcessingPrompts.slice(); + removeFromArray(processingPrompts, value.prompt); + setIsProcessing(processingPrompts.length > 0); + return processingPrompts; + }); + }, + onInvalidValue: (value) => { + setValues((values) => { + const newValues = { ...values }; + newValues[value.prompt.id].invalid = true; + return newValues; + }); + /** If custom validation, treat all values together and not individually */ + if (!value.prompt.validates) { + setProcessingPrompts([]); + setIsProcessing(false); + } else { + setProcessingPrompts((currentlyProcessingPrompts) => { + const processingPrompts = currentlyProcessingPrompts.slice(); + removeFromArray(processingPrompts, value.prompt); + setIsProcessing(processingPrompts.length > 0); + return processingPrompts; + }); + } + }, + onComplete: () => { + onDismiss(challenge); + }, + onCancel: () => { + onDismiss(challenge); + }, + } + ); + + return () => { + removeChallengeObserver(); + }; + }, [application, challenge, onDismiss]); + + if (!challenge.prompts) { + return null; + } + + return ( + + + {challenge.cancelable && ( + + )} + +
+ {challenge.heading} +
+
+ {challenge.subheading} +
+
{ + e.preventDefault(); + submit(); + }} + > + {challenge.prompts.map((prompt, index) => ( + + ))} + + + {shouldShowForgotPasscode && ( + + )} +
+
+ ); +}; diff --git a/app/assets/javascripts/components/ChallengeModal/ChallengePrompt.tsx b/app/assets/javascripts/components/ChallengeModal/ChallengePrompt.tsx new file mode 100644 index 000000000..24be05e2c --- /dev/null +++ b/app/assets/javascripts/components/ChallengeModal/ChallengePrompt.tsx @@ -0,0 +1,103 @@ +import { + ChallengePrompt, + ChallengeValidation, + ProtectionSessionDurations, +} from '@standardnotes/snjs'; +import { FunctionComponent } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; +import { DecoratedInput } from '../DecoratedInput'; +import { DecoratedPasswordInput } from '../DecoratedPasswordInput'; +import { ChallengeModalValues } from './ChallengeModal'; + +type Props = { + prompt: ChallengePrompt; + values: ChallengeModalValues; + index: number; + onValueChange: (value: string | number, prompt: ChallengePrompt) => void; + isInvalid: boolean; +}; + +export const ChallengeModalPrompt: FunctionComponent = ({ + prompt, + values, + index, + onValueChange, + isInvalid, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + if (index === 0) { + inputRef.current?.focus(); + } + }, [index]); + + useEffect(() => { + if (isInvalid) { + inputRef.current?.focus(); + } + }, [isInvalid]); + + return ( + <> + {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? ( +
+
+ Allow protected access for +
+
+ {ProtectionSessionDurations.map((option) => { + const selected = + option.valueInSeconds === values[prompt.id].value; + return ( + + ); + })} +
+
+ ) : prompt.secureTextEntry ? ( + onValueChange(value, prompt)} + /> + ) : ( + onValueChange(value, prompt)} + /> + )} + {isInvalid && ( +
+ Invalid authentication, please try again. +
+ )} + + ); +}; diff --git a/app/assets/javascripts/components/DecoratedInput.tsx b/app/assets/javascripts/components/DecoratedInput.tsx index 5307106c7..a2e23229c 100644 --- a/app/assets/javascripts/components/DecoratedInput.tsx +++ b/app/assets/javascripts/components/DecoratedInput.tsx @@ -23,7 +23,7 @@ const getClassNames = ( container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${ !hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : '' }`, - input: `w-full border-0 focus:shadow-none ${ + input: `w-full border-0 focus:shadow-none bg-transparent ${ !hasLeftDecorations && hasRightDecorations ? 'pl-2' : '' } ${hasRightDecorations ? 'pr-2' : ''}`, disabled: 'bg-grey-5 cursor-not-allowed', diff --git a/app/assets/javascripts/components/DecoratedPasswordInput.tsx b/app/assets/javascripts/components/DecoratedPasswordInput.tsx index 32e21a56a..22fdaf514 100644 --- a/app/assets/javascripts/components/DecoratedPasswordInput.tsx +++ b/app/assets/javascripts/components/DecoratedPasswordInput.tsx @@ -9,7 +9,7 @@ const Toggle: FunctionComponent<{ setIsToggled: StateUpdater; }> = ({ isToggled, setIsToggled }) => ( = ({ const focusableClass = focusable ? '' : 'focus:shadow-none'; return (