mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
8f531ef622
commit
ba6ecc5e9e
17
app/client/accountMain.ts
Normal file
17
app/client/accountMain.ts
Normal file
@ -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),
|
||||
]));
|
@ -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);
|
||||
}
|
||||
|
||||
|
269
app/client/ui/AccountPage.ts
Normal file
269
app/client/ui/AccountPage.ts
Normal file
@ -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<string>(this, '');
|
||||
private _userObs = Observable.create<FullUser|null>(this, null);
|
||||
// private _userMfaPreferences = Observable.create<UserMFAPreferences|null>(this, null);
|
||||
private _isEditingName = Observable.create(this, false);
|
||||
private _nameEdit = Observable.create<string>(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;
|
||||
// `);
|
@ -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,
|
||||
|
||||
|
625
app/client/ui/MFAConfig.ts
Normal file
625
app/client/ui/MFAConfig.ts
Normal file
@ -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<UserMFAPreferences|null>,
|
||||
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<EnableAuthMethodStep>(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<DisableAuthMethodStep>(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<string|null> = 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<string|null> = 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<string|null> = Observable.create(null, null);
|
||||
const verificationCode = Observable.create(null, '');
|
||||
const pending = Observable.create(null, false);
|
||||
|
||||
const error: Observable<string|null> = 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<boolean>,
|
||||
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<boolean>,
|
||||
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;
|
||||
`);
|
@ -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<string>(owner, '');
|
||||
const userObs = Observable.create<FullUser|null>(owner, null);
|
||||
const isEditingName = Observable.create(owner, false);
|
||||
const nameEdit = Observable.create<string>(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;
|
||||
`);
|
@ -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",
|
||||
|
@ -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<void>;
|
||||
moveDoc(docId: string, workspaceId: number): Promise<void>;
|
||||
getUserProfile(): Promise<FullUser>;
|
||||
getUserMfaPreferences(): Promise<UserMFAPreferences>;
|
||||
updateUserName(name: string): Promise<void>;
|
||||
getWorker(key: string): Promise<string>;
|
||||
getWorkerAPI(key: string): Promise<DocWorkerAPI>;
|
||||
@ -320,6 +337,8 @@ export interface UserAPI {
|
||||
onUploadProgress?: (ev: ProgressEvent) => void,
|
||||
}): Promise<string>;
|
||||
deleteUser(userId: number, name: string): Promise<void>;
|
||||
registerSoftwareToken(): Promise<SoftwareTokenRegistrationInfo>;
|
||||
unregisterSoftwareToken(): Promise<void>;
|
||||
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<ICustomWidget[]>;
|
||||
@ -582,6 +601,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
||||
return this.requestJson(`${this._url}/api/profile/user`);
|
||||
}
|
||||
|
||||
public async getUserMfaPreferences(): Promise<UserMFAPreferences> {
|
||||
return this.requestJson(`${this._url}/api/profile/mfa_preferences`);
|
||||
}
|
||||
|
||||
public async updateUserName(name: string): Promise<void> {
|
||||
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<SoftwareTokenRegistrationInfo> {
|
||||
return this.requestJson(`${this._url}/api/auth/register_totp`, {method: 'POST'});
|
||||
}
|
||||
|
||||
public async unregisterSoftwareToken(): Promise<void> {
|
||||
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.
|
||||
|
@ -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<GristLoadConfig>,
|
||||
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<GristLoadConfig>, 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')!; }
|
||||
|
@ -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<void> {
|
||||
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<SessionUserObj>} newProps New property values to set.
|
||||
*/
|
||||
public async updateUser(req: Request, newProps: Partial<SessionUserObj>): Promise<void> {
|
||||
await this.operateOnScopedSession(req, async user => ({...user, ...newProps}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
|
@ -15,15 +15,25 @@ export function expressWrap(callback: express.RequestHandler): express.RequestHa
|
||||
};
|
||||
}
|
||||
|
||||
interface JsonErrorHandlerOptions {
|
||||
shouldLogBody?: boolean;
|
||||
shouldLogParams?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns a custom error-handling middleware that responds to errors in json.
|
||||
*
|
||||
* Currently allows for toggling of logging request bodies and params.
|
||||
*/
|
||||
export const jsonErrorHandler: express.ErrorRequestHandler = (err, req, res, next) => {
|
||||
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 params %s body %s", req.path, err.message,
|
||||
mreq.userId,
|
||||
JSON.stringify(req.params), JSON.stringify(req.body));
|
||||
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) {
|
||||
@ -33,8 +43,25 @@ export const jsonErrorHandler: express.ErrorRequestHandler = (err, req, res, nex
|
||||
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 = 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.
|
||||
*/
|
||||
|
@ -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();
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
14
static/account.html
Normal file
14
static/account.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<!-- INSERT BASE -->
|
||||
<link rel="icon" type="image/x-icon" href="favicon.png" />
|
||||
<link rel="stylesheet" href="icons/icons.css">
|
||||
<title>Grist</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- INSERT ACCOUNT -->
|
||||
<script src="account.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
File diff suppressed because one or more lines are too long
7
static/ui-icons/UI/BarcodeQR.svg
Normal file
7
static/ui-icons/UI/BarcodeQR.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 0.5H0.5V6.5H6.5V0.5Z" stroke="#212121" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.5 9.5H0.5V15.5H6.5V9.5Z" stroke="#212121" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.5 0.5H9.5V6.5H15.5V0.5Z" stroke="#212121" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.5 15.5H9.5" stroke="#212121" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.5 9.5H15.5V12.5" stroke="#212121" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 714 B |
11
static/ui-icons/UI/BarcodeQR2.svg
Normal file
11
static/ui-icons/UI/BarcodeQR2.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 7H1C0.4 7 0 6.6 0 6V1C0 0.4 0.4 0 1 0H6C6.6 0 7 0.4 7 1V6C7 6.6 6.6 7 6 7ZM2 5H5V2H2V5Z" fill="#16B378"/>
|
||||
<path d="M6 16H1C0.4 16 0 15.6 0 15V10C0 9.4 0.4 9 1 9H6C6.6 9 7 9.4 7 10V15C7 15.6 6.6 16 6 16ZM2 14H5V11H2V14Z" fill="#16B378"/>
|
||||
<path d="M15 7H10C9.4 7 9 6.6 9 6V1C9 0.4 9.4 0 10 0H15C15.6 0 16 0.4 16 1V6C16 6.6 15.6 7 15 7ZM11 5H14V2H11V5Z" fill="#16B378"/>
|
||||
<path d="M4 3H3V4H4V3Z" fill="#16B378"/>
|
||||
<path d="M4 12H3V13H4V12Z" fill="#16B378"/>
|
||||
<path d="M16 14H9V16H16V14Z" fill="#16B378"/>
|
||||
<path d="M16 13H14V11H11V9H15C15.6 9 16 9.4 16 10V13Z" fill="#16B378"/>
|
||||
<path d="M13 12H12V13H13V12Z" fill="#16B378"/>
|
||||
<path d="M13 3H12V4H13V3Z" fill="#16B378"/>
|
||||
</svg>
|
After Width: | Height: | Size: 777 B |
@ -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() {
|
||||
|
104
yarn.lock
104
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"
|
||||
|
Loading…
Reference in New Issue
Block a user