import { WebApplication } from '@/ui_models/application'; import { Dialog } from '@reach/dialog'; import { ChallengeValue, removeFromArray, Challenge, ChallengeReason, ChallengePrompt, ChallengeValidation, ProtectionSessionDurations, } from '@standardnotes/snjs'; import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; import { WebDirective } from '@/types'; import { confirmDialog } from '@/services/alertService'; import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings'; import { Ref, render } from 'preact'; import { useRef } from 'preact/hooks'; import ng from 'angular'; type InputValue = { prompt: ChallengePrompt; value: string | number | boolean; invalid: boolean; }; type Values = Record; type ChallengeModalState = { prompts: ChallengePrompt[]; values: Partial; processing: boolean; forgotPasscode: boolean; showForgotPasscodeLink: boolean; processingPrompts: ChallengePrompt[]; hasAccount: boolean; protectedNoteAccessDuration: number; }; class ChallengeModalCtrl extends PureViewCtrl { application!: WebApplication; challenge!: Challenge; onDismiss!: () => void; submitting = false; /** @template */ protectionsSessionDurations = ProtectionSessionDurations; protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration; /* @ngInject */ constructor( private $element: ng.IRootElementService, $timeout: ng.ITimeoutService ) { super($timeout); } getState() { return this.state as ChallengeModalState; } $onInit() { super.$onInit(); const values = {} as Values; const prompts = this.challenge.prompts; for (const prompt of prompts) { values[prompt.id] = { prompt, value: prompt.initialValue ?? '', invalid: false, }; } const showForgotPasscodeLink = [ ChallengeReason.ApplicationUnlock, ChallengeReason.Migration, ].includes(this.challenge.reason); this.setState({ prompts, values, processing: false, forgotPasscode: false, showForgotPasscodeLink, hasAccount: this.application.hasAccount(), processingPrompts: [], protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds, }); this.application.addChallengeObserver(this.challenge, { onValidValue: (value) => { this.state.values[value.prompt.id]!.invalid = false; removeFromArray(this.state.processingPrompts, value.prompt); this.reloadProcessingStatus(); /** Trigger UI update */ 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(); } /** Trigger UI update */ this.afterStateChange(); }, onComplete: () => { this.dismiss(); }, onCancel: () => { this.dismiss(); }, }); } deinit() { (this.application as any) = undefined; (this.challenge as any) = undefined; super.deinit(); } reloadProcessingStatus() { return this.setState({ processing: this.state.processingPrompts.length > 0, }); } async destroyLocalData() { if ( await confirmDialog({ text: STRING_SIGN_OUT_CONFIRMATION, confirmButtonStyle: 'danger', }) ) { await this.application.signOut(); this.dismiss(); } } /** @template */ cancel() { if (this.challenge.cancelable) { this.application!.cancelChallenge(this.challenge); } } onForgotPasscodeClick() { this.setState({ forgotPasscode: true, }); } onTextValueChange(prompt: ChallengePrompt) { const values = this.getState().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; } async submit() { 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.getState().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) */ this.$timeout(() => { if (values.length > 0) { this.application.submitValuesForChallenge(this.challenge, values); } else { this.setState({ processing: false }); } this.submitting = false; }, 50); } afterStateChange() { this.render(); } dismiss() { this.onDismiss(); } $onDestroy() { render(<>, this.$element[0]); super.$onDestroy(); } private render() { if (!this.state.prompts) return; render(, this.$element[0]); } } export class ChallengeModal extends WebDirective { constructor() { super(); this.restrict = 'E'; // this.template = template; this.controller = ChallengeModalCtrl; this.controllerAs = 'ctrl'; this.bindToController = true; this.scope = { challenge: '=', application: '=', onDismiss: '&', }; } } function ChallengeModalView({ ctrl }: { ctrl: ChallengeModalCtrl }) { const initialFocusRef = useRef(null); return ( { if (ctrl.challenge.cancelable) { ctrl.cancel(); } }} >
{ctrl.challenge.modalTitle}
{ctrl.challenge.heading}
{ctrl.challenge.subheading && (
{ctrl.challenge.subheading}
)}
{ChallengePrompts({ ctrl, initialFocusRef })}
{ctrl.challenge.cancelable && ( <>
ctrl.cancel()} > Cancel )}
{ctrl.state.showForgotPasscodeLink && (
{ctrl.state.forgotPasscode ? ( <>

{ctrl.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.'}

{ ctrl.destroyLocalData(); }} > Delete Local Data ) : ( ctrl.onForgotPasscodeClick()} > Forgot your passcode? )}
)}
); } function ChallengePrompts({ ctrl, initialFocusRef, }: { ctrl: ChallengeModalCtrl; initialFocusRef: Ref; }) { return ctrl.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(); ctrl.onNumberValueChange(prompt, option.valueInSeconds); }} > {option.label} ))}
) : (
{ event.preventDefault(); ctrl.submit(); }} > { const value = (event.target as HTMLInputElement).value; ctrl.state.values[prompt.id]!.value = value; ctrl.onTextValueChange(prompt); }} ref={index === 0 ? initialFocusRef : undefined} placeholder={prompt.title} type={prompt.secureTextEntry ? 'password' : 'text'} />
)} {ctrl.state.values[prompt.id]!.invalid && (
)} )); }