(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:
George Gevoian 2022-01-07 10:11:52 -08:00
parent 8f531ef622
commit ba6ecc5e9e
21 changed files with 1179 additions and 211 deletions

17
app/client/accountMain.ts Normal file
View 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),
]));

View File

@ -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);
}

View 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;
// `);

View File

@ -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
View 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;
`);

View File

@ -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;
`);

View File

@ -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",

View File

@ -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.

View File

@ -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')!; }

View File

@ -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}));
}
/**

View File

@ -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,

View File

@ -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.

View File

@ -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();

View File

@ -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",

View File

@ -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
View 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

View 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

View 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

View File

@ -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
View File

@ -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"