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"