import {handleSubmit} from 'app/client/lib/formUtils'; import {AppModel} from 'app/client/models/AppModel'; import {reportError, reportSuccess} from 'app/client/models/errors'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {colors, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {cssModalBody, cssModalTitle, cssModalWidth, IModalControl, modal, cssModalButtons as modalButtons} from 'app/client/ui2018/modals'; import {ApiError} from 'app/common/ApiError'; import {FullUser} from 'app/common/LoginSessionAPI'; import {AuthMethod, ChallengeRequired, UserMFAPreferences} from 'app/common/UserAPI'; import {Disposable, dom, input, makeTestId, MultiHolder, Observable, styled} from 'grainjs'; import {toDataURL} from 'qrcode'; const testId = makeTestId('test-mfa-'); /** * Step in the dialog flow for enabling a MFA method. */ type EnableAuthMethodStep = | 'verify-password' | 'choose-auth-method' | 'configure-auth-app' | 'configure-phone-message'; /** * Step in the dialog flow for disabling a MFA method. */ type DisableAuthMethodStep = | 'confirm-disable' | 'verify-password' | 'disable-method'; interface MFAConfigOptions { appModel: AppModel; // Called when the MFA status is changed successfully. onChange: () => void; } interface EnablePhoneMessageOptions { // Called on successful completion of the enable phone message form. onSuccess: (newPhoneNumber: string) => void; // If true, shows a back text button on the first screen of the form. showBackButton?: boolean; // The text to use for the back button if `showBackButton` is true. backButtonText?: string; // Called when the back button is clicked. onBack?: () => void; } // Common HTML input options for 6 digit verification fields (SMS and TOTP). const verificationCodeInputOpts = { name: 'verificationCode', type: 'text', inputmode: 'numeric', pattern: '\\d{6}', required: 'true', }; /** * Shows information about multi-factor authentication preferences for the logged-in user * and buttons for enabling/disabling them. * * Currently supports software tokens (TOTP) and SMS. */ export class MFAConfig extends Disposable { private _appModel: AppModel; private _user: FullUser; constructor( private _mfaPrefs: Observable, private _options: MFAConfigOptions ) { super(); this._appModel = _options.appModel; this._user = this._appModel.currentUser!; } public buildDom() { return this._buildButtons(); } private _buildButtons() { return cssButtons( dom.domComputed(this._mfaPrefs, mfaPrefs => { if (!mfaPrefs) { return cssSmallSpinner(cssSmallLoadingSpinner()); } const {isSmsMfaEnabled, isSoftwareTokenMfaEnabled, phoneNumber} = mfaPrefs; return [ !isSmsMfaEnabled && !isSoftwareTokenMfaEnabled ? cssTextBtn( 'Configure two-factor authentication', dom.on('click', () => this._showAddAuthMethodModal(undefined, { onSuccess: () => { reportSuccess('Two-factor authentication enabled'); this._options.onChange(); } })), testId('enable-2fa'), ) : dom.frag( cssDataRow( cssIconAndText(cssIcon('BarcodeQR'), cssText('Authenticator app')), isSoftwareTokenMfaEnabled ? cssTextBtn( 'Disable', dom.on('click', () => this._showDisableAuthMethodModal('TOTP', { onSuccess: () => { reportSuccess('Authentication app disabled'); this._options.onChange(); } })), testId('disable-auth-app'), ) : cssTextBtn( 'Enable', dom.on('click', () => this._showAddAuthMethodModal('TOTP', { onSuccess: () => { reportSuccess('Authentication app enabled'); this._options.onChange(); } })), testId('enable-auth-app'), ), testId('auth-app-row') ), cssDataRow( cssIconAndText( cssIcon('MobileChat'), cssText('SMS', isSmsMfaEnabled && phoneNumber ? ` to ${phoneNumber}` : null), ), isSmsMfaEnabled ? [ cssTextBtn( 'Change', dom.on('click', () => this._showAddAuthMethodModal('SMS', { onSuccess: () => { reportSuccess('Phone number changed'); this._options.onChange(); } })), testId('change-phone-number'), ), cssTextBtn( 'Disable', dom.on('click', () => this._showDisableAuthMethodModal('SMS', { onSuccess: () => { reportSuccess('Phone message disabled'); this._options.onChange(); } })), testId('disable-sms'), ), ] : cssTextBtn( 'Enable', dom.on('click', () => this._showAddAuthMethodModal('SMS', { onSuccess: () => { reportSuccess('Phone message enabled'); this._options.onChange(); } })), testId('enable-sms'), ), testId('sms-row') ), ), ]; }), testId('buttons') ); } /** * Displays a modal that allows users to enable a MFA method for their account. * * @param {AuthMethod | undefined} method If specified, skips the 'choose-auth-method' step. * @param {() => void} options.onSuccess Called after successfully adding an auth method. */ private _showAddAuthMethodModal( method: AuthMethod | undefined, options: {onSuccess: () => void} ): void { return modal((ctl, owner) => { const currentStep = Observable.create(owner, 'verify-password'); return [ dom.domComputed((use) => { const step = use(currentStep); switch (step) { case 'verify-password': { return [ this._buildSecurityVerificationForm(ctl, {onSuccess: async (hadSecondStep) => { /** * If method was unspecified, but second step verification occurred, we know that * the client doesn't have up-to-date 2FA preferences. Close the modal, and force * a refresh of UserMFAPreferences, which should cause the correct buttons to be * rendered once preferences are loaded. * * This is ultimately caused by older Grist sessions (pre-2FA) not having access * or refresh tokens, which makes it impossible to get MFA status without first * requiring that users re-authenticate. Token-less sessions currently return a * disabled status for all 2FA methods as an interim solution, until all old * sessions have expired (3 months). * * TODO: Revisit this 3 months after this commit has landed in prod; we may no longer * need much of these changes. */ if (!method && hadSecondStep) { ctl.close(); this._options.onChange(); } if (!method) { return currentStep.set('choose-auth-method'); } currentStep.set(method === 'SMS' ? 'configure-phone-message' : 'configure-auth-app'); }}), ]; } case 'choose-auth-method': { return [ cssModalTitle('Two-factor authentication', testId('title')), cssModalBody( cssMainText( "Once you enable two step authentication, you'll need to enter a special code " + "when you log in. Please choose a method you'd like to receive codes with." ), cssAuthMethods( cssAuthMethod( cssAuthMethodTitle(cssGreenIcon('BarcodeQR2'), 'Authenticator app', testId('auth-method-title')), cssAuthMethodDesc( "An authenticator app lets you access your security code without receiving a call " + "or text message. If you don't already have an authenticator app, we'd recommend " + "using ", cssLink('Google Authenticator', dom.on('click', e => e.stopPropagation()), { href: 'https://play.google.com/store/apps/' + 'details?id=com.google.android.apps.authenticator2&hl=en_US&gl=US', target: '_blank', }), ".", ), dom.on('click', () => currentStep.set('configure-auth-app')), testId('auth-app-method'), ), cssAuthMethod( cssAuthMethodTitle(cssGreenIcon('MobileChat2'), 'Phone message', testId('auth-method-title')), cssAuthMethodDesc( 'You need to add a U.S. phone number where you can receive authentication codes by text.', ), dom.on('click', () => currentStep.set('configure-phone-message')), testId('sms-method'), ), ), ), ]; } case 'configure-auth-app': { return [ this._buildConfigureAuthAppForm(ctl, {onSuccess: async () => { ctl.close(); options.onSuccess(); }}), method ? null: cssBackBtn('← Back to methods', dom.on('click', () => { currentStep.set('choose-auth-method'); }), testId('back-to-methods'), ), ]; } case 'configure-phone-message': { return [ this._buildConfigurePhoneMessageForm(ctl, { onSuccess: async () => { ctl.close(); options.onSuccess(); }, showBackButton: !method, backButtonText: '← Back to methods', onBack: () => currentStep.set('choose-auth-method'), }), ]; } } }), cssModalWidth('fixed-wide'), ]; }); } /** * Displays a modal that allows users to disable a MFA method for their account. * * @param {AuthMethod} method The auth method to disable. * @param {() => void} options.onSuccess Called after successfully disabling an auth method. */ private _showDisableAuthMethodModal(method: AuthMethod, options: {onSuccess: () => void}): void { return modal((ctl, owner) => { const currentStep = Observable.create(owner, 'confirm-disable'); return [ dom.domComputed((use) => { const step = use(currentStep); switch (step) { case 'confirm-disable': { return [ cssModalTitle( `Disable ${method === 'TOTP' ? 'authentication app' : 'phone message'}?`, testId('title') ), cssModalBody( cssMainText( "Two-factor authentication is an extra layer of security for your Grist account designed " + "to ensure that you're the only person who can access your account, even if someone " + "knows your password." ), cssModalButtons( bigPrimaryButton('Yes, disable', dom.on('click', () => currentStep.set('verify-password')), testId('disable'), ), bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), ), ]; } case 'verify-password': { return [ this._buildSecurityVerificationForm(ctl, {onSuccess: () => { currentStep.set('disable-method'); }}), ]; } case 'disable-method': { const disableMethod = method === 'SMS' ? this._unregisterSMS() : this._unregisterSoftwareToken(); disableMethod .then(() => { options.onSuccess(); }) .catch(reportError) .finally(() => ctl.close()); return cssSpinner(loadingSpinner()); } } }), cssModalWidth('fixed-wide'), ]; }); } /** * Builds security verification forms, including a password form and optional 2FA verification form. * * A callback function must be passed, which will be called after successful completion of the * verification forms. * * @param {() => void} options.onSuccess Called after successful completion of verification. */ private _buildSecurityVerificationForm( ctl: IModalControl, {onSuccess}: {onSuccess: (hadSecondStep: boolean) => void} ) { const holder = new MultiHolder(); const securityStep = Observable.create<'password' | 'sms' | 'totp' | 'loading'>(holder, 'password'); const password = Observable.create(holder, ''); const session = Observable.create(holder, ''); const challengeDetails = Observable.create(holder, null); return [ dom.autoDispose(holder), dom.domComputed(securityStep, (step) => { switch (step) { case 'loading': { return cssSpinner(loadingSpinner()); } case 'password': { let formElement: HTMLFormElement; const multiHolder = new MultiHolder(); const pending = Observable.create(multiHolder, false); const errorObs: Observable = Observable.create(multiHolder, null); return dom.frag( dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), dom.autoDispose(multiHolder), cssModalTitle('Confirm your password', testId('title')), cssModalBody( formElement = dom('form', cssMainText('Please confirm your password to continue.'), cssBoldSubHeading('Password'), cssInput(password, {onInput: true}, { name: 'password', placeholder: 'password', type: 'password', autocomplete: 'current-password', id: 'current-password', required: 'true', }, (el) => { setTimeout(() => el.focus(), 10); }, dom.onKeyDown({Enter: () => formElement.requestSubmit()}), testId('password-input'), ), cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), handleSubmit(pending, ({password: pass}) => this._verifyPassword(pass), (result) => { if (!result.isChallengeRequired) { return onSuccess(false); } session.set(result.session); challengeDetails.set(result); if (result.challengeName === 'SMS_MFA') { securityStep.set('sms'); } else { securityStep.set('totp'); } }, (err) => handleFormError(err, errorObs), ), cssModalButtons( bigPrimaryButton('Confirm', dom.boolAttr('disabled', pending), testId('confirm')), bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), ), ), ); } case 'totp': { let formElement: HTMLFormElement; const multiHolder = new MultiHolder(); const pending = Observable.create(multiHolder, false); const verificationCode = Observable.create(multiHolder, ''); const errorObs: Observable = Observable.create(multiHolder, null); const {isAlternateChallengeAvailable, deliveryDestination} = challengeDetails.get()!; return dom.frag( dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), dom.autoDispose(multiHolder), cssModalTitle('Almost there!', testId('title')), cssModalBody( formElement = dom('form', cssMainText( 'Enter the authentication code generated by your app to confirm your account.', testId('main-text'), ), cssBoldSubHeading('Verification Code'), cssCodeInput(verificationCode, {onInput: true}, verificationCodeInputOpts, (el) => { setTimeout(() => el.focus(), 10); }, dom.onKeyDown({Enter: () => formElement.requestSubmit()}), testId('verification-code-input'), ), cssInput(session, {onInput: true}, {name: 'session', type: 'hidden'}), cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), handleSubmit(pending, ({verificationCode: code, session: s}) => this._verifySecondStep('TOTP', code, s), () => onSuccess(true), (err) => handleFormError(err, errorObs), ), cssModalButtons( bigPrimaryButton('Submit', dom.boolAttr('disabled', pending), testId('submit')), bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), !isAlternateChallengeAvailable ? null : cssSubText( 'Receive a code by SMS? ', cssLink( // Use masked phone from prefs or challenge response, if available. `Text ${deliveryDestination}.`, dom.on('click', async () => { if (pending.get()) { return; } securityStep.set('loading'); try { const result = await this._verifyPassword(password.get(), 'SMS'); if (result.isChallengeRequired) { session.set(result.session); challengeDetails.set(result); securityStep.set('sms'); } } catch (err) { reportError(err as Error|string); securityStep.set('totp'); } }), ), testId('use-sms'), ), ), ), ); } case 'sms': { let formElement: HTMLFormElement; const multiHolder = new MultiHolder(); const pending = Observable.create(multiHolder, false); const verificationCode = Observable.create(multiHolder, ''); const isResendingCode = Observable.create(multiHolder, false); const errorObs: Observable = Observable.create(multiHolder, null); const resendingListener = isResendingCode.addListener(isResending => { if (!isResending) { return; } errorObs.set(null); verificationCode.set(''); }); const {isAlternateChallengeAvailable, deliveryDestination} = challengeDetails.get()!; return dom.frag( dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), dom.autoDispose(resendingListener), dom.autoDispose(multiHolder), dom.domComputed(isResendingCode, isLoading => { if (isLoading) { return cssSpinner(loadingSpinner()); } return [ cssModalTitle('Almost there!', testId('title')), cssModalBody( formElement = dom('form', cssMainText( 'We have sent an authentication code to ', cssLightlyBoldedText(deliveryDestination), '. Enter it below to confirm your account.', testId('main-text'), ), cssBoldSubHeading('Authentication Code'), cssCodeInput(verificationCode, {onInput: true}, {...verificationCodeInputOpts, autocomplete: 'one-time-code'}, (el) => { setTimeout(() => el.focus(), 10); }, dom.onKeyDown({Enter: () => formElement.requestSubmit()}), testId('verification-code-input'), ), cssInput(session, {onInput: true}, {name: 'session', type: 'hidden'}), cssSubText( "Didn't receive a code? ", cssLink( 'Resend it', dom.on('click', async () => { if (pending.get()) { return; } try { isResendingCode.set(true); const result = await this._verifyPassword(password.get(), 'SMS'); if (result.isChallengeRequired) { session.set(result.session); challengeDetails.set(result); } } finally { isResendingCode.set(false); } }), testId('resend-code'), ), ), cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), handleSubmit(pending, ({verificationCode: code, session: s}) => this._verifySecondStep('SMS', code, s), () => onSuccess(true), (err) => handleFormError(err, errorObs), ), cssModalButtons( bigPrimaryButton('Submit', dom.boolAttr('disabled', pending), testId('submit')), bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), !isAlternateChallengeAvailable ? null : cssSubText( cssLink( 'Use code from authenticator app?', dom.on('click', async () => { if (pending.get()) { return; } securityStep.set('loading'); try { const result = await this._verifyPassword(password.get(), 'TOTP'); if (result.isChallengeRequired) { session.set(result.session); securityStep.set('totp'); } } catch (err) { reportError(err as Error|string); securityStep.set('sms'); } }), testId('use-auth-app'), ), ), ) ) ]; }), ); } } }), ]; } /** * Builds a form for registering a software token (TOTP) MFA method. * * A callback function must be passed, which will be called after successful completion of the * registration form. * * @param {() => void} options.onSuccess Called after successful completion of registration. */ private _buildConfigureAuthAppForm(ctl: IModalControl, {onSuccess}: {onSuccess: () => void}) { let formElement: HTMLFormElement; const holder = new MultiHolder(); const qrCode: Observable = Observable.create(holder, null); const verificationCode = Observable.create(holder, ''); const pending = Observable.create(holder, false); const errorObs: Observable = Observable.create(holder, null); this._getSoftwareTokenQRCode() .then(code => qrCode.isDisposed() || qrCode.set(code)) .catch(e => { ctl.close(); reportError(e); }); return [ dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), dom.autoDispose(holder), dom.domComputed(qrCode, code => { if (code === null) { return cssSpinner(loadingSpinner()); } return [ cssModalTitle('Configure authenticator app', testId('title')), cssModalBody( cssModalBody( formElement = dom('form', cssMainText( "An authenticator app lets you access your security code without receiving a call " + "or text message. If you don't already have an authenticator app, we'd recommend " + "using ", cssLink('Google Authenticator', { href: 'https://play.google.com/store/apps/' + 'details?id=com.google.android.apps.authenticator2&hl=en_US&gl=US', target: '_blank', }), ".", ), cssBoldSubHeading('To configure your authenticator app:'), cssListItem('1. Add a new account'), cssListItem('2. Scan the following barcode', {style: 'margin-bottom: 8px'}), cssQRCode({src: code}, testId('qr-code')), cssListItem('3. Enter the verification code that appears after scanning the barcode'), cssBoldSubHeading('Authentication code'), cssCodeInput(verificationCode, {onInput: true}, verificationCodeInputOpts, dom.onKeyDown({Enter: () => formElement.requestSubmit()}), testId('verification-code-input'), ), cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), handleSubmit(pending, ({verificationCode: c}) => this._confirmRegisterSoftwareToken(c), () => onSuccess(), (err) => handleFormError(err, errorObs), ), cssModalButtons( bigPrimaryButton('Verify', dom.boolAttr('disabled', pending), testId('verify')), bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), ), ), ), ]; }), ]; } /** * Builds a form for registering a SMS MFA method. * * A callback function must be passed, which will be called after successful completion of the * registration form. * * @param {EnablePhoneMessageOptions} options Form options. */ private _buildConfigurePhoneMessageForm( ctl: IModalControl, {onSuccess, showBackButton, backButtonText, onBack}: EnablePhoneMessageOptions, ) { const holder = new MultiHolder(); const configStep = Observable.create<'enter-phone' | 'verify-phone'>(holder, 'enter-phone'); const pending = Observable.create(holder, false); const phoneNumber = Observable.create(holder, ''); const maskedPhoneNumber = Observable.create(holder, ''); return [ dom.autoDispose(holder), dom.domComputed(configStep, (step) => { switch (step) { case 'enter-phone': { let formElement: HTMLFormElement; const multiHolder = new MultiHolder(); const errorObs: Observable = Observable.create(multiHolder, null); return dom.frag( dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), dom.autoDispose(multiHolder), cssModalTitle('Configure phone message', testId('title')), cssModalBody( formElement = dom('form', cssMainText( 'You need to add a U.S. phone number where you can receive authentication codes by text.', ), cssBoldSubHeading('Phone number'), cssInput(phoneNumber, {onInput: true}, {name: 'phoneNumber', placeholder: '+1 (201) 555 0123', type: 'text', required: 'true'}, (el) => { setTimeout(() => el.focus(), 10); }, dom.onKeyDown({Enter: () => formElement.requestSubmit()}), testId('phone-number-input'), ), cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), handleSubmit(pending, ({phoneNumber: phone}) => this._registerSMS(phone), ({deliveryDestination}) => { maskedPhoneNumber.set(deliveryDestination); configStep.set('verify-phone'); }, (err) => handleFormError(err, errorObs), ), cssModalButtons( bigPrimaryButton('Send code', dom.boolAttr('disabled', pending), testId('send-code')), bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), ), ), showBackButton && backButtonText !== undefined && onBack ? cssBackBtn(backButtonText, dom.on('click', () => onBack()), testId('back')) : null, ); } case 'verify-phone': { let formElement: HTMLFormElement; const multiHolder = new MultiHolder(); const verificationCode = Observable.create(multiHolder, ''); const isResendingCode = Observable.create(multiHolder, false); const errorObs: Observable = Observable.create(multiHolder, null); const resendingListener = isResendingCode.addListener(isResending => { if (!isResending) { return; } errorObs.set(null); verificationCode.set(''); }); return dom.frag( dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), dom.autoDispose(resendingListener), dom.autoDispose(multiHolder), dom.domComputed(isResendingCode, isLoading => { if (isLoading) { return cssSpinner(loadingSpinner()); } return [ cssModalTitle('Confirm your phone', testId('title')), cssModalBody( formElement = dom('form', cssMainText( 'We have sent the authentication code to ', cssLightlyBoldedText(maskedPhoneNumber.get()), '. Enter it below to confirm your account.', testId('main-text'), ), cssBoldSubHeading('Authentication Code'), cssCodeInput(verificationCode, {onInput: true}, {...verificationCodeInputOpts, autocomplete: 'one-time-code'}, (el) => { setTimeout(() => el.focus(), 10); }, dom.onKeyDown({Enter: () => formElement.requestSubmit()}), testId('verification-code-input'), ), cssSubText( "Didn't receive a code? ", cssLink( 'Resend it', dom.on('click', async () => { if (pending.get()) { return; } try { isResendingCode.set(true); await this._registerSMS(phoneNumber.get()); } finally { isResendingCode.set(false); } }), testId('resend-code'), ), ), cssFormError(dom.text(use => use(errorObs) ?? ''), testId('form-error')), handleSubmit(pending, ({verificationCode: code}) => this._confirmRegisterSMS(code), () => onSuccess(maskedPhoneNumber.get()), (err) => handleFormError(err, errorObs), ), cssModalButtons( bigPrimaryButton('Confirm', dom.boolAttr('disabled', pending), testId('confirm')), bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), ) ), cssBackBtn('← Back to phone number', dom.on('click', () => configStep.set('enter-phone')), testId('back-to-phone') ), ]; }) ); } } }), ]; } private async _registerSoftwareToken() { return await this._appModel.api.registerSoftwareToken(); } private async _confirmRegisterSoftwareToken(verificationCode: string) { await this._appModel.api.confirmRegisterSoftwareToken(verificationCode); } private async _unregisterSoftwareToken() { await this._appModel.api.unregisterSoftwareToken(); } private async _registerSMS(phoneNumber: string) { return await this._appModel.api.registerSMS(phoneNumber); } private async _confirmRegisterSMS(verificationCode: string) { await this._appModel.api.confirmRegisterSMS(verificationCode); } private async _unregisterSMS() { await this._appModel.api.unregisterSMS(); } private async _verifyPassword(password: string, preferredMfaMethod?: AuthMethod) { return await this._appModel.api.verifyPassword(password, preferredMfaMethod); } private async _verifySecondStep(authMethod: AuthMethod, verificationCode: string, session: string) { await this._appModel.api.verifySecondStep(authMethod, verificationCode, session); } /** * Returns a data URL for a QR code that encodes a software token (TOTP) MFA shared secret. The * URL can be set on an HTML image tag to display an image of the QR code in the browser. * * Used by _buildConfigureAuthAppForm to build the TOTP registration form. */ private async _getSoftwareTokenQRCode() { const {secretCode} = await this._registerSoftwareToken(); const qrCodeUrl = `otpauth://totp/${encodeURI(`Grist:${this._user.email}`)}?secret=${secretCode}&issuer=Grist`; const qrCode = await toDataURL(qrCodeUrl); return qrCode; } } /** * Sets the error details on `errObs` if `err` is a 4XX error (except 401). Otherwise, reports the * error via the Notifier instance. */ function handleFormError(err: unknown, errObs: Observable) { if ( err instanceof ApiError && err.status !== 401 && err.status >= 400 && err.status < 500 ) { errObs.set(err.details?.userError ?? err.message); } else { reportError(err as Error|string); } } const spinnerSizePixels = '24px'; const cssButtons = styled('div', ` min-height: ${spinnerSizePixels}; position: relative; display: flex; flex-direction: column; margin: 8px 0px; `); const cssDataRow = styled('div', ` margin-top: 8px; display: flex; gap: 16px; `); const cssText = styled('div', ` font-size: ${vars.mediumFontSize}; border: none; padding: 0; text-align: left; `); const cssMainText = styled(cssText, ` margin-bottom: 32px; `); const cssListItem = styled(cssText, ` margin-bottom: 16px; `); const cssSubText = styled(cssText, ` margin-top: 16px; `); const cssFormError = styled('div', ` color: red; min-height: 20px; margin-top: 16px; `); const cssIconAndText = styled('div', ` display: flex; align-items: center; gap: 8px; `); const cssTextBtn = styled('button', ` font-size: ${vars.mediumFontSize}; color: ${colors.lightGreen}; cursor: pointer; background-color: transparent; border: none; padding: 0; text-align: left; &:hover { color: ${colors.darkGreen}; } `); const cssBackBtn = styled(cssTextBtn, ` margin-top: 16px; `); const cssAuthMethods = styled('div', ` display: grid; grid-auto-rows: 1fr; margin-top: 16px; gap: 8px; `); const cssAuthMethod = styled('div', ` border: 1px solid ${colors.mediumGreyOpaque}; border-radius: 3px; cursor: pointer; &:hover { border: 1px solid ${colors.slate}; } `); const cssAuthMethodTitle = styled(cssIconAndText, ` font-size: ${vars.mediumFontSize}; font-weight: bold; color: ${colors.lightGreen}; margin: 16px 16px 8px 16px; `); const cssAuthMethodDesc = styled('div', ` color: #8a8a8a; padding: 0px 16px 16px 40px; `); const cssInput = styled(input, ` font-size: ${vars.mediumFontSize}; height: 42px; line-height: 16px; width: 100%; padding: 13px; border: 1px solid #D9D9D9; border-radius: 3px; outline: none; &[type=number] { -moz-appearance: textfield; } &[type=number]::-webkit-inner-spin-button, &[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } `); const cssCodeInput = styled(cssInput, ` width: 200px; `); const cssSmallLoadingSpinner = styled(loadingSpinner, ` width: ${spinnerSizePixels}; height: ${spinnerSizePixels}; border-radius: ${spinnerSizePixels}; `); const cssCenteredDiv = styled('div', ` display: flex; justify-content: center; align-items: center; `); const cssSmallSpinner = cssCenteredDiv; const cssSpinner = styled(cssCenteredDiv, ` height: 200px; min-width: 200px; `); const cssBoldSubHeading = styled('div', ` font-weight: bold; margin-bottom: 16px; `); const cssLightlyBoldedText = styled('span', ` font-weight: 500; `); const cssQRCode = styled('img', ` width: 140px; height: 140px; margin-bottom: 16px; `); const cssIcon = styled(icon, ` width: 16px; height: 16px; `); const cssGreenIcon = styled(cssIcon, ` background-color: ${colors.lightGreen}; `); const cssModalButtons = styled(modalButtons, ` margin: 16px 0 0 0; `);