From ba6ecc5e9ec4e2162af9b04e4cbbf7451024e84f Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Fri, 7 Jan 2022 10:11:52 -0800 Subject: [PATCH] (core) Move user profile to new page and begin MFA work Summary: The user profile dialog is now a separate page, in preparation for upcoming work to enable MFA. This commit also contains some MFA changes, but the UI is currently disabled and the implementation is limited to software tokens (TOTP) only. Test Plan: Updated browser tests for new profile page. Tests for MFAConfig and CognitoClient will be added in a later diff, once the UI is enabled. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3199 --- app/client/accountMain.ts | 17 + app/client/models/gristUrlState.ts | 8 +- app/client/ui/AccountPage.ts | 269 +++++++++++++ app/client/ui/AccountWidget.ts | 5 +- app/client/ui/MFAConfig.ts | 625 +++++++++++++++++++++++++++++ app/client/ui/ProfileDialog.ts | 174 -------- app/client/ui2018/IconList.ts | 6 +- app/common/UserAPI.ts | 31 ++ app/common/gristUrls.ts | 7 + app/server/lib/BrowserSession.ts | 28 +- app/server/lib/FlexServer.ts | 15 +- app/server/lib/expressWrap.ts | 57 ++- app/server/mergedServerMain.ts | 1 + buildtools/webpack.config.js | 1 + package.json | 2 + static/account.html | 14 + static/icons/icons.css | 2 + static/ui-icons/UI/BarcodeQR.svg | 7 + static/ui-icons/UI/BarcodeQR2.svg | 11 + test/nbrowser/gristUtils.ts | 6 +- yarn.lock | 104 ++++- 21 files changed, 1179 insertions(+), 211 deletions(-) create mode 100644 app/client/accountMain.ts create mode 100644 app/client/ui/AccountPage.ts create mode 100644 app/client/ui/MFAConfig.ts delete mode 100644 app/client/ui/ProfileDialog.ts create mode 100644 static/account.html create mode 100644 static/ui-icons/UI/BarcodeQR.svg create mode 100644 static/ui-icons/UI/BarcodeQR2.svg diff --git a/app/client/accountMain.ts b/app/client/accountMain.ts new file mode 100644 index 00000000..ab9a8974 --- /dev/null +++ b/app/client/accountMain.ts @@ -0,0 +1,17 @@ +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 {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), +])); diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 3c0665f8..af1d9419 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -145,14 +145,14 @@ export class UrlStateImpl { */ public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState { const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) || - newState.billing || newState.welcome) ? + newState.account || newState.billing || newState.welcome) ? (prevState.org ? {org: prevState.org, newui: prevState.newui} : {}) : prevState; return {...keepState, ...newState}; } /** - * Billing pages and doc-specific pages for now require a page load. + * The account page, billing pages, and doc-specific pages for now require a page load. * TODO: Make it so doc pages do NOT require a page load, since we are actually serving the same * single-page app for home and for docs, and should only need a reload triggered if it's * a matter of DocWorker requiring a different version (e.g. /v/OTHER/doc/...). @@ -162,6 +162,8 @@ export class UrlStateImpl { const orgReload = prevState.org !== newState.org; // Reload when moving to/from a document or between doc and non-doc. const docReload = prevState.doc !== newState.doc; + // Reload when moving to/from the account page. + const accountReload = Boolean(prevState.account) !== Boolean(newState.account); // Reload when moving to/from a billing page. const billingReload = Boolean(prevState.billing) !== Boolean(newState.billing); // Reload when changing 'newui' flag. @@ -170,7 +172,7 @@ export class UrlStateImpl { const welcomeReload = Boolean(prevState.welcome) !== Boolean(newState.welcome); // Reload when link keys change, which changes what the user can access const linkKeysReload = !isEqual(prevState.params?.linkParameters, newState.params?.linkParameters); - return Boolean(orgReload || billingReload || gristConfig.errPage + return Boolean(orgReload || accountReload || billingReload || gristConfig.errPage || docReload || newuiReload || welcomeReload || linkKeysReload); } diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts new file mode 100644 index 00000000..62fe877e --- /dev/null +++ b/app/client/ui/AccountPage.ts @@ -0,0 +1,269 @@ +import {AppModel, reportError} from 'app/client/models/AppModel'; +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 {pagePanels} from 'app/client/ui/PagePanels'; +import {createTopBarHome} from 'app/client/ui/TopBar'; +import {transientInput} from 'app/client/ui/transientInput'; +import {buildNameWarningsDom, checkName} from 'app/client/ui/WelcomePage'; +import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; +import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs'; +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 {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs'; + +const testId = makeTestId('test-account-page-'); + +/** + * Creates the account page where a user can manage their profile settings. + */ +export class AccountPage extends Disposable { + private _apiKey = Observable.create(this, ''); + private _userObs = 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)); + + constructor(private _appModel: AppModel) { + super(); + this._fetchAll().catch(reportError); + } + + public buildDom() { + const panelOpen = Observable.create(this, false); + return pagePanels({ + leftPanel: { + panelWidth: Observable.create(this, 240), + panelOpen, + hideOpener: true, + header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), + content: leftPanelBasic(this._appModel, panelOpen), + }, + headerMain: this._buildHeaderMain(), + contentMain: this._buildContentMain(), + }); + } + + private _buildContentMain() { + return domComputed(this._userObs, (user) => user && ( + cssContainer(cssAccountPage( + cssHeader('Account settings'), + cssDataRow(cssSubHeader('Email'), user.email), + cssDataRow( + cssSubHeader('Name'), + domComputed(this._isEditingName, (isEditing) => ( + isEditing ? [ + transientInput( + { + initialValue: user.name, + save: (val) => this._isNameValid.get() && this._updateUserName(val), + close: () => { this._isEditingName.set(false); this._nameEdit.set(''); }, + }, + dom.on('input', (_ev, el) => this._nameEdit.set(el.value)), + ), + cssTextBtn( + cssIcon('Settings'), 'Save', + // No need to save on 'click'. The transient input already does it on close. + ), + ] : [ + user.name, + cssTextBtn( + cssIcon('Settings'), 'Edit', + dom.on('click', () => this._isEditingName.set(true)), + ), + ] + )), + testId('username'), + ), + // show warning for invalid name but not for the empty string + dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), cssWarnings), + cssHeader('Password & Security'), + 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, + // }), + // ), + cssHeader('API'), + cssDataRow(cssSubHeader('API Key'), cssContent( + dom.create(ApiKey, { + apiKey: this._apiKey, + onCreate: () => this._createApiKey(), + onDelete: () => this._deleteApiKey(), + anonymous: false, + }) + )), + ), + testId('body'), + ))); + } + + private _buildHeaderMain() { + return dom.frag( + cssBreadcrumbs({ style: 'margin-left: 16px;' }, + cssBreadcrumbsLink( + urlState().setLinkUrl({}), + 'Home', + testId('home'), + ), + separator(' / '), + dom('span', 'Account'), + ), + createTopBarHome(this._appModel), + ); + } + + private async _fetchApiKey() { + this._apiKey.set(await this._appModel.api.fetchApiKey()); + } + + private async _createApiKey() { + this._apiKey.set(await this._appModel.api.createApiKey()); + } + + private async _deleteApiKey() { + await this._appModel.api.deleteApiKey(); + this._apiKey.set(''); + } + + private async _fetchUserProfile() { + this._userObs.set(await this._appModel.api.getUserProfile()); + } + + // private async _fetchUserMfaPreferences() { + // this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences()); + // } + + private async _fetchAll() { + await Promise.all([ + // this._fetchUserMfaPreferences(), + this._fetchApiKey(), + this._fetchUserProfile(), + ]); + } + + private async _updateUserName(val: string) { + const user = this._userObs.get(); + if (user && val && val === user.name) { return; } + + await this._appModel.api.updateUserName(val); + await this._fetchAll(); + } +} + +function confirmPwdResetModal(userEmail: string) { + return modal((ctl, _owner) => { + return [ + cssModalTitle('Reset Password'), + cssModalBody(`Click continue to open the password reset form. Submit it for your email address: ${userEmail}`), + cssModalButtons( + bigPrimaryButtonLink( + { href: getResetPwdUrl(), target: '_blank' }, + 'Continue', + dom.on('click', () => ctl.close()), + ), + bigBasicButton( + 'Cancel', + dom.on('click', () => ctl.close()), + ), + ), + ]; + }); +} + +const cssContainer = styled('div', ` + display: flex; + justify-content: center; + overflow: auto; +`); + +const cssHeader = styled('div', ` + height: 32px; + line-height: 32px; + margin: 28px 0 16px 0; + color: ${colors.dark}; + font-size: ${vars.xxxlargeFontSize}; + font-weight: ${vars.headerControlTextWeight}; +`); + +const cssAccountPage = styled('div', ` + max-width: 600px; + padding: 32px 64px 24px 64px; +`); + +const cssDataRow = styled('div', ` + margin: 8px 0px; + display: flex; + align-items: baseline; +`); + +const cssSubHeaderFullWidth = styled('div', ` + padding: 8px 0; + display: inline-block; + vertical-align: top; + font-weight: bold; +`); + +const cssSubHeader = styled(cssSubHeaderFullWidth, ` + width: 110px; +`); + +const cssContent = styled('div', ` + flex: 1 1 300px; +`); + +const cssTextBtn = styled('button', ` + font-size: ${vars.mediumFontSize}; + color: ${colors.lightGreen}; + cursor: pointer; + margin-left: auto; + background-color: transparent; + border: none; + padding: 0; + text-align: left; + width: 90px; + + &:hover { + color: ${colors.darkGreen}; + } +`); + +const cssIcon = styled(icon, ` + background-color: ${colors.lightGreen}; + margin: 0 4px 2px 0; + + .${cssTextBtn.className}:hover > & { + background-color: ${colors.darkGreen}; + } +`); + +const cssWarnings = styled(buildNameWarningsDom, ` + margin: -8px 0 0 110px; +`); + +// const cssDescription = styled('div', ` +// color: #8a8a8a; +// font-size: 13px; +// `); diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 9da4602f..7acae528 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -2,7 +2,6 @@ import {loadGristDoc, loadUserManager} from 'app/client/lib/imports'; import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState'; -import {showProfileModal} from 'app/client/ui/ProfileDialog'; import {createUserImage} from 'app/client/ui/UserImage'; import * as viewport from 'app/client/ui/viewport'; import {primaryButton} from 'app/client/ui2018/buttons'; @@ -97,11 +96,11 @@ export class AccountWidget extends Disposable { return [ cssUserInfo( createUserImage(user, 'large'), - cssUserName(user.name, + cssUserName(dom('span', user.name, testId('usermenu-name')), cssEmail(user.email, testId('usermenu-email')) ) ), - menuItem(() => showProfileModal(this._appModel), 'Profile Settings'), + menuItemLink(urlState().setLinkUrl({account: 'profile'}), 'Profile Settings'), documentSettingsItem, diff --git a/app/client/ui/MFAConfig.ts b/app/client/ui/MFAConfig.ts new file mode 100644 index 00000000..9b862d98 --- /dev/null +++ b/app/client/ui/MFAConfig.ts @@ -0,0 +1,625 @@ +import {submitForm} from 'app/client/lib/uploads'; +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'; +import {cssLink} from 'app/client/ui2018/links'; +import {loadingSpinner} from 'app/client/ui2018/loaders'; +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 {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'; + +/** + * Step in the dialog flow for disabling a MFA method. + */ +type DisableAuthMethodStep = + | 'confirm-disable' + | 'verify-password' + | 'disable-method'; + +interface MFAConfigOptions { + appModel: AppModel; + user: FullUser; +} + +/** + * Shows information about multi-factor authentication preferences for the logged-in user + * and buttons for enabling/disabling them. + * + * Currently supports software tokens only. + */ +export class MFAConfig extends Disposable { + private _appModel: AppModel; + private _user: FullUser; + + constructor( + private _mfaPrefs: Observable, + options: MFAConfigOptions + ) { + super(); + this._appModel = options.appModel; + this._user = options.user; + } + + public buildDom() { + return this._buildButtons(); + } + + 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 ? + 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'), + ), + ), + testId('container'), + ); + }); + } + + /** + * 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. + */ + private _showAddAuthMethodModal(method?: AuthMethod): void { + return modal((ctl, owner) => { + const selectedAuthMethod = Observable.create(owner, method ?? null); + const currentStep = Observable.create(owner, 'verify-password'); + + return [ + dom.domComputed((use) => { + const step = use(currentStep); + switch (step) { + case 'verify-password': { + return [ + this._buildSecurityVerificationForm({onSuccess: async () => { + currentStep.set('choose-auth-method'); + }}), + cssTextBtn('← Back', dom.on('click', () => { ctl.close(); })), + ]; + } + case 'choose-auth-method': { + return [ + cssModalTitle('Two-factor authentication'), + cssModalBody( + cssText( + "Once you enable two step verification, 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'), + 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', () => { + selectedAuthMethod.set('SOFTWARE_TOKEN'); + currentStep.set('configure-auth-app'); + }), + ), + ) + ), + ]; + } + case 'configure-auth-app': { + return [ + this._buildConfigureAuthAppForm(ctl, {onSuccess: async () => { + ctl.close(); + reportSuccess('Two-factor authentication enabled'); + this._mfaPrefs.set({...this._mfaPrefs.get()!, isSoftwareTokenMfaEnabled: true}); + }}), + cssTextBtn('← Back to methods', dom.on('click', () => { currentStep.set('choose-auth-method'); })), + ]; + } + } + }), + cssModal.cls(''), + ]; + }); + } + + /** + * 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. + */ + private _showDisableAuthMethodModal(method: AuthMethod): 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 authenticator app?'), + cssModalBody( + cssText( + "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())), + ), + ), + ]; + } + case 'verify-password': { + return [ + this._buildSecurityVerificationForm({onSuccess: () => currentStep.set('disable-method')}), + cssTextBtn('← Back', dom.on('click', () => { currentStep.set('confirm-disable'); })), + ]; + } + 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()); + } + } + }), + cssModal.cls(''), + ]; + }); + } + + /** + * 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({onSuccess}: {onSuccess: () => void}) { + const securityStep = Observable.create<'password' | 'verification-code'>(null, 'password'); + const pending = Observable.create(null, false); + const session = Observable.create(null, ''); + + return [ + dom.autoDispose(securityStep), + dom.autoDispose(session), + dom.autoDispose(pending), + dom.domComputed(securityStep, (step) => { + switch (step) { + 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)); + + return dom.frag( + dom.autoDispose(password), + dom.autoDispose(error), + dom.autoDispose(errorListener), + cssModalTitle('Confirm your password'), + cssModalBody( + dom('form', + {method: 'post', action: verifyPasswordUrl}, + handleSubmit(pending, + (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); + } else { + reportError(err as Error|string); + } + }, + ), + 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), + ), + ), + ), + ), + ); + } + 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)); + + return dom.frag( + dom.autoDispose(authCode), + dom.autoDispose(error), + dom.autoDispose(errorListener), + cssModalTitle('Almost there!'), + cssModalBody( + dom('form', + {method: 'post', action: verifyAuthCodeUrl}, + handleSubmit(pending, + () => onSuccess(), + (err) => { + if (isUserError(err)) { + error.set(err.details?.userError ?? err.message); + } else { + reportError(err as Error|string); + } + }, + ), + 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), + ), + ), + ), + ), + ); + } + } + }), + ]; + } + + /** + * 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}) { + 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)); + + this._getSoftwareTokenQRCode() + .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.domComputed(qrCode, code => { + if (code === null) { return cssLoadingSpinner(loadingSpinner()); } + + return [ + cssModalTitle('Configure authenticator app'), + 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}, + handleSubmit(pending, + () => 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) ?? '')), + cssModalButtons( + bigPrimaryButton('Verify', + dom.boolAttr('disabled', use => use(pending) || use(verificationCode).trim().length !== 6), + ), + bigBasicButton('Cancel', dom.on('click', () => ctl.close())), + ), + ), + ), + ), + ]; + }), + ]; + } + + private async _registerSoftwareToken() { + return await this._appModel.api.registerSoftwareToken(); + } + + private async _unregisterSoftwareToken() { + return await this._appModel.api.unregisterSoftwareToken(); + } + + /** + * 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; + } +} + +/** + * 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. + */ +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); + } +} + +/** + * 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 cssContainer = styled('div', ` + 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 cssConfirmText = styled(cssText, ` + margin-bottom: 32px; +`); + +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; + 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 cssAuthMethods = styled('div', ` + display: flex; + flex-direction: column; + margin-top: 16px; + gap: 8px; +`); + +const cssAuthMethod = styled('div', ` + border: 1px solid ${colors.mediumGreyOpaque}; + cursor: pointer; + + &:hover { + border: 1px solid ${colors.slate}; + } +`); + +const cssAuthMethodTitle = styled(cssIconAndText, ` + font-size: ${vars.mediumFontSize}; + font-weight: bold; + color: ${colors.lightGreen}; + margin: 16px; +`); + +const cssAuthMethodDesc = styled('div', ` + color: #8a8a8a; + padding-left: 28px; + margin: 16px; +`); + +const cssInput = styled(input, ` + margin-top: 16px; + 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 cssModal = styled('div', ` + width: 600px; +`); + +const cssLoadingSpinner = styled('div', ` + height: 200px; + display: flex; + justify-content: center; + align-items: center; +`); + +const cssBoldSubHeading = styled('div', ` + font-weight: bold; +`); + +const cssConfigureAuthAppSubHeading = styled(cssBoldSubHeading, ` + margin-bottom: 16px; +`); + +const cssConfigureAuthAppStep = styled(cssText, ` + margin-bottom: 16px; +`); + +const cssQRCode = styled('img', ` + width: 140px; + height: 140px; +`); + +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; +`); diff --git a/app/client/ui/ProfileDialog.ts b/app/client/ui/ProfileDialog.ts deleted file mode 100644 index cd4f59e5..00000000 --- a/app/client/ui/ProfileDialog.ts +++ /dev/null @@ -1,174 +0,0 @@ -import {AppModel, reportError} from 'app/client/models/AppModel'; -import {getResetPwdUrl} from 'app/client/models/gristUrlState'; -import {ApiKey} from 'app/client/ui/ApiKey'; -import * as billingPageCss from 'app/client/ui/BillingPageCss'; -import {transientInput} from 'app/client/ui/transientInput'; -import {buildNameWarningsDom, checkName} from 'app/client/ui/WelcomePage'; -import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; -import {testId} from 'app/client/ui2018/cssVars'; -import {cssModalBody, cssModalButtons, cssModalTitle, cssModalWidth} from 'app/client/ui2018/modals'; -import {IModalControl, modal} from 'app/client/ui2018/modals'; -import {FullUser} from 'app/common/LoginSessionAPI'; -import {Computed, dom, domComputed, DomElementArg, MultiHolder, Observable, styled} from 'grainjs'; - - -/** - * Renders a modal with profile settings. - */ -export function showProfileModal(appModel: AppModel): void { - return modal((ctl, owner) => showProfileContent(ctl, owner, appModel)); -} - -function showProfileContent(ctl: IModalControl, owner: MultiHolder, appModel: AppModel): DomElementArg { - const apiKey = Observable.create(owner, ''); - const userObs = Observable.create(owner, null); - const isEditingName = Observable.create(owner, false); - const nameEdit = Observable.create(owner, ''); - const isNameValid = Computed.create(owner, nameEdit, (use, val) => checkName(val)); - - let needsReload = false; - - async function fetchApiKey() { apiKey.set(await appModel.api.fetchApiKey()); } - async function createApiKey() { apiKey.set(await appModel.api.createApiKey()); } - async function deleteApiKey() { await appModel.api.deleteApiKey(); apiKey.set(''); } - async function fetchUserProfile() { userObs.set(await appModel.api.getUserProfile()); } - - async function fetchAll() { - await Promise.all([ - fetchApiKey(), - fetchUserProfile() - ]); - } - - fetchAll().catch(reportError); - - async function updateUserName(val: string) { - const user = userObs.get(); - if (user && val && val !== user.name) { - await appModel.api.updateUserName(val); - await fetchAll(); - needsReload = true; - } - } - - owner.onDispose(() => { - if (needsReload) { - appModel.topAppModel.initialize(); - } - }); - - return [ - cssModalTitle('User Profile'), - cssModalWidth('fixed-wide'), - domComputed(userObs, (user) => user && ( - cssModalBody( - cssDataRow(cssSubHeader('Email'), user.email), - cssDataRow( - cssSubHeader('Name'), - domComputed(isEditingName, (isediting) => ( - isediting ? [ - transientInput( - { - initialValue: user.name, - save: ctl.doWork(async (val) => isNameValid.get() && updateUserName(val)), - close: () => { isEditingName.set(false); nameEdit.set(''); }, - }, - dom.on('input', (ev, el) => nameEdit.set(el.value)), - ), - cssTextBtn( - cssBillingIcon('Settings'), 'Save', - // no need to save on 'click', the transient input already does it on close - ), - ] : [ - user.name, - cssTextBtn( - cssBillingIcon('Settings'), 'Edit', - dom.on('click', () => isEditingName.set(true)) - ), - ] - )), - testId('username') - ), - // show warning for invalid name but not for the empty string - dom.maybe(use => use(nameEdit) && !use(isNameValid), cssWarnings), - 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 - cssBillingIcon('Settings'), 'Reset', - dom.on('click', () => confirmPwdResetModal(user.email)) - ) : null, - testId('login-method'), - ), - cssDataRow(cssSubHeader('API Key'), cssContent( - dom.create(ApiKey, { - apiKey, - onCreate: ctl.doWork(createApiKey), - onDelete: ctl.doWork(deleteApiKey), - anonymous: false, - }) - )), - ) - )), - cssModalButtons( - bigPrimaryButton('Close', - dom.boolAttr('disabled', ctl.workInProgress), - dom.on('click', () => ctl.close()), - testId('modal-confirm') - ), - ), - ]; -} - -// We cannot use the confirmModal here because of the button link that we need here. -function confirmPwdResetModal(userEmail: string) { - return modal((ctl, owner) => { - return [ - cssModalTitle('Reset Password'), - cssModalBody(`Click continue to open the password reset form. Submit it for your email address: ${userEmail}`), - cssModalButtons( - bigPrimaryButtonLink( - { href: getResetPwdUrl(), target: '_blank' }, - 'Continue', - dom.on('click', () => ctl.close()) - ), - bigBasicButton( - 'Cancel', - dom.on('click', () => ctl.close()) - ), - ), - ]; - }); -} - - -const cssDataRow = styled('div', ` - margin: 8px 0px; - display: flex; - align-items: baseline; -`); - -const cssSubHeader = styled('div', ` - width: 110px; - padding: 8px 0; - display: inline-block; - vertical-align: top; - font-weight: bold; -`); - -const cssContent = styled('div', ` - flex: 1 1 300px; -`); - -const cssTextBtn = styled(billingPageCss.billingTextBtn, ` - width: 90px; - margin-left: auto; -`); - -const cssBillingIcon = billingPageCss.billingIcon; - -const cssWarnings = styled(buildNameWarningsDom, ` - margin: -8px 0 0 110px; -`); diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 5b430bb8..cc29296f 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -32,6 +32,8 @@ export type IconName = "ChartArea" | "FieldToggle" | "GristLogo" | "ThumbPreview" | + "BarcodeQR" | + "BarcodeQR2" | "CenterAlign" | "Code" | "Collapse" | @@ -99,9 +101,9 @@ export type IconName = "ChartArea" | export const IconList: IconName[] = ["ChartArea", "ChartBar", + "ChartDonut", "ChartKaplan", "ChartLine", - "ChartDonut", "ChartPie", "TypeCard", "TypeCardList", @@ -131,6 +133,8 @@ export const IconList: IconName[] = ["ChartArea", "FieldToggle", "GristLogo", "ThumbPreview", + "BarcodeQR", + "BarcodeQR2", "CenterAlign", "Code", "Collapse", diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index a72be186..565c028b 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -266,6 +266,22 @@ export interface DocStateComparisonDetails { rightChanges: ActionSummary; } +/** + * User multi-factor authentication preferences, as fetched from Cognito. + */ +export interface UserMFAPreferences { + isSmsMfaEnabled: boolean; + isSoftwareTokenMfaEnabled: boolean; +} + +/** + * Cognito response to initiating software token MFA registration. + */ +export interface SoftwareTokenRegistrationInfo { + session: string; + secretCode: string; +} + export {UserProfile} from 'app/common/LoginSessionAPI'; export interface UserAPI { @@ -304,6 +320,7 @@ export interface UserAPI { unpinDoc(docId: string): Promise; moveDoc(docId: string, workspaceId: number): Promise; getUserProfile(): Promise; + getUserMfaPreferences(): Promise; updateUserName(name: string): Promise; getWorker(key: string): Promise; getWorkerAPI(key: string): Promise; @@ -320,6 +337,8 @@ export interface UserAPI { onUploadProgress?: (ev: ProgressEvent) => void, }): Promise; deleteUser(userId: number, name: string): Promise; + registerSoftwareToken(): Promise; + unregisterSoftwareToken(): 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; @@ -582,6 +601,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { return this.requestJson(`${this._url}/api/profile/user`); } + public async getUserMfaPreferences(): Promise { + return this.requestJson(`${this._url}/api/profile/mfa_preferences`); + } + public async updateUserName(name: string): Promise { await this.request(`${this._url}/api/profile/user/name`, { method: 'POST', @@ -668,6 +691,14 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { body: JSON.stringify({name})}); } + public async registerSoftwareToken(): Promise { + return this.requestJson(`${this._url}/api/auth/register_totp`, {method: 'POST'}); + } + + public async unregisterSoftwareToken(): Promise { + await this.request(`${this._url}/api/auth/unregister_totp`, {method: 'POST'}); + } + 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/app/common/gristUrls.ts b/app/common/gristUrls.ts index 22b13463..95490154 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -20,6 +20,9 @@ export type IHomePage = typeof HomePage.type; export const WelcomePage = StringUnion('user', 'info', 'teams', 'signup', 'verify', 'select-account'); export type WelcomePage = typeof WelcomePage.type; +export const AccountPage = StringUnion('profile'); +export type AccountPage = typeof AccountPage.type; + // Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience. export const InterfaceStyle = StringUnion('light', 'full'); export type InterfaceStyle = typeof InterfaceStyle.type; @@ -62,6 +65,7 @@ export interface IGristUrlState { fork?: UrlIdParts; docPage?: IDocPage; newui?: boolean; + account?: AccountPage; billing?: BillingPage; welcome?: WelcomePage; welcomeTour?: boolean; @@ -183,6 +187,8 @@ export function encodeUrl(gristConfig: Partial, parts.push(`p/${state.homePage}`); } + if (state.account) { parts.push('account'); } + if (state.billing) { parts.push(state.billing === 'billing' ? 'billing' : `billing/${state.billing}`); } @@ -271,6 +277,7 @@ export function decodeUrl(gristConfig: Partial, location: Locat } if (map.has('m')) { state.mode = OpenDocMode.parse(map.get('m')); } if (sp.has('newui')) { state.newui = useNewUI(sp.get('newui') ? sp.get('newui') === '1' : undefined); } + if (map.has('account')) { state.account = AccountPage.parse('account') || 'profile'; } if (map.has('billing')) { state.billing = BillingSubPage.parse(map.get('billing')) || 'billing'; } if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')) || 'user'; } if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; } diff --git a/app/server/lib/BrowserSession.ts b/app/server/lib/BrowserSession.ts index 68df1bb6..242e7d80 100644 --- a/app/server/lib/BrowserSession.ts +++ b/app/server/lib/BrowserSession.ts @@ -13,16 +13,22 @@ export interface SessionUserObj { // The user profile object. profile?: UserProfile; + /** + * Unix time in seconds of the last successful login. Includes security + * verification prompts, such as those for configuring MFA preferences. + */ + lastLoginTimestamp?: number; + // [UNUSED] Authentication provider string indicating the login method used. authProvider?: string; // [UNUSED] Login ID token used to access AWS services. idToken?: string; - // [UNUSED] Login access token used to access other AWS services. + // Login access token used to access other AWS services. accessToken?: string; - // [UNUSED] Login refresh token used to retrieve new ID and access tokens. + // Login refresh token used to retrieve new ID and access tokens. refreshToken?: string; // State for SAML-mediated logins. @@ -166,14 +172,16 @@ export class ScopedSession { // This is mainly used to know which emails are logged in in this session; fields like name and // picture URL come from the database instead. public async updateUserProfile(req: Request, profile: UserProfile|null): Promise { - if (profile) { - await this.operateOnScopedSession(req, async user => { - user.profile = profile; - return user; - }); - } else { - await this.clearScopedSession(req); - } + profile ? await this.updateUser(req, {profile}) : await this.clearScopedSession(req); + } + + /** + * Updates the properties of the current session user. + * + * @param {Partial} newProps New property values to set. + */ + public async updateUser(req: Request, newProps: Partial): Promise { + await this.operateOnScopedSession(req, async user => ({...user, ...newProps})); } /** diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index a9f08e76..15b2644a 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -30,7 +30,7 @@ import {DocManager} from 'app/server/lib/DocManager'; import {DocStorageManager} from 'app/server/lib/DocStorageManager'; import {DocWorker} from 'app/server/lib/DocWorker'; import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; -import {expressWrap, jsonErrorHandler} from 'app/server/lib/expressWrap'; +import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/lib/expressWrap'; import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg'; import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth"; import {GristLoginMiddleware, GristServer, RequestWithGrist} from 'app/server/lib/GristServer'; @@ -521,6 +521,7 @@ export class FlexServer implements GristServer { }); // Add a final error handler for /api endpoints that reports errors as JSON. + this.app.use('/api/auth', secureJsonErrorHandler); this.app.use('/api', jsonErrorHandler); } @@ -1003,6 +1004,18 @@ export class FlexServer implements GristServer { this._disableS3 = true; } + public addAccountPage() { + const middleware = [ + this._redirectToHostMiddleware, + this._userIdMiddleware, + this._redirectToLoginWithoutExceptionsMiddleware + ]; + + this.app.get('/account', ...middleware, expressWrap(async (req, resp) => { + return this._sendAppPage(req, resp, {path: 'account.html', status: 200, config: {}}); + })); + } + public addBillingPages() { const middleware = [ this._redirectToHostMiddleware, diff --git a/app/server/lib/expressWrap.ts b/app/server/lib/expressWrap.ts index 1bbbd813..79098e3c 100644 --- a/app/server/lib/expressWrap.ts +++ b/app/server/lib/expressWrap.ts @@ -15,25 +15,52 @@ export function expressWrap(callback: express.RequestHandler): express.RequestHa }; } +interface JsonErrorHandlerOptions { + shouldLogBody?: boolean; + shouldLogParams?: boolean; +} + +/** + * Returns a custom error-handling middleware that responds to errors in json. + * + * Currently allows for toggling of logging request bodies and params. + */ +const buildJsonErrorHandler = (options: JsonErrorHandlerOptions = {}): express.ErrorRequestHandler => { + return (err, req, res, _next) => { + const mreq = req as RequestWithLogin; + log.warn( + "Error during api call to %s: (%s) user %d%s%s", + req.path, err.message, mreq.userId, + options.shouldLogParams !== false ? ` params ${JSON.stringify(req.params)}` : '', + options.shouldLogBody !== false ? ` body ${JSON.stringify(req.body)}` : '', + ); + let details = err.details && {...err.details}; + const status = details?.status || err.status || 500; + if (details) { + // Remove some details exposed for websocket API only. + delete details.accessMode; + delete details.status; // TODO: reconcile err.status and details.status, no need for both. + if (Object.keys(details).length === 0) { details = undefined; } + } + res.status(status).json({error: err.message || 'internal error', details}); + }; +}; + /** * Error-handling middleware that responds to errors in json. The status code is taken from * error.status property (for which ApiError is convenient), and defaults to 500. */ -export const jsonErrorHandler: express.ErrorRequestHandler = (err, req, res, next) => { - const mreq = req as RequestWithLogin; - log.warn("Error during api call to %s: (%s) user %d params %s body %s", req.path, err.message, - mreq.userId, - JSON.stringify(req.params), JSON.stringify(req.body)); - let details = err.details && {...err.details}; - const status = details?.status || err.status || 500; - if (details) { - // Remove some details exposed for websocket API only. - delete details.accessMode; - delete details.status; // TODO: reconcile err.status and details.status, no need for both. - if (Object.keys(details).length === 0) { details = undefined; } - } - res.status(status).json({error: err.message || 'internal error', details}); -}; +export const jsonErrorHandler: express.ErrorRequestHandler = buildJsonErrorHandler(); + +/** + * Variant of `jsonErrorHandler` that skips logging request bodies and params. + * + * Should be used for sensitive routes, such as those under '/api/auth/'. + */ +export const secureJsonErrorHandler: express.ErrorRequestHandler = buildJsonErrorHandler({ + shouldLogBody: false, + shouldLogParams: false, +}); /** * Middleware that responds with a 404 status and a json error object. diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index 84e89fc6..35027940 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -118,6 +118,7 @@ export async function main(port: number, serverTypes: ServerType[], await server.addHousekeeper(); } await server.addLoginRoutes(); + server.addAccountPage(); server.addBillingPages(); server.addWelcomePaths(); server.addLogEndpoint(); diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js index 187743bf..41786c3c 100644 --- a/buildtools/webpack.config.js +++ b/buildtools/webpack.config.js @@ -7,6 +7,7 @@ module.exports = { entry: { main: "app/client/app.js", errorPages: "app/client/errorMain.js", + account: "app/client/accountMain.js", }, output: { filename: "[name].bundle.js", diff --git a/package.json b/package.json index 6e15a9e3..ef7ef5d9 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/numeral": "0.0.25", "@types/pidusage": "2.0.1", "@types/plotly.js": "1.44.15", + "@types/qrcode": "1.4.2", "@types/redlock": "3.0.2", "@types/saml2-js": "2.0.1", "@types/selenium-webdriver": "4.0.0", @@ -119,6 +120,7 @@ "plotly.js-basic-dist": "1.51.1", "popper-max-size-modifier": "0.2.0", "popweasel": "0.1.18", + "qrcode": "1.5.0", "randomcolor": "0.5.3", "redis": "2.8.0", "redlock": "3.1.2", diff --git a/static/account.html b/static/account.html new file mode 100644 index 00000000..bec529b3 --- /dev/null +++ b/static/account.html @@ -0,0 +1,14 @@ + + + + + + + + Grist + + + + + + diff --git a/static/icons/icons.css b/static/icons/icons.css index d1d0baa5..9cc60e64 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -33,6 +33,8 @@ --icon-FieldToggle: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNjQ1ODI0NCwxMSBMMTAsMTEgQzExLjY1Njg1NDIsMTEgMTMsOS42NTY4NTQyNSAxMyw4IEMxMyw2LjM0MzE0NTc1IDExLjY1Njg1NDIsNSAxMCw1IEw4LjY0NTgyNDQsNSBDOS40NzYyNDUxMSw1LjczMjk0NDQ1IDEwLDYuODA1MzA3NDcgMTAsOCBDMTAsOS4xOTQ2OTI1MyA5LjQ3NjI0NTExLDEwLjI2NzA1NTUgOC42NDU4MjQ0LDExIFogTTYsNCBMMTAsNCBDMTIuMjA5MTM5LDQgMTQsNS43OTA4NjEgMTQsOCBDMTQsMTAuMjA5MTM5IDEyLjIwOTEzOSwxMiAxMCwxMiBMNiwxMiBDMy43OTA4NjEsMTIgMiwxMC4yMDkxMzkgMiw4IEMyLDUuNzkwODYxIDMuNzkwODYxLDQgNiw0IFogTTYsMTEgQzcuNjU2ODU0MjUsMTEgOSw5LjY1Njg1NDI1IDksOCBDOSw2LjM0MzE0NTc1IDcuNjU2ODU0MjUsNSA2LDUgQzQuMzQzMTQ1NzUsNSAzLDYuMzQzMTQ1NzUgMyw4IEMzLDkuNjU2ODU0MjUgNC4zNDMxNDU3NSwxMSA2LDExIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-GristLogo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDEiIGhlaWdodD0iMzgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI4LjEzNCAxLjE2MikiIGZpbGw9IiMyQ0IwQUYiLz48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEuMTYyKSIgZmlsbD0iIzJDQjBBRiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDEzLjc0KSIgZmlsbD0iI0Y5QUU0MSIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDI2LjMxOSkiIGZpbGw9IiNGOUFFNDEiLz48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEzLjc0KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDEzLjc0KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjUuNTcgMjYuMzE5KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDI2LjMxOSkiIGZpbGw9IiNEMkQyRDIiLz48L2c+PC9zdmc+'); --icon-ThumbPreview: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDYiIGhlaWdodD0iNDYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxwYXRoIGQ9Ik00LjA1NzYxNzE5LDYuMzY4MTY0MDYgTDQuMDU3NjE3MTksNS4xNjIxMDkzOCBMNy4xNzI4NTE1Niw1LjE2MjEwOTM4IEw3LjE3Mjg1MTU2LDguMDEzNjcxODggQzYuODcwMTE1NjcsOC4zMDY2NDIwOSA2LjQzMTQ4MDc0LDguNTY0NjE0NzcgNS44NTY5MzM1OSw4Ljc4NzU5NzY2IEM1LjI4MjM4NjQ1LDkuMDEwNTgwNTQgNC43MDA1MjM3OCw5LjEyMjA3MDMxIDQuMTExMzI4MTIsOS4xMjIwNzAzMSBDMy4zNjI2MjY0Niw5LjEyMjA3MDMxIDIuNzA5OTYzNzIsOC45NjUwMDgwOCAyLjE1MzMyMDMxLDguNjUwODc4OTEgQzEuNTk2Njc2OSw4LjMzNjc0OTczIDEuMTc4Mzg2ODIsNy44ODc1MzU0NyAwLjg5ODQzNzUsNy4zMDMyMjI2NiBDMC42MTg0ODgxODQsNi43MTg5MDk4NCAwLjQ3ODUxNTYyNSw2LjA4MzMzNjc3IDAuNDc4NTE1NjI1LDUuMzk2NDg0MzggQzAuNDc4NTE1NjI1LDQuNjUxMDM3OTQgMC42MzQ3NjQwNjIsMy45ODg2MDk2NyAwLjk0NzI2NTYyNSwzLjQwOTE3OTY5IEMxLjI1OTc2NzE5LDIuODI5NzQ5NzEgMS43MTcxMTkzOCwyLjM4NTQxODIxIDIuMzE5MzM1OTQsMi4wNzYxNzE4OCBDMi43NzgzMjI2MSwxLjgzODU0MDQ4IDMuMzQ5NjA1OTYsMS43MTk3MjY1NiA0LjAzMzIwMzEyLDEuNzE5NzI2NTYgQzQuOTIxODc5NDQsMS43MTk3MjY1NiA1LjYxNjA0NTY4LDEuOTA2MDg1MzggNi4xMTU3MjI2NiwyLjI3ODgwODU5IEM2LjYxNTM5OTYzLDIuNjUxNTMxODEgNi45MzY4NDgyNCwzLjE2NjY2MzM4IDcuMDgwMDc4MTIsMy44MjQyMTg3NSBMNS42NDQ1MzEyNSw0LjA5Mjc3MzQ0IEM1LjU0MzYxOTI5LDMuNzQxMjA5MTggNS4zNTQwMDUzLDMuNDYzNzA1NDQgNS4wNzU2ODM1OSwzLjI2MDI1MzkxIEM0Ljc5NzM2MTg5LDMuMDU2ODAyMzcgNC40NDk4NzE4NywyLjk1NTA3ODEyIDQuMDMzMjAzMTIsMi45NTUwNzgxMiBDMy40MDE2ODk1NSwyLjk1NTA3ODEyIDIuODk5NTc4NjksMy4xNTUyNzE0NCAyLjUyNjg1NTQ3LDMuNTU1NjY0MDYgQzIuMTU0MTMyMjUsMy45NTYwNTY2OSAxLjk2Nzc3MzQ0LDQuNTUwMTI2MjcgMS45Njc3NzM0NCw1LjMzNzg5MDYyIEMxLjk2Nzc3MzQ0LDYuMTg3NTA0MjUgMi4xNTY1NzM2Myw2LjgyNDcwNDkxIDIuNTM0MTc5NjksNy4yNDk1MTE3MiBDMi45MTE3ODU3NCw3LjY3NDMxODUzIDMuNDA2NTcyNDYsNy44ODY3MTg3NSA0LjAxODU1NDY5LDcuODg2NzE4NzUgQzQuMzIxMjkwNTgsNy44ODY3MTg3NSA0LjYyNDgzNTcyLDcuODI3MzExNzkgNC45MjkxOTkyMiw3LjcwODQ5NjA5IEM1LjIzMzU2MjcyLDcuNTg5NjgwNCA1LjQ5NDc5MDU4LDcuNDQ1NjM4ODcgNS43MTI4OTA2Miw3LjI3NjM2NzE5IEw1LjcxMjg5MDYyLDYuMzY4MTY0MDYgTDQuMDU3NjE3MTksNi4zNjgxNjQwNiBaIE05LjIxMDc0MjE4LDkgTDkuMjEwNzQyMTgsMS44NDE3OTY4OCBMMTIuMjUyNzM0NCwxLjg0MTc5Njg4IEMxMy4wMTc3MTIxLDEuODQxNzk2ODggMTMuNTczNTMzNCwxLjkwNjA4NjYgMTMuOTIwMjE0OCwyLjAzNDY2Nzk3IEMxNC4yNjY4OTYzLDIuMTYzMjQ5MzQgMTQuNTQ0NCwyLjM5MTkyNTQ0IDE0Ljc1MjczNDQsMi43MjA3MDMxMiBDMTQuOTYxMDY4NywzLjA0OTQ4MDgxIDE1LjA2NTIzNDQsMy40MjU0NTM2MSAxNS4wNjUyMzQ0LDMuODQ4NjMyODEgQzE1LjA2NTIzNDQsNC4zODU3NDQ4NyAxNC45MDczNTgzLDQuODI5MjYyNTcgMTQuNTkxNjAxNiw1LjE3OTE5OTIyIEMxNC4yNzU4NDQ4LDUuNTI5MTM1ODYgMTMuODAzODQ0Myw1Ljc0OTY3NDAyIDEzLjE3NTU4NTksNS44NDA4MjAzMSBDMTMuNDg4MDg3NSw2LjAyMzExMjg5IDEzLjc0NjA2MDIsNi4yMjMzMDYyIDEzLjk0OTUxMTcsNi40NDE0MDYyNSBDMTQuMTUyOTYzMiw2LjY1OTUwNjMgMTQuNDI3MjExOCw3LjA0Njg3MjIyIDE0Ljc3MjI2NTYsNy42MDM1MTU2MiBMMTUuNjQ2Mjg5MSw5IEwxMy45MTc3NzM0LDkgTDEyLjg3Mjg1MTYsNy40NDIzODI4MSBDMTIuNTAxNzU1OSw2Ljg4NTczOTQgMTIuMjQ3ODUyMiw2LjUzNDk5NDIxIDEyLjExMTEzMjgsNi4zOTAxMzY3MiBDMTEuOTc0NDEzNCw2LjI0NTI3OTIyIDExLjgyOTU1OCw2LjE0NTk5NjM2IDExLjY3NjU2MjUsNi4wOTIyODUxNiBDMTEuNTIzNTY2OSw2LjAzODU3Mzk1IDExLjI4MTA1NjMsNi4wMTE3MTg3NSAxMC45NDkwMjM0LDYuMDExNzE4NzUgTDEwLjY1NjA1NDcsNi4wMTE3MTg3NSBMMTAuNjU2MDU0Nyw5IEw5LjIxMDc0MjE4LDkgWiBNMTAuNjU2MDU0Nyw0Ljg2OTE0MDYyIEwxMS43MjUzOTA2LDQuODY5MTQwNjIgQzEyLjQxODc1MzUsNC44NjkxNDA2MiAxMi44NTE2OTE4LDQuODM5ODQ0MDQgMTMuMDI0MjE4Nyw0Ljc4MTI1IEMxMy4xOTY3NDU2LDQuNzIyNjU1OTYgMTMuMzMxODM1NCw0LjYyMTc0NTUxIDEzLjQyOTQ5MjIsNC40Nzg1MTU2MiBDMTMuNTI3MTQ4OSw0LjMzNTI4NTc0IDEzLjU3NTk3NjYsNC4xNTYyNTEwNyAxMy41NzU5NzY2LDMuOTQxNDA2MjUgQzEzLjU3NTk3NjYsMy43MDA1MTk2MyAxMy41MTE2ODY4LDMuNTA2MDIyODggMTMuMzgzMTA1NSwzLjM1NzkxMDE2IEMxMy4yNTQ1MjQxLDMuMjA5Nzk3NDQgMTMuMDczMDQ4LDMuMTE2MjExMTMgMTIuODM4NjcxOSwzLjA3NzE0ODQ0IEMxMi43MjE0ODM4LDMuMDYwODcyMzEgMTIuMzY5OTI0OCwzLjA1MjczNDM4IDExLjc4Mzk4NDQsMy4wNTI3MzQzOCBMMTAuNjU2MDU0NywzLjA1MjczNDM4IEwxMC42NTYwNTQ3LDQuODY5MTQwNjIgWiBNMTcuMDgzNTkzNyw5IEwxNy4wODM1OTM3LDEuODQxNzk2ODggTDE4LjUyODkwNjIsMS44NDE3OTY4OCBMMTguNTI4OTA2Miw5IEwxNy4wODM1OTM3LDkgWiBNMjAuMjM5NjQ4NCw2LjY3MDg5ODQ0IEwyMS42NDU4OTg0LDYuNTM0MTc5NjkgQzIxLjczMDUzNDIsNy4wMDYxODcyNiAyMS45MDIyNDQ4LDcuMzUyODYzNDggMjIuMTYxMDM1MSw3LjU3NDIxODc1IEMyMi40MTk4MjU1LDcuNzk1NTc0MDIgMjIuNzY4OTQzMSw3LjkwNjI1IDIzLjIwODM5ODQsNy45MDYyNSBDMjMuNjczODk1NSw3LjkwNjI1IDI0LjAyNDY0MDcsNy44MDc3ODA5MyAyNC4yNjA2NDQ1LDcuNjEwODM5ODQgQzI0LjQ5NjY0ODMsNy40MTM4OTg3NSAyNC42MTQ2NDg0LDcuMTgzNTk1MDcgMjQuNjE0NjQ4NCw2LjkxOTkyMTg4IEMyNC42MTQ2NDg0LDYuNzUwNjUwMiAyNC41NjUwMDcsNi42MDY2MDg2NyAyNC40NjU3MjI2LDYuNDg3NzkyOTcgQzI0LjM2NjQzODMsNi4zNjg5NzcyNyAyNC4xOTMxMDAyLDYuMjY1NjI1NDQgMjMuOTQ1NzAzMSw2LjE3NzczNDM4IEMyMy43NzY0MzE0LDYuMTE5MTQwMzMgMjMuMzkwNjkzMSw2LjAxNDk3NDcxIDIyLjc4ODQ3NjUsNS44NjUyMzQzOCBDMjIuMDEzNzMzMSw1LjY3MzE3NjEyIDIxLjQ3MDExODcsNS40MzcxNzU4OCAyMS4xNTc2MTcyLDUuMTU3MjI2NTYgQzIwLjcxODE2MTgsNC43NjMzNDQzOCAyMC40OTg0Mzc1LDQuMjgzMjA1OTYgMjAuNDk4NDM3NSwzLjcxNjc5Njg4IEMyMC40OTg0Mzc1LDMuMzUyMjExNzIgMjAuNjAxNzg5MywzLjAxMTIzMjA2IDIwLjgwODQ5NjEsMi42OTM4NDc2NiBDMjEuMDE1MjAyOCwyLjM3NjQ2MzI2IDIxLjMxMzA1MTQsMi4xMzQ3NjY0NiAyMS43MDIwNTA3LDEuOTY4NzUgQzIyLjA5MTA1MDEsMS44MDI3MzM1NCAyMi41NjA2MDkyLDEuNzE5NzI2NTYgMjMuMTEwNzQyMiwxLjcxOTcyNjU2IEMyNC4wMDkxODQxLDEuNzE5NzI2NTYgMjQuNjg1NDQ2OSwxLjkxNjY2NDcgMjUuMTM5NTUwNywyLjMxMDU0Njg4IEMyNS41OTM2NTQ2LDIuNzA0NDI5MDUgMjUuODMyMDk2MiwzLjIzMDEzOTk0IDI1Ljg1NDg4MjgsMy44ODc2OTUzMSBMMjQuNDA5NTcwMywzLjk1MTE3MTg4IEMyNC4zNDc3MjEsMy41ODMzMzE0OSAyNC4yMTUwNzI2LDMuMzE4ODQ4NDYgMjQuMDExNjIxMSwzLjE1NzcxNDg0IEMyMy44MDgxNjk1LDIuOTk2NTgxMjMgMjMuNTAyOTk2OCwyLjkxNjAxNTYyIDIzLjA5NjA5MzcsMi45MTYwMTU2MiBDMjIuNjc2MTY5NywyLjkxNjAxNTYyIDIyLjM0NzM5NywzLjAwMjI3Nzc4IDIyLjEwOTc2NTYsMy4xNzQ4MDQ2OSBDMjEuOTU2NzcsMy4yODU0ODIzMiAyMS44ODAyNzM0LDMuNDMzNTkyODIgMjEuODgwMjczNCwzLjYxOTE0MDYyIEMyMS44ODAyNzM0LDMuNzg4NDEyMyAyMS45NTE4ODczLDMuOTMzMjY3NjMgMjIuMDk1MTE3Miw0LjA1MzcxMDk0IEMyMi4yNzc0MDk3LDQuMjA2NzA2NDkgMjIuNzIwMTEzNiw0LjM2NjIxMDExIDIzLjQyMzI0MjIsNC41MzIyMjY1NiBDMjQuMTI2MzcwNyw0LjY5ODI0MzAyIDI0LjY0NjM4NSw0Ljg2OTk1MzU0IDI0Ljk4MzMwMDcsNS4wNDczNjMyOCBDMjUuMzIwMjE2NSw1LjIyNDc3MzAyIDI1LjU4Mzg4NTcsNS40NjcyODM2MiAyNS43NzQzMTY0LDUuNzc0OTAyMzQgQzI1Ljk2NDc0Nyw2LjA4MjUyMTA3IDI2LjA1OTk2MDksNi40NjI1NjI4NCAyNi4wNTk5NjA5LDYuOTE1MDM5MDYgQzI2LjA1OTk2MDksNy4zMjUxOTczNiAyNS45NDYwMjk3LDcuNzA5MzA4MTEgMjUuNzE4MTY0LDguMDY3MzgyODEgQzI1LjQ5MDI5ODMsOC40MjU0NTc1MiAyNS4xNjgwMzU5LDguNjkxNTY4MTQgMjQuNzUxMzY3Miw4Ljg2NTcyMjY2IEMyNC4zMzQ2OTg0LDkuMDM5ODc3MTcgMjMuODE1NDk3OSw5LjEyNjk1MzEyIDIzLjE5Mzc1LDkuMTI2OTUzMTIgQzIyLjI4ODc5NzUsOS4xMjY5NTMxMiAyMS41OTM4MTc1LDguOTE3ODA4MDggMjEuMTA4Nzg5LDguNDk5NTExNzIgQzIwLjYyMzc2MDYsOC4wODEyMTUzNiAyMC4zMzQwNDk5LDcuNDcxNjgzNjkgMjAuMjM5NjQ4NCw2LjY3MDg5ODQ0IFogTTI5LjU4NzEwOTMsOSBMMjkuNTg3MTA5MywzLjA1MjczNDM4IEwyNy40NjMwODU5LDMuMDUyNzM0MzggTDI3LjQ2MzA4NTksMS44NDE3OTY4OCBMMzMuMTUxNTYyNSwxLjg0MTc5Njg4IEwzMy4xNTE1NjI1LDMuMDUyNzM0MzggTDMxLjAzMjQyMTgsMy4wNTI3MzQzOCBMMzEuMDMyNDIxOCw5IEwyOS41ODcxMDkzLDkgWiIgaWQ9ImEiLz48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBvcGFjaXR5PSIuOSI+PHBhdGggZD0iTTAgMEg0OFY0OEgweiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZmlsbD0iIzBFNTEyQiIgZD0iTTQzIDI2TDMgMjYgMCAyMiA0IDE4IDQyIDE4IDQ2IDIyeiIvPjxwYXRoIGQ9Ik00MCw0NiBMNiw0NiBDNC44OTUsNDYgNCw0NS4xMDUgNCw0NCBMNCwyIEM0LDAuODk1IDQuODk1LDAgNiwwIEwzMCwwIEw0MiwxMiBMNDIsNDQgQzQyLDQ1LjEwNSA0MS4xMDUsNDYgNDAsNDYgWiIgZmlsbD0iI0U2RTZFNiIvPjxwYXRoIGQ9Ik0zMCwwIEwzMCwxMCBDMzAsMTEuMTA1IDMwLjg5NSwxMiAzMiwxMiBMNDIsMTIgTDMwLDAgWiIgZmlsbD0iI0IzQjNCMyIvPjxwYXRoIGQ9Ik00NCw0MCBMMiw0MCBDMC44OTUsNDAgMCwzOS4xMDUgMCwzOCBMMCwyMiBMNDYsMjIgTDQ2LDM4IEM0NiwzOS4xMDUgNDUuMTA1LDQwIDQ0LDQwIFoiIGZpbGw9IiMxNkIzNzgiLz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDI1KSI+PHVzZSBmaWxsPSIjMDAwIiB4bGluazpocmVmPSIjYSIvPjx1c2UgZmlsbD0iI0ZGRiIgeGxpbms6aHJlZj0iI2EiLz48L2c+PC9nPjwvc3ZnPg=='); + --icon-BarcodeQR: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNS41SC41VjYuNUg2LjVWLjV6TTYuNSA5LjVILjVWMTUuNUg2LjVWOS41ek0xNS41LjVIOS41VjYuNUgxNS41Vi41ek0xNS41IDE1LjVIOS41TTExLjUgOS41SDE1LjVWMTIuNSIgc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg=='); + --icon-BarcodeQR2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYgN0gxQy40IDcgMCA2LjYgMCA2VjFDMCAuNC40IDAgMSAwSDZDNi42IDAgNyAuNCA3IDFWNkM3IDYuNiA2LjYgNyA2IDd6TTIgNUg1VjJIMlY1ek02IDE2SDFDLjQgMTYgMCAxNS42IDAgMTVWMTBDMCA5LjQuNCA5IDEgOUg2QzYuNiA5IDcgOS40IDcgMTBWMTVDNyAxNS42IDYuNiAxNiA2IDE2ek0yIDE0SDVWMTFIMlYxNHpNMTUgN0gxMEM5LjQgNyA5IDYuNiA5IDZWMUM5IC40IDkuNCAwIDEwIDBIMTVDMTUuNiAwIDE2IC40IDE2IDFWNkMxNiA2LjYgMTUuNiA3IDE1IDd6TTExIDVIMTRWMkgxMVY1eiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik00IDNIM1Y0SDRWM3pNNCAxMkgzVjEzSDRWMTJ6TTE2IDE0SDlWMTZIMTZWMTR6TTE2IDEzSDE0VjExSDExVjlIMTVDMTUuNiA5IDE2IDkuNCAxNiAxMFYxM3oiIGZpbGw9IiMxNkIzNzgiLz48cGF0aCBkPSJNMTMgMTJIMTJWMTNIMTNWMTJ6TTEzIDNIMTJWNEgxM1YzeiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg=='); --icon-CenterAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUuNSw4LjUgQzUuMjIzODU3NjMsOC41IDUsOC4yNzYxNDIzNyA1LDggQzUsNy43MjM4NTc2MyA1LjIyMzg1NzYzLDcuNSA1LjUsNy41IEwxMC41LDcuNSBDMTAuNzc2MTQyNCw3LjUgMTEsNy43MjM4NTc2MyAxMSw4IEMxMSw4LjI3NjE0MjM3IDEwLjc3NjE0MjQsOC41IDEwLjUsOC41IEw1LjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE00LDEzIEMzLjcyMzg1NzYzLDEzIDMuNSwxMi43NzYxNDI0IDMuNSwxMi41IEMzLjUsMTIuMjIzODU3NiAzLjcyMzg1NzYzLDEyIDQsMTIgTDEyLDEyIEMxMi4yNzYxNDI0LDEyIDEyLjUsMTIuMjIzODU3NiAxMi41LDEyLjUgQzEyLjUsMTIuNzc2MTQyNCAxMi4yNzYxNDI0LDEzIDEyLDEzIEw0LDEzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Code: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEwLjAzMzMyNzIsMC4zMjA1MTA0NiBDMTAuMTMyNDU2NSwwLjA2Mjc3NDE4OCAxMC40MjE3NTMzLC0wLjA2NTgwMjEzODYgMTAuNjc5NDg5NSwwLjAzMzMyNzE5NjkgQzEwLjkzNzIyNTgsMC4xMzI0NTY1MzIgMTEuMDY1ODAyMSwwLjQyMTc1MzI2NyAxMC45NjY2NzI4LDAuNjc5NDg5NTQgTDUuOTY2NjcyOCwxMy42Nzk0ODk1IEM1Ljg2NzU0MzQ3LDEzLjkzNzIyNTggNS41NzgyNDY3MywxNC4wNjU4MDIxIDUuMzIwNTEwNDYsMTMuOTY2NjcyOCBDNS4wNjI3NzQxOSwxMy44Njc1NDM1IDQuOTM0MTk3ODYsMTMuNTc4MjQ2NyA1LjAzMzMyNzIsMTMuMzIwNTEwNSBMMTAuMDMzMzI3MiwwLjMyMDUxMDQ2IFogTTMuODUzNTUzMzksOS4xNDY0NDY2MSBDNC4wNDg4MTU1NCw5LjM0MTcwODc2IDQuMDQ4ODE1NTQsOS42NTgyOTEyNCAzLjg1MzU1MzM5LDkuODUzNTUzMzkgQzMuNjU4MjkxMjQsMTAuMDQ4ODE1NSAzLjM0MTcwODc2LDEwLjA0ODgxNTUgMy4xNDY0NDY2MSw5Ljg1MzU1MzM5IEwwLjE0NjQ0NjYwOSw2Ljg1MzU1MzM5IEMtMC4wNDg4MTU1MzY1LDYuNjU4MjkxMjQgLTAuMDQ4ODE1NTM2NSw2LjM0MTcwODc2IDAuMTQ2NDQ2NjA5LDYuMTQ2NDQ2NjEgTDMuMTQ2NDQ2NjEsMy4xNDY0NDY2MSBDMy4zNDE3MDg3NiwyLjk1MTE4NDQ2IDMuNjU4MjkxMjQsMi45NTExODQ0NiAzLjg1MzU1MzM5LDMuMTQ2NDQ2NjEgQzQuMDQ4ODE1NTQsMy4zNDE3MDg3NiA0LjA0ODgxNTU0LDMuNjU4MjkxMjQgMy44NTM1NTMzOSwzLjg1MzU1MzM5IEwxLjIwNzEwNjc4LDYuNSBMMy44NTM1NTMzOSw5LjE0NjQ0NjYxIFogTTEyLjE0NjQ0NjYsMy44NTM1NTMzOSBDMTEuOTUxMTg0NSwzLjY1ODI5MTI0IDExLjk1MTE4NDUsMy4zNDE3MDg3NiAxMi4xNDY0NDY2LDMuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsMi45NTExODQ0NiAxMi42NTgyOTEyLDIuOTUxMTg0NDYgMTIuODUzNTUzNCwzLjE0NjQ0NjYxIEwxNS44NTM1NTM0LDYuMTQ2NDQ2NjEgQzE2LjA0ODgxNTUsNi4zNDE3MDg3NiAxNi4wNDg4MTU1LDYuNjU4MjkxMjQgMTUuODUzNTUzNCw2Ljg1MzU1MzM5IEwxMi44NTM1NTM0LDkuODUzNTUzMzkgQzEyLjY1ODI5MTIsMTAuMDQ4ODE1NSAxMi4zNDE3MDg4LDEwLjA0ODgxNTUgMTIuMTQ2NDQ2Niw5Ljg1MzU1MzM5IEMxMS45NTExODQ1LDkuNjU4MjkxMjQgMTEuOTUxMTg0NSw5LjM0MTcwODc2IDEyLjE0NjQ0NjYsOS4xNDY0NDY2MSBMMTQuNzkyODkzMiw2LjUgTDEyLjE0NjQ0NjYsMy44NTM1NTMzOSBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-Collapse: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); diff --git a/static/ui-icons/UI/BarcodeQR.svg b/static/ui-icons/UI/BarcodeQR.svg new file mode 100644 index 00000000..b3df6ed6 --- /dev/null +++ b/static/ui-icons/UI/BarcodeQR.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/ui-icons/UI/BarcodeQR2.svg b/static/ui-icons/UI/BarcodeQR2.svg new file mode 100644 index 00000000..c5407d1c --- /dev/null +++ b/static/ui-icons/UI/BarcodeQR2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 80575cc3..c68cd4c6 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1716,10 +1716,10 @@ async function openAccountMenu() { await driver.sleep(250); // There's still some jitter (scroll-bar? other user accounts?) } -export async function openUserProfile() { +export async function openProfileSettingsPage() { await openAccountMenu(); - await driver.findContent('.grist-floating-menu li', 'Profile Settings').click(); - await driver.findWait('.test-login-method', 5000); + await driver.findContent('.grist-floating-menu a', 'Profile Settings').click(); + await driver.findWait('.test-account-page-login-method', 5000); } export async function openDocumentSettings() { diff --git a/yarn.lock b/yarn.lock index cc986e3a..bd0a3404 100644 --- a/yarn.lock +++ b/yarn.lock @@ -311,6 +311,13 @@ dependencies: "@types/d3" "^3" +"@types/qrcode@1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.4.2.tgz#7d7142d6fa9921f195db342ed08b539181546c74" + integrity sha512-7uNT9L4WQTNJejHTSTdaJhfBSCN73xtXaHFyBJ8TSwiLhe4PRuTue7Iph0s2nG9R/ifUaSnGhLUOZavlBEqDWQ== + dependencies: + "@types/node" "*" + "@types/qs@*": version "6.9.2" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.2.tgz#faab98ec4f96ee72c829b7ec0983af4f4d343113" @@ -1677,6 +1684,15 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -2270,6 +2286,11 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" +dijkstrajs@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" + integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg== + domain-browser@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" @@ -2401,6 +2422,11 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2807,6 +2833,14 @@ find-up@^1.0.0: path-exists "^2.0.0" pinkie-promise "^2.0.0" +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + findup-sync@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc" @@ -4259,6 +4293,13 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + lodash-node@~2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/lodash-node/-/lodash-node-2.4.1.tgz#ea82f7b100c733d1a42af76801e506105e2a80ec" @@ -5246,7 +5287,7 @@ p-is-promise@^2.0.0: resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== -p-limit@^2.0.0: +p-limit@^2.0.0, p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -5260,6 +5301,13 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-map@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" @@ -5379,6 +5427,11 @@ path-exists@^3.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -5498,6 +5551,11 @@ plotly.js-basic-dist@1.51.1: resolved "https://registry.yarnpkg.com/plotly.js-basic-dist/-/plotly.js-basic-dist-1.51.1.tgz#a81f9514ed50ff4660fa5f30caa333318650814f" integrity sha512-QnbSF6hzYYXjjfoImSaNDJM05mfCwrgaYc+k5oT4rSmwIeqhRiQE57YMd1BuTEKDpZOwE5OtzMxhfmxx0CbCMQ== +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + popper-max-size-modifier@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/popper-max-size-modifier/-/popper-max-size-modifier-0.2.0.tgz#1574744401296a488b4974909d130a85db94256f" @@ -5660,6 +5718,16 @@ q@^1.0.1: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qrcode@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b" + integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@6.5.2, qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -7523,6 +7591,15 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -7690,6 +7767,14 @@ yargs-parser@^11.1.1: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^20.2.2: version "20.2.7" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.7.tgz#61df85c113edfb5a7a4e36eb8aa60ef423cbc90a" @@ -7738,6 +7823,23 @@ yargs@^12.0.5: y18n "^3.2.1 || ^4.0.0" yargs-parser "^11.1.1" +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yargs@^16.0.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"