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.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? )}
)}
); } }