diff --git a/app/client/accountMain.ts b/app/client/accountMain.ts index ab9a8974..ed4b2da1 100644 --- a/app/client/accountMain.ts +++ b/app/client/accountMain.ts @@ -1,17 +1,5 @@ -import {TopAppModelImpl} from 'app/client/models/AppModel'; -import {setUpErrorHandling} from 'app/client/models/errors'; import {AccountPage} from 'app/client/ui/AccountPage'; -import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; -import {addViewportTag} from 'app/client/ui/viewport'; -import {attachCssRootVars} from 'app/client/ui2018/cssVars'; +import {setupPage} from 'app/client/ui/setupPage'; import {dom} from 'grainjs'; -// Set up the global styles for variables, and root/body styles. -setUpErrorHandling(); -const topAppModel = TopAppModelImpl.create(null, {}); -attachCssRootVars(topAppModel.productFlavor); -addViewportTag(); -dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [ - dom.create(AccountPage, appModel), - buildSnackbarDom(appModel.notifier, appModel), -])); +setupPage((appModel) => dom.create(AccountPage, appModel)); diff --git a/app/client/errorMain.ts b/app/client/errorMain.ts index f4f44f97..55c7830e 100644 --- a/app/client/errorMain.ts +++ b/app/client/errorMain.ts @@ -1,17 +1,4 @@ -import {TopAppModelImpl} from 'app/client/models/AppModel'; -import {setUpErrorHandling} from 'app/client/models/errors'; import {createErrPage} from 'app/client/ui/errorPages'; -import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; -import {addViewportTag} from 'app/client/ui/viewport'; -import {attachCssRootVars} from 'app/client/ui2018/cssVars'; -import {dom} from 'grainjs'; +import {setupPage} from 'app/client/ui/setupPage'; -// Set up the global styles for variables, and root/body styles. -setUpErrorHandling(); -const topAppModel = TopAppModelImpl.create(null, {}); -attachCssRootVars(topAppModel.productFlavor); -addViewportTag(); -dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [ - createErrPage(appModel), - buildSnackbarDom(appModel.notifier, appModel), -])); +setupPage((appModel) => createErrPage(appModel)); diff --git a/app/client/lib/formUtils.ts b/app/client/lib/formUtils.ts new file mode 100644 index 00000000..6ef74fab --- /dev/null +++ b/app/client/lib/formUtils.ts @@ -0,0 +1,54 @@ +import {reportError} from 'app/client/models/errors'; +import {BaseAPI} from 'app/common/BaseAPI'; +import {dom, Observable} from 'grainjs'; + +/** + * Handles submission of an HTML form element. + * + * When the form is submitted, `onSubmit` will be called, followed by + * either `onSuccess` or `onError`, depending on whether `onSubmit` threw any + * unhandled errors. The `pending` observable is set to true until `onSubmit` + * resolves. + */ +export function handleSubmit( + pending: Observable, + onSubmit: (fields: { [key: string]: string }, form: HTMLFormElement) => Promise = submitForm, + onSuccess: (v: T) => void = () => { /* noop */ }, + onError: (e: unknown) => void = (e) => reportError(e as string | Error) +): (elem: HTMLFormElement) => void { + return dom.on('submit', async (e, form) => { + e.preventDefault(); + try { + if (pending.get()) { return; } + + pending.set(true); + const result = await onSubmit(formDataToObj(form), form).finally(() => pending.set(false)); + onSuccess(result); + } catch (err) { + onError(err); + } + }); +} + +/** + * Convert a form to a JSON-stringifiable object, ignoring any File fields. + */ +export function formDataToObj(formElem: HTMLFormElement): { [key: string]: string } { + // Use FormData to collect values (rather than e.g. finding elements) to ensure we get + // values from all form items correctly (e.g. checkboxes and textareas). + const formData = new FormData(formElem); + const data: { [key: string]: string } = {}; + for (const [name, value] of formData.entries()) { + if (typeof value === 'string') { + data[name] = value; + } + } + return data; +} + +/** + * Submit a form using BaseAPI. Send inputs as JSON, and interpret any reply as JSON. + */ +export async function submitForm(fields: { [key: string]: string }, form: HTMLFormElement): Promise { + return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(fields)}); +} diff --git a/app/client/lib/uploads.ts b/app/client/lib/uploads.ts index bfce3cce..363849a5 100644 --- a/app/client/lib/uploads.ts +++ b/app/client/lib/uploads.ts @@ -10,7 +10,6 @@ import {DocComm} from 'app/client/components/DocComm'; import {UserError} from 'app/client/models/errors'; import {FileDialogOptions, openFilePicker} from 'app/client/ui/FileDialog'; -import {BaseAPI} from 'app/common/BaseAPI'; import {GristLoadConfig} from 'app/common/gristUrls'; import {byteString, safeJsonParse} from 'app/common/gutil'; import {FetchUrlOptions, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads'; @@ -184,25 +183,3 @@ export function isDriveUrl(url: string) { const match = /^https:\/\/(docs|drive).google.com\/(spreadsheets|file)\/d\/([^/]*)/i.exec(url); return !!match; } - -/** - * Convert a form to a JSON-stringifiable object, ignoring any File fields. - */ -export function formDataToObj(formElem: HTMLFormElement): {[key: string]: string} { - // Use FormData to collect values (rather than e.g. finding elements) to ensure we get - // values from all form items correctly (e.g. checkboxes and textareas). - const formData = new FormData(formElem); - const data: {[key: string]: string} = {}; - for (const [name, value] of formData.entries()) { - if (typeof value === 'string') { - data[name] = value; - } - } - return data; -} - -// Submit a form using BaseAPI. Send inputs as JSON, and interpret any reply as JSON. -export async function submitForm(form: HTMLFormElement): Promise { - const data = formDataToObj(form); - return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(data)}); -} diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts index 62fe877e..574aa27e 100644 --- a/app/client/ui/AccountPage.ts +++ b/app/client/ui/AccountPage.ts @@ -3,7 +3,7 @@ import {getResetPwdUrl, urlState} from 'app/client/models/gristUrlState'; import {ApiKey} from 'app/client/ui/ApiKey'; import {AppHeader} from 'app/client/ui/AppHeader'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; -// import {MFAConfig} from 'app/client/ui/MFAConfig'; +import {MFAConfig} from 'app/client/ui/MFAConfig'; import {pagePanels} from 'app/client/ui/PagePanels'; import {createTopBarHome} from 'app/client/ui/TopBar'; import {transientInput} from 'app/client/ui/transientInput'; @@ -13,7 +13,7 @@ import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/b import {icon} from 'app/client/ui2018/icons'; import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals'; import {colors, vars} from 'app/client/ui2018/cssVars'; -import {FullUser, /*UserMFAPreferences*/} from 'app/common/UserAPI'; +import {FullUser, UserMFAPreferences} from 'app/common/UserAPI'; import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs'; const testId = makeTestId('test-account-page-'); @@ -24,7 +24,7 @@ const testId = makeTestId('test-account-page-'); export class AccountPage extends Disposable { private _apiKey = Observable.create(this, ''); private _userObs = Observable.create(this, null); - // private _userMfaPreferences = Observable.create(this, null); + private _userMfaPreferences = Observable.create(this, null); private _isEditingName = Observable.create(this, false); private _nameEdit = Observable.create(this, ''); private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val)); @@ -86,26 +86,24 @@ export class AccountPage extends Disposable { cssDataRow( cssSubHeader('Login Method'), user.loginMethod, - // TODO: should show btn only when logged in with google user.loginMethod === 'Email + Password' ? cssTextBtn( - // rename to remove mention of Billing in the css cssIcon('Settings'), 'Reset', dom.on('click', () => confirmPwdResetModal(user.email)), ) : null, testId('login-method'), ), - // user.loginMethod !== 'Email + Password' ? null : dom.frag( - // cssSubHeaderFullWidth('Two-factor authentication'), - // cssDescription( - // "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." - // ), - // dom.create(MFAConfig, this._userMfaPreferences, { - // user, - // appModel: this._appModel, - // }), - // ), + user.loginMethod !== 'Email + Password' ? null : dom.frag( + cssSubHeaderFullWidth('Two-factor authentication'), + cssDescription( + "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." + ), + dom.create(MFAConfig, this._userMfaPreferences, { + appModel: this._appModel, + onChange: () => this._fetchUserMfaPreferences(), + }), + ), cssHeader('API'), cssDataRow(cssSubHeader('API Key'), cssContent( dom.create(ApiKey, { @@ -152,13 +150,14 @@ export class AccountPage extends Disposable { this._userObs.set(await this._appModel.api.getUserProfile()); } - // private async _fetchUserMfaPreferences() { - // this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences()); - // } + private async _fetchUserMfaPreferences() { + this._userMfaPreferences.set(null); + this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences()); + } private async _fetchAll() { await Promise.all([ - // this._fetchUserMfaPreferences(), + this._fetchUserMfaPreferences(), this._fetchApiKey(), this._fetchUserProfile(), ]); @@ -263,7 +262,7 @@ const cssWarnings = styled(buildNameWarningsDom, ` margin: -8px 0 0 110px; `); -// const cssDescription = styled('div', ` -// color: #8a8a8a; -// font-size: 13px; -// `); +const cssDescription = styled('div', ` + color: #8a8a8a; + font-size: 13px; +`); diff --git a/app/client/ui/MFAConfig.ts b/app/client/ui/MFAConfig.ts index 9b862d98..c86d3660 100644 --- a/app/client/ui/MFAConfig.ts +++ b/app/client/ui/MFAConfig.ts @@ -1,7 +1,6 @@ -import {submitForm} from 'app/client/lib/uploads'; +import {handleSubmit} from 'app/client/lib/formUtils'; import {AppModel} from 'app/client/models/AppModel'; import {reportError, reportSuccess} from 'app/client/models/errors'; -import {getMainOrgUrl} from 'app/client/models/gristUrlState'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {colors, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; @@ -11,22 +10,20 @@ import {cssModalBody, cssModalTitle, IModalControl, modal, cssModalButtons as modalButtons} from 'app/client/ui2018/modals'; import {ApiError} from 'app/common/ApiError'; import {FullUser} from 'app/common/LoginSessionAPI'; -import {UserMFAPreferences} from 'app/common/UserAPI'; -import {Disposable, dom, input, makeTestId, Observable, styled} from 'grainjs'; +import {AuthMethod, UserMFAPreferences} from 'app/common/UserAPI'; +import {Disposable, dom, input, makeTestId, MultiHolder, Observable, styled} from 'grainjs'; import {toDataURL} from 'qrcode'; const testId = makeTestId('test-mfa-'); -type AuthMethod = - | 'SOFTWARE_TOKEN'; - /** * Step in the dialog flow for enabling a MFA method. */ type EnableAuthMethodStep = | 'verify-password' | 'choose-auth-method' - | 'configure-auth-app'; + | 'configure-auth-app' + | 'configure-phone-message'; /** * Step in the dialog flow for disabling a MFA method. @@ -38,14 +35,35 @@ type DisableAuthMethodStep = interface MFAConfigOptions { appModel: AppModel; - user: FullUser; + // 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 only. + * Currently supports software tokens (TOTP) and SMS. */ export class MFAConfig extends Disposable { private _appModel: AppModel; @@ -53,11 +71,11 @@ export class MFAConfig extends Disposable { constructor( private _mfaPrefs: Observable, - options: MFAConfigOptions + private _options: MFAConfigOptions ) { super(); - this._appModel = options.appModel; - this._user = options.user; + this._appModel = _options.appModel; + this._user = this._appModel.currentUser!; } public buildDom() { @@ -65,45 +83,107 @@ export class MFAConfig extends Disposable { } private _buildButtons() { - return dom.maybe(this._mfaPrefs, mfaPrefs => { - const {isSmsMfaEnabled, isSoftwareTokenMfaEnabled} = mfaPrefs; - - return cssContainer( - !isSmsMfaEnabled && !isSoftwareTokenMfaEnabled ? - cssTextBtn( - 'Enable two-factor authentication', - dom.on('click', () => this._showAddAuthMethodModal()), - testId('enable-2fa'), - ) : - dom.frag( - isSoftwareTokenMfaEnabled ? + return cssButtons( + dom.domComputed(this._mfaPrefs, mfaPrefs => { + if (!mfaPrefs) { return cssCenteredDiv(cssSmallLoadingSpinner()); } + + const {isSmsMfaEnabled, isSoftwareTokenMfaEnabled, phoneNumber} = mfaPrefs; + return [ + !isSmsMfaEnabled && !isSoftwareTokenMfaEnabled ? + cssTextBtn( + 'Enable 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')), - cssTextBtn( - 'Disable', - dom.on('click', () => this._showDisableAuthMethodModal('SOFTWARE_TOKEN')), - testId('disable-auth-app'), - ) - ) : - cssTextBtn( - 'Add an authenticator app', - dom.on('click', () => this._showAddAuthMethodModal('SOFTWARE_TOKEN')), - testId('add-auth-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') ), - ), - testId('container'), - ); - }); + 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} method If specified, skips the 'choose-auth-method' step. + * @param {AuthMethod | undefined} method If specified, skips the 'choose-auth-method' step. + * @param {() => void} options.onSuccess Called after successfully adding a auth method. */ - private _showAddAuthMethodModal(method?: AuthMethod): void { + private _showAddAuthMethodModal( + method: AuthMethod | undefined, + options: {onSuccess: () => void} + ): void { return modal((ctl, owner) => { - const selectedAuthMethod = Observable.create(owner, method ?? null); const currentStep = Observable.create(owner, 'verify-password'); return [ @@ -112,23 +192,24 @@ export class MFAConfig extends Disposable { switch (step) { case 'verify-password': { return [ - this._buildSecurityVerificationForm({onSuccess: async () => { - currentStep.set('choose-auth-method'); + this._buildSecurityVerificationForm(ctl, {onSuccess: async () => { + if (!method) { return currentStep.set('choose-auth-method'); } + + currentStep.set(method === 'SMS' ? 'configure-phone-message' : 'configure-auth-app'); }}), - cssTextBtn('← Back', dom.on('click', () => { ctl.close(); })), ]; } case 'choose-auth-method': { return [ - cssModalTitle('Two-factor authentication'), + cssModalTitle('Two-factor authentication', testId('title')), cssModalBody( - cssText( - "Once you enable two step verification, you'll need to enter a special code " + + 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'), + 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 " + @@ -140,12 +221,18 @@ export class MFAConfig extends Disposable { }), ".", ), - dom.on('click', () => { - selectedAuthMethod.set('SOFTWARE_TOKEN'); - currentStep.set('configure-auth-app'); - }), + 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 phone number where you can receive authentication codes by text.', + ), + dom.on('click', () => currentStep.set('configure-phone-message')), + testId('sms-method'), + ), + ), ), ]; } @@ -153,10 +240,25 @@ export class MFAConfig extends Disposable { return [ this._buildConfigureAuthAppForm(ctl, {onSuccess: async () => { ctl.close(); - reportSuccess('Two-factor authentication enabled'); - this._mfaPrefs.set({...this._mfaPrefs.get()!, isSoftwareTokenMfaEnabled: true}); + options.onSuccess(); }}), - cssTextBtn('← Back to methods', dom.on('click', () => { currentStep.set('choose-auth-method'); })), + 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'), + }), ]; } } @@ -169,9 +271,9 @@ export class MFAConfig extends Disposable { /** * Displays a modal that allows users to disable a MFA method for their account. * - * @param {AuthMethod} method The auth method to disable. Currently unused, until additional methods are added. + * @param {AuthMethod} method The auth method to disable. */ - private _showDisableAuthMethodModal(method: AuthMethod): void { + private _showDisableAuthMethodModal(method: AuthMethod, options: {onSuccess: () => void}): void { return modal((ctl, owner) => { const currentStep = Observable.create(owner, 'confirm-disable'); @@ -181,36 +283,43 @@ export class MFAConfig extends Disposable { switch (step) { case 'confirm-disable': { return [ - cssModalTitle('Disable authenticator app?'), + cssModalTitle( + `Disable ${method === 'TOTP' ? 'authentication app' : 'phone message'}?`, + testId('title') + ), cssModalBody( - cssText( + 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('Confirm', dom.on('click', () => { currentStep.set('verify-password'); })), - bigBasicButton('Cancel', dom.on('click', () => ctl.close())), + 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({onSuccess: () => currentStep.set('disable-method')}), - cssTextBtn('← Back', dom.on('click', () => { currentStep.set('confirm-disable'); })), + this._buildSecurityVerificationForm(ctl, {onSuccess: () => { + currentStep.set('disable-method'); + }}), ]; } case 'disable-method': { - this._unregisterSoftwareToken() - .then(() => { - reportSuccess('Authenticator app disabled'); - this._mfaPrefs.set({...this._mfaPrefs.get()!, isSoftwareTokenMfaEnabled: false}); - }) - .catch(reportError) - .finally(() => ctl.close()); - - return cssLoadingSpinner(loadingSpinner()); + const disableMethod = method === 'SMS' ? + this._unregisterSMS() : + this._unregisterSoftwareToken(); + disableMethod + .then(() => { options.onSuccess(); }) + .catch(reportError) + .finally(() => ctl.close()); + + return cssCenteredDivFixedHeight(loadingSpinner()); } } }), @@ -227,104 +336,231 @@ export class MFAConfig extends Disposable { * * @param {() => void} options.onSuccess Called after successful completion of verification. */ - private _buildSecurityVerificationForm({onSuccess}: {onSuccess: () => void}) { - const securityStep = Observable.create<'password' | 'verification-code'>(null, 'password'); - const pending = Observable.create(null, false); - const session = Observable.create(null, ''); + private _buildSecurityVerificationForm(ctl: IModalControl, {onSuccess}: {onSuccess: () => void}) { + const holder = new MultiHolder(); + const securityStep = Observable.create<'password' | 'sms' | 'totp' | 'loading'>(holder, 'password'); + const password = Observable.create(holder, ''); + const maskedPhoneNumber = Observable.create(holder, ''); + const session = Observable.create(holder, ''); return [ - dom.autoDispose(securityStep), - dom.autoDispose(session), - dom.autoDispose(pending), + dom.autoDispose(holder), dom.domComputed(securityStep, (step) => { switch (step) { + case 'loading': { + return cssCenteredDivFixedHeight(loadingSpinner()); + } case 'password': { - const verifyPasswordUrl = getMainOrgUrl() + 'api/auth/verify_pass'; - const password = Observable.create(null, ''); - let passwordElement: HTMLInputElement; - setTimeout(() => passwordElement.focus(), 10); - - const error: Observable = Observable.create(null, null); - const errorListener = pending.addListener(isPending => isPending && error.set(null)); + 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(password), - dom.autoDispose(error), - dom.autoDispose(errorListener), - cssModalTitle('Confirm your password'), + dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), + dom.autoDispose(multiHolder), + cssModalTitle('Confirm your password', testId('title')), cssModalBody( - dom('form', - {method: 'post', action: verifyPasswordUrl}, + 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(); } session.set(result.session); - securityStep.set('verification-code'); - }, - (err) => { - if (isUserError(err)) { - error.set(err.details?.userError ?? err.message); + if (result.challengeName === 'SMS_MFA') { + maskedPhoneNumber.set(result.deliveryDestination!); + securityStep.set('sms'); } else { - reportError(err as Error|string); + securityStep.set('totp'); } }, + (err) => handleFormError(err, errorObs), ), - cssConfirmText('Please confirm your password to continue.'), - cssBoldSubHeading('Password'), - passwordElement = cssInput(password, - {onInput: true}, - {name: 'password', placeholder: 'password', type: 'password'}, - ), - cssFormError(dom.text(use => use(error) ?? '')), cssModalButtons( - bigPrimaryButton('Confirm', - dom.boolAttr('disabled', use => use(pending) || use(password).trim().length === 0), - ), + bigPrimaryButton('Confirm', dom.boolAttr('disabled', pending), testId('confirm')), + bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), ), ), ); } - case 'verification-code': { - const verifyAuthCodeUrl = getMainOrgUrl() + 'api/auth/verify_totp'; - const authCode = Observable.create(null, ''); - - const error: Observable = Observable.create(null, null); - const errorListener = pending.addListener(isPending => isPending && error.set(null)); + 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 smsNumber = this._mfaPrefs.get()?.phoneNumber; return dom.frag( - dom.autoDispose(authCode), - dom.autoDispose(error), - dom.autoDispose(errorListener), - cssModalTitle('Almost there!'), + dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), + dom.autoDispose(multiHolder), + cssModalTitle('Almost there!', testId('title')), cssModalBody( - dom('form', - {method: 'post', action: verifyAuthCodeUrl}, + 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(), - (err) => { - if (isUserError(err)) { - error.set(err.details?.userError ?? err.message); - } else { - reportError(err as Error|string); - } - }, + (err) => handleFormError(err, errorObs), ), - cssConfirmText('Enter the authentication code generated by your app to confirm your account.'), - cssBoldSubHeading('Verification Code '), - cssCodeInput(authCode, {onInput: true}, {name: 'verificationCode', type: 'number'}), - cssFormError(dom.text(use => use(error) ?? '')), - cssInput(session, {onInput: true}, {name: 'session', type: 'hidden'}), cssModalButtons( - bigPrimaryButton('Submit', - dom.boolAttr('disabled', use => use(pending) || use(authCode).trim().length !== 6), + bigPrimaryButton('Submit', dom.boolAttr('disabled', pending), testId('submit')), + bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), + ), + !this._mfaPrefs.get()?.isSmsMfaEnabled || !smsNumber ? null : cssSubText( + 'Receive a code by SMS?', + cssLink( + ` Text ${smsNumber}.`, + 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); + maskedPhoneNumber.set(result.deliveryDestination!); + 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(''); + }); + + 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 cssCenteredDivFixedHeight(loadingSpinner()); } + + return [ + cssModalTitle('Almost there!', testId('title')), + cssModalBody( + formElement = dom('form', + cssMainText( + 'We have sent an 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'), + ), + 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); } + } 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(), + (err) => handleFormError(err, errorObs), + ), + cssModalButtons( + bigPrimaryButton('Submit', dom.boolAttr('disabled', pending), testId('submit')), + bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), + ), + !this._mfaPrefs.get()?.isSoftwareTokenMfaEnabled ? 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'), + ), + ), + ) + ) + ]; + }), + ); + } } }), ]; @@ -339,67 +575,60 @@ export class MFAConfig extends Disposable { * @param {() => void} options.onSuccess Called after successful completion of registration. */ private _buildConfigureAuthAppForm(ctl: IModalControl, {onSuccess}: {onSuccess: () => void}) { - const confirmCodeUrl = getMainOrgUrl() + 'api/auth/confirm_totp_registration'; - const qrCode: Observable = Observable.create(null, null); - const verificationCode = Observable.create(null, ''); - const pending = Observable.create(null, false); - - const error: Observable = Observable.create(null, null); - const errorListener = pending.addListener(isPending => isPending && error.set(null)); + 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); }); + .then(code => qrCode.isDisposed() || qrCode.set(code)) + .catch(e => { ctl.close(); reportError(e); }); return [ - dom.autoDispose(qrCode), - dom.autoDispose(verificationCode), - dom.autoDispose(pending), - dom.autoDispose(error), - dom.autoDispose(errorListener), + dom.autoDispose(pending.addListener(isPending => isPending && errorObs.set(null))), + dom.autoDispose(holder), dom.domComputed(qrCode, code => { - if (code === null) { return cssLoadingSpinner(loadingSpinner()); } + if (code === null) { return cssCenteredDivFixedHeight(loadingSpinner()); } return [ - cssModalTitle('Configure authenticator app'), + cssModalTitle('Configure authenticator app', testId('title')), cssModalBody( cssModalBody( - cssConfigureAuthAppDesc( - "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', - }), - ".", - ), - cssConfigureAuthAppSubHeading('To configure your authenticator app:'), - cssConfigureAuthAppStep('1. Add a new account'), - cssConfigureAuthAppStep('2. Scan the following QR code', {style: 'margin-bottom: 0px'}), - cssQRCode({src: code}), - cssConfigureAuthAppStep('3. Enter the verification code that appears after scanning the QR code'), - dom('form', - {method: 'post', action: confirmCodeUrl}, + 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) => { - if (isUserError(err)) { - error.set(err.details?.userError ?? err.message); - } else { - reportError(err as Error|string); - } - }, - ), - cssBoldSubHeading('Authentication code'), - cssCodeInput(verificationCode, {onInput: true}, {name: 'userCode', type: 'number'}), - cssFormError(dom.text(use => use(error) ?? '')), + (err) => handleFormError(err, errorObs), + ), cssModalButtons( - bigPrimaryButton('Verify', - dom.boolAttr('disabled', use => use(pending) || use(verificationCode).trim().length !== 6), - ), - bigBasicButton('Cancel', dom.on('click', () => ctl.close())), + bigPrimaryButton('Verify', dom.boolAttr('disabled', pending), testId('verify')), + bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('cancel')), ), ), ), @@ -409,12 +638,180 @@ export class MFAConfig extends Disposable { ]; } + /** + * 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 phone number where you can receive authentication codes by text.', + ), + cssBoldSubHeading('Phone number'), + cssInput(phoneNumber, + {onInput: true}, + {name: 'phoneNumber', placeholder: '+999 (99) 999 99 99', 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 cssCenteredDivFixedHeight(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() { - return await this._appModel.api.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); } /** @@ -432,50 +829,26 @@ export class MFAConfig extends Disposable { } /** - * Helper function that handles form submissions. Sets `pending` to true after - * submitting, and resets it to false after submission completes. - * - * Callback functions `onSuccess` and `onError` handle post-submission logic. + * Sets the error details on `errObs` if `err` is a 4XX error (except 401). Otherwise, reports the + * error via the Notifier instance. */ -function handleSubmit(pending: Observable, - onSuccess: (v: any) => void, - onError?: (e: unknown) => void -): (elem: HTMLFormElement) => void { - return dom.on('submit', async (e, form) => { - e.preventDefault(); - await submit(form, pending, onSuccess, onError); - }); -} - -/** - * Submits an HTML form, and forwards responses and errors to `onSuccess` and `onError` respectively. - */ -async function submit(form: HTMLFormElement, pending: Observable, - onSuccess: (v: any) => void, - onError: (e: unknown) => void = (e) => reportError(e as string|Error) -) { - try { - if (pending.get()) { return; } - pending.set(true); - const result = await submitForm(form).finally(() => pending.set(false)); - onSuccess(result); - } catch (err) { - onError(err); +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); } } -/** - * Returns true if `error` is an API error with a 4XX status code. - * - * Used to determine which errors should be shown in-line in forms. - */ -function isUserError(error: unknown): error is ApiError { - if (!(error instanceof ApiError)) { return false; } - - return error.status >= 400 && error.status < 500; -} +const spinnerSizePixels = '24px'; -const cssContainer = styled('div', ` +const cssButtons = styled('div', ` + min-height: ${spinnerSizePixels}; position: relative; display: flex; flex-direction: column; @@ -495,20 +868,24 @@ const cssText = styled('div', ` text-align: left; `); -const cssConfirmText = styled(cssText, ` +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 cssConfigureAuthAppDesc = styled(cssText, ` - margin-bottom: 32px; -`); - const cssIconAndText = styled('div', ` display: flex; align-items: center; @@ -529,6 +906,10 @@ const cssTextBtn = styled('button', ` } `); +const cssBackBtn = styled(cssTextBtn, ` + margin-top: 16px; +`); + const cssAuthMethods = styled('div', ` display: flex; flex-direction: column; @@ -537,6 +918,7 @@ const cssAuthMethods = styled('div', ` `); const cssAuthMethod = styled('div', ` + height: 120px; border: 1px solid ${colors.mediumGreyOpaque}; cursor: pointer; @@ -559,7 +941,6 @@ const cssAuthMethodDesc = styled('div', ` `); const cssInput = styled(input, ` - margin-top: 16px; font-size: ${vars.mediumFontSize}; height: 42px; line-height: 16px; @@ -587,28 +968,35 @@ const cssModal = styled('div', ` width: 600px; `); -const cssLoadingSpinner = styled('div', ` - height: 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 cssBoldSubHeading = styled('div', ` - font-weight: bold; +const cssCenteredDivFixedHeight = styled(cssCenteredDiv, ` + height: 200px; `); -const cssConfigureAuthAppSubHeading = styled(cssBoldSubHeading, ` +const cssBoldSubHeading = styled('div', ` + font-weight: bold; margin-bottom: 16px; `); -const cssConfigureAuthAppStep = styled(cssText, ` - 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, ` diff --git a/app/client/ui/WelcomePage.ts b/app/client/ui/WelcomePage.ts index 4368905e..2c4402e1 100644 --- a/app/client/ui/WelcomePage.ts +++ b/app/client/ui/WelcomePage.ts @@ -1,6 +1,6 @@ import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs"; -import { submitForm } from "app/client/lib/uploads"; +import { handleSubmit, submitForm } from "app/client/lib/formUtils"; import { AppModel, reportError } from "app/client/models/AppModel"; import { getLoginUrl, getSignupUrl, urlState } from "app/client/models/gristUrlState"; import { AccountWidget } from "app/client/ui/AccountWidget"; @@ -35,29 +35,12 @@ function _redirectOnSuccess(result: any) { window.location.assign(redirectUrl); } - -async function _submitForm(form: HTMLFormElement, pending: Observable, - onSuccess: (v: any) => void = _redirectOnSuccess, - onError: (e: Error) => void = reportError) { - try { - if (pending.get()) { return; } - pending.set(true); - const result = await submitForm(form).finally(() => pending.set(false)); - onSuccess(result); - } catch (err) { - onError(err?.details?.userError || err); - } -} - -// If a 'pending' observable is given, it will be set to true while waiting for the submission. -function handleSubmit(pending: Observable, - onSuccess?: (v: any) => void, - onError?: (e: Error) => void): (elem: HTMLFormElement) => void { - return dom.on('submit', async (e, form) => { - e.preventDefault(); - // TODO: catch isn't needed, so either remove or propagate errors from _submitForm. - _submitForm(form, pending, onSuccess, onError).catch(reportError); - }); +function handleSubmitForm( + pending: Observable, + onSuccess: (v: any) => void = _redirectOnSuccess, + onError?: (e: unknown) => void +): (elem: HTMLFormElement) => void { + return handleSubmit(pending, submitForm, onSuccess, onError); } export class WelcomePage extends Disposable { @@ -114,13 +97,13 @@ export class WelcomePage extends Disposable { return form = dom( 'form', { method: "post", action: location.href }, - handleSubmit(pending), + handleSubmitForm(pending), cssLabel('Your full name, as you\'d like it displayed to your collaborators.'), inputEl = cssInput( value, { onInput: true, }, { name: "username" }, // TODO: catch isn't needed, so either remove or propagate errors from _submitForm. - dom.onKeyDown({Enter: () => isNameValid.get() && _submitForm(form, pending).catch(reportError)}), + dom.onKeyDown({Enter: () => isNameValid.get() && form.requestSubmit()}), ), dom.maybe((use) => use(value) && !use(isNameValid), buildNameWarningsDom), cssButtonGroup( @@ -153,7 +136,7 @@ export class WelcomePage extends Disposable { return dom( 'form', { method: "post", action: action.href }, - handleSubmit(pending, () => _redirectToSiblingPage('verify')), + handleSubmitForm(pending, () => _redirectToSiblingPage('verify')), dom('p', `Welcome Sumo-ling! ` + // This flow currently only used with AppSumo. `Your Grist site is almost ready. Let's get your account set up and verified. ` + @@ -212,7 +195,7 @@ export class WelcomePage extends Disposable { return dom( 'form', { method: "post", action: action.href }, - handleSubmit(pending, (result) => { + handleSubmitForm(pending, (result) => { if (result.act === 'confirmed') { const verified = new URL(window.location.href); verified.pathname = '/verified'; @@ -259,7 +242,7 @@ export class WelcomePage extends Disposable { const pending = Observable.create(owner, false); return forms.form({method: "post", action: location.href }, - handleSubmit(pending), + handleSubmitForm(pending), (elem) => { setTimeout(() => elem.focus(), 0); }, forms.text('Please help us serve you better by answering a few questions.'), forms.question( diff --git a/app/client/ui/setupPage.ts b/app/client/ui/setupPage.ts new file mode 100644 index 00000000..aa789648 --- /dev/null +++ b/app/client/ui/setupPage.ts @@ -0,0 +1,21 @@ +import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel'; +import {setUpErrorHandling} from 'app/client/models/errors'; +import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; +import {addViewportTag} from 'app/client/ui/viewport'; +import {attachCssRootVars} from 'app/client/ui2018/cssVars'; +import {dom, DomContents} from 'grainjs'; + +/** + * Sets up error handling and global styles, and replaces the DOM body with + * the result of calling `buildPage`. + */ +export function setupPage(buildPage: (appModel: AppModel) => DomContents) { + setUpErrorHandling(); + const topAppModel = TopAppModelImpl.create(null, {}); + attachCssRootVars(topAppModel.productFlavor); + addViewportTag(); + dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [ + buildPage(appModel), + buildSnackbarDom(appModel.notifier, appModel), + ])); +} diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 6cad31e2..3375399d 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -66,6 +66,8 @@ export type IconName = "ChartArea" | "Log" | "Mail" | "Minus" | + "MobileChat" | + "MobileChat2" | "NewNotification" | "Notification" | "Offline" | @@ -177,6 +179,8 @@ export const IconList: IconName[] = ["ChartArea", "Log", "Mail", "Minus", + "MobileChat", + "MobileChat2", "NewNotification", "Notification", "Offline", diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 565c028b..75cb7c53 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -271,6 +271,8 @@ export interface DocStateComparisonDetails { */ export interface UserMFAPreferences { isSmsMfaEnabled: boolean; + // If SMS MFA is enabled, the destination number for receiving verification codes. + phoneNumber?: string; isSoftwareTokenMfaEnabled: boolean; } @@ -278,10 +280,42 @@ export interface UserMFAPreferences { * Cognito response to initiating software token MFA registration. */ export interface SoftwareTokenRegistrationInfo { - session: string; secretCode: string; } +/** + * Cognito response to initiating SMS MFA registration. + */ +export interface SMSRegistrationInfo { + deliveryDestination: string; +} + +/** + * Cognito response to verifying a password (e.g. in a security verification form). + */ +export type PassVerificationResult = ChallengeRequired | ChallengeNotRequired; + +/** + * Information about the follow-up authentication challenge. + */ +interface ChallengeRequired { + isChallengeRequired: true; + // Session identifier that must be re-used in response to auth challenge. + session: string; + challengeName: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA'; + // If challenge is 'SMS_MFA', the destination number that the verification code was sent. + deliveryDestination?: string; +} + +/** + * Successful authentication, with no additional challenge required. + */ +interface ChallengeNotRequired { + isChallengeRequired: false; +} + +export type AuthMethod = 'TOTP' | 'SMS'; + export {UserProfile} from 'app/common/LoginSessionAPI'; export interface UserAPI { @@ -338,7 +372,13 @@ export interface UserAPI { }): Promise; deleteUser(userId: number, name: string): Promise; registerSoftwareToken(): Promise; + confirmRegisterSoftwareToken(verificationCode: string): Promise; unregisterSoftwareToken(): Promise; + registerSMS(phoneNumber: string): Promise; + confirmRegisterSMS(verificationCode: string): Promise; + unregisterSMS(): Promise; + verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise; + verifySecondStep(authMethod: AuthMethod, verificationCode: string, session: string): Promise; getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps. forRemoved(): UserAPI; // Get a version of the API that works on removed resources. getWidgets(): Promise; @@ -695,10 +735,53 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { return this.requestJson(`${this._url}/api/auth/register_totp`, {method: 'POST'}); } + public async confirmRegisterSoftwareToken(verificationCode: string): Promise { + await this.request(`${this._url}/api/auth/confirm_register_totp`, { + method: 'POST', + body: JSON.stringify({verificationCode}), + }); + } + public async unregisterSoftwareToken(): Promise { await this.request(`${this._url}/api/auth/unregister_totp`, {method: 'POST'}); } + public async registerSMS(phoneNumber: string): Promise { + return this.requestJson(`${this._url}/api/auth/register_sms`, { + method: 'POST', + body: JSON.stringify({phoneNumber}), + }); + } + + public async confirmRegisterSMS(verificationCode: string): Promise { + await this.request(`${this._url}/api/auth/confirm_register_sms`, { + method: 'POST', + body: JSON.stringify({verificationCode}), + }); + } + + public async unregisterSMS(): Promise { + await this.request(`${this._url}/api/auth/unregister_sms`, {method: 'POST'}); + } + + public async verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise { + return this.requestJson(`${this._url}/api/auth/verify_pass`, { + method: 'POST', + body: JSON.stringify({password, preferredMfaMethod}), + }); + } + + public async verifySecondStep( + authMethod: AuthMethod, + verificationCode: string, + session: string + ): Promise { + await this.request(`${this._url}/api/auth/verify_second_step`, { + method: 'POST', + body: JSON.stringify({authMethod, verificationCode, session}), + }); + } + public getBaseUrl(): string { return this._url; } // Recomputes the URL on every call to pick up changes in the URL when switching orgs. diff --git a/static/icons/icons.css b/static/icons/icons.css index 19fd3cce..adfe975e 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -67,6 +67,8 @@ --icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMTMgQzE0Ljc3NTg1NzYsMTMgMTUsMTIuNzc1ODU3NiAxNSwxMi41IEwxNSwxLjUgQzE1LDEuMjI0MTQyMzcgMTQuNzc1ODU3NiwxIDE0LjUsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsMTIuNSBDMSwxMi43NzU4NTc2IDEuMjI0MTQyMzcsMTMgMS41LDEzIEwxNC41LDEzIFogTTE0LjUsMTQgTDEuNSwxNCBDMC42NzE4NTc2MjUsMTQgMCwxMy4zMjgxNDI0IDAsMTIuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsOC44ODE3ODQyZS0xNiAxLjUsOC44ODE3ODQyZS0xNiBMMTQuNSw4Ljg4MTc4NDJlLTE2IEMxNS4zMjgxNDI0LDguODgxNzg0MmUtMTYgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxMi41IEMxNiwxMy4zMjgxNDI0IDE1LjMyODE0MjQsMTQgMTQuNSwxNCBaIE0xMy4xODMzODExLDMuMTEzMDIxMzUgQzEzLjM5NzEwMzUsMi45MzgxNTc1NiAxMy43MTIxMTQ5LDIuOTY5NjU4NyAxMy44ODY5Nzg2LDMuMTgzMzgxMSBDMTQuMDYxODQyNCwzLjM5NzEwMzUxIDE0LjAzMDM0MTMsMy43MTIxMTQ4NiAxMy44MTY2MTg5LDMuODg2OTc4NjUgTDguMzE2NjE4OSw4LjM4Njk3ODY1IEM4LjEzMjQzNTk1LDguNTM3NjczNzggNy44Njc1NjQwNSw4LjUzNzY3Mzc4IDcuNjgzMzgxMSw4LjM4Njk3ODY1IEwyLjE4MzM4MTEsMy44ODY5Nzg2NSBDMS45Njk2NTg3LDMuNzEyMTE0ODYgMS45MzgxNTc1NiwzLjM5NzEwMzUxIDIuMTEzMDIxMzUsMy4xODMzODExIEMyLjI4Nzg4NTE0LDIuOTY5NjU4NyAyLjYwMjg5NjQ5LDIuOTM4MTU3NTYgMi44MTY2MTg5LDMuMTEzMDIxMzUgTDgsNy4zNTM5Njk1MyBMMTMuMTgzMzgxMSwzLjExMzAyMTM1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4='); + --icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); + --icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg=='); --icon-NewNotification: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIGN4PSI4IiBjeT0iOCIgcj0iMyIvPjwvc3ZnPg=='); --icon-Notification: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSwxMSBDMi4zMjg4NTc2MywxMSAzLDEwLjMyODg1NzYgMyw5LjUgTDMsNSBDMywyLjIzODg1NzYzIDUuMjM4ODU3NjMsMCA4LDAgQzEwLjc2MTE0MjQsMCAxMywyLjIzODg1NzYzIDEzLDUgTDEzLDkuNSBDMTMsMTAuMjIwMzMyNCAxNC4wNDg5ODI4LDExIDE1LDExIEwxNS41LDExIEMxNS43NzYxNDI0LDExIDE2LDExLjIyMzg1NzYgMTYsMTEuNSBDMTYsMTEuNzc2MTQyNCAxNS43NzYxNDI0LDEyIDE1LjUsMTIgTDAuNSwxMiBDMC4yMjM4NTc2MjUsMTIgMCwxMS43NzYxNDI0IDAsMTEuNSBDMCwxMS4yMjM4NTc2IDAuMjIzODU3NjI1LDExIDAuNSwxMSBMMS41MDAwMDAyNiwxMSBaIE0zLjUwMDQ0ODk0LDExIEwxMi42NzQ1Njg5LDExIEMxMi4yNjMzODg1LDEwLjU3NzkwMjggMTIsMTAuMDU1NTcxOCAxMiw5LjUgTDEyLDUgQzEyLDIuNzkxMTQyMzcgMTAuMjA4ODU3NiwxIDgsMSBDNS43OTExNDIzNywxIDQsMi43OTExNDIzNyA0LDUgTDQsOS41IEM0LDEwLjA2Mjg5NTUgMy44MTQxNTM5MSwxMC41ODIyMjQ1IDMuNTAwNDQ4OTQsMTEgWiBNOS41LDEzLjUgQzkuNSwxMy4yMjM4NTc2IDkuNzIzODU3NjMsMTMgMTAsMTMgQzEwLjI3NjE0MjQsMTMgMTAuNSwxMy4yMjM4NTc2IDEwLjUsMTMuNSBDMTAuNSwxNC44ODExNDI0IDkuMzgxMTQyMzcsMTYgOCwxNiBDNi42MTg4NTc2MywxNiA1LjUsMTQuODgxMTQyNCA1LjUsMTMuNSBDNS41LDEzLjIyMzg1NzYgNS43MjM4NTc2MywxMyA2LDEzIEM2LjI3NjE0MjM3LDEzIDYuNSwxMy4yMjM4NTc2IDYuNSwxMy41IEM2LjUsMTQuMzI4ODU3NiA3LjE3MTE0MjM3LDE1IDgsMTUgQzguODI4ODU3NjMsMTUgOS41LDE0LjMyODg1NzYgOS41LDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Offline: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuMTc1MzI0MDksNy4xMTc1NjkxMyBMMTEuNTU5NTE0Nyw0LjczMzM3ODUgQzguMjc0NDE0MjEsMy4zMTk2ODAxNCA0LjMxODgyNzQ1LDMuOTU0MDg5NTYgMS42MzU1LDYuNjM2NjA2NzcgQzEuNDQwMjA4MzcsNi44MzE4Mzk0MyAxLjEyMzYyNTg5LDYuODMxNzkxNjMgMC45MjgzOTMyMjcsNi42MzY1IEMwLjczMzE2MDU2OCw2LjQ0MTIwODM3IDAuNzMzMjA4MzcyLDYuMTI0NjI1ODkgMC45Mjg1LDUuOTI5MzkzMjMgQzQuMDA4NTkyMjksMi44NTAyMzA5OSA4LjU5NzM2ODQ3LDIuMTk5Nzc3NDUgMTIuMzE0ODYwNiwzLjk3ODAzMjYxIEwxNC4xNDY0NDY2LDIuMTQ2NDQ2NjEgQzE0LjM0MTcwODgsMS45NTExODQ0NiAxNC42NTgyOTEyLDEuOTUxMTg0NDYgMTQuODUzNTUzNCwyLjE0NjQ0NjYxIEMxNS4wNDg4MTU1LDIuMzQxNzA4NzYgMTUuMDQ4ODE1NSwyLjY1ODI5MTI0IDE0Ljg1MzU1MzQsMi44NTM1NTMzOSBMMTMuMjMwNDA4LDQuNDc2Njk4NzUgQzEzLjg4NDI3OTksNC44Nzg3NDgwNCAxNC41MDE5MTUyLDUuMzYyOTc5NTMgMTUuMDY4NSw1LjkyOTM5MzIzIEMxNS4yNjM3OTE2LDYuMTI0NjI1ODkgMTUuMjYzODM5NCw2LjQ0MTIwODM3IDE1LjA2ODYwNjgsNi42MzY1IEMxNC44NzMzNzQxLDYuODMxNzkxNjMgMTQuNTU2NzkxNiw2LjgzMTgzOTQzIDE0LjM2MTUsNi42MzY2MDY3NyBDMTMuNzkyMzczOSw2LjA2NzY1MjQ4IDEzLjE2NjAxNDksNS41OTA4MzI3NCAxMi41MDA5NTkyLDUuMjA2MTQ3NTYgTDEwLjI2MzI2MjIsNy40NDM4NDQ1NSBDMTAuOTgyMjA2Miw3LjczNjQ0NTI2IDExLjY1NTg5NTIsOC4xNzQyNjQ1MyAxMi4yMzk0MDkxLDguNzU3MzAyMzcgQzEyLjQzNDc1MDksOC45NTI0ODQ4MiAxMi40MzQ4ODAxLDkuMjY5MDY3MjggMTIuMjM5Njk3Niw5LjQ2NDQwOTA5IEMxMi4wNDQ1MTUyLDkuNjU5NzUwOSAxMS43Mjc5MzI3LDkuNjU5ODgwMDggMTEuNTMyNTkwOSw5LjQ2NDY5NzYzIEMxMC45MzgzNDY0LDguODcwOTM3ODcgMTAuMjMxODc1MSw4LjQ1NzkwODYgOS40ODE0OTY5Niw4LjIyNTYwOTgyIEwyLjg1MzU1MzM5LDE0Ljg1MzU1MzQgQzIuNjU4MjkxMjQsMTUuMDQ4ODE1NSAyLjM0MTcwODc2LDE1LjA0ODgxNTUgMi4xNDY0NDY2MSwxNC44NTM1NTM0IEMxLjk1MTE4NDQ2LDE0LjY1ODI5MTIgMS45NTExODQ0NiwxNC4zNDE3MDg4IDIuMTQ2NDQ2NjEsMTQuMTQ2NDQ2NiBMOC4yODMxNTk2Myw4LjAwOTczMzU5IEM2LjkxMTA1MTE1LDcuOTMxOTY4MiA1LjUxMzAwNTkzLDguNDE2OTU2MjIgNC40NjQ0MDkwOSw5LjQ2NDY5NzYzIEM0LjI2OTA2NzI4LDkuNjU5ODgwMDggMy45NTI0ODQ4Miw5LjY1OTc1MDkgMy43NTczMDIzNyw5LjQ2NDQwOTA5IEMzLjU2MjExOTkyLDkuMjY5MDY3MjggMy41NjIyNDkxLDguOTUyNDg0ODIgMy43NTc1OTA5MSw4Ljc1NzMwMjM3IEM1LjIyOTkyNzgsNy4yODYxNjY1OSA3LjI3NjM3OTkzLDYuNzM5NTg4ODQgOS4xNzUzMjQwOSw3LjExNzU2OTEzIFogTTguNjY2NjY2NTYsMTIuMjUzNjc3OSBDOC40NjA4NDIyNCwxMi4wNjk1ODI5IDguNDQzMjI3MTIsMTEuNzUzNDkwOSA4LjYyNzMyMjEsMTEuNTQ3NjY2NiBDOC44MTE0MTcwNywxMS4zNDE4NDIyIDkuMTI3NTA5MTEsMTEuMzI0MjI3MSA5LjMzMzMzMzQ0LDExLjUwODMyMjEgQzEwLjAxODIxODMsMTIuMTIwOTAyMSAxMC4xOTc1ODA3LDEzLjEyMTM0MTYgOS43NjgxNjM0NywxMy45MzM2OTc3IEM5LjMzODc0NjIyLDE0Ljc0NjA1MzggOC40MTEwNDYzMiwxNS4xNjEyOTIzIDcuNTE5MTMxMjIsMTQuOTQwMzY0NiBDNi42MjcyMTYxMywxNC43MTk0MzY4IDYuMDAwNTkyMDMsMTMuOTE5MTkxNyA2LjAwMDAwMDEsMTMuMDAwMzIyMSBDNS45OTk4MjIyMSwxMi43MjQxNzk4IDYuMjIzNTM1NTksMTIuNTAwMTc4IDYuNDk5Njc3OSwxMi41MDAwMDAxIEM2Ljc3NTgyMDIyLDEyLjQ5OTgyMjIgNi45OTk4MjIwMSwxMi43MjM1MzU2IDYuOTk5OTk5OSwxMi45OTk2Nzc5IEM3LjAwMDI5NTg2LDEzLjQ1OTExMjcgNy4zMTM2MDc5MSwxMy44NTkyMzUzIDcuNzU5NTY1NDYsMTMuOTY5Njk5MSBDOC4yMDU1MjMsMTQuMDgwMTYzIDguNjY5MzcyOTUsMTMuODcyNTQzOCA4Ljg4NDA4MTU4LDEzLjQ2NjM2NTcgQzkuMDk4NzkwMiwxMy4wNjAxODc3IDkuMDA5MTA5MDEsMTIuNTU5OTY3OSA4LjY2NjY2NjU2LDEyLjI1MzY3NzkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); diff --git a/static/ui-icons/UI/MobileChat.svg b/static/ui-icons/UI/MobileChat.svg new file mode 100644 index 00000000..3a36d076 --- /dev/null +++ b/static/ui-icons/UI/MobileChat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/ui-icons/UI/MobileChat2.svg b/static/ui-icons/UI/MobileChat2.svg new file mode 100644 index 00000000..b3bde614 --- /dev/null +++ b/static/ui-icons/UI/MobileChat2.svg @@ -0,0 +1,4 @@ + + + +