import {AppModel, reportError} from 'app/client/models/AppModel'; import {urlState} from 'app/client/models/gristUrlState'; import {ApiKey} from 'app/client/ui/ApiKey'; import {AppHeader} from 'app/client/ui/AppHeader'; import {buildChangePasswordDialog} from 'app/client/ui/ChangePasswordDialog'; 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 {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {icon} from 'app/client/ui2018/icons'; import {colors, vars} from 'app/client/ui2018/cssVars'; import {FullUser} from 'app/common/UserAPI'; import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs'; const testId = makeTestId('test-account-page-'); /** * Creates the account page where a user can manage their profile settings. */ export class AccountPage extends Disposable { private _apiKey = Observable.create(this, ''); private _userObs = Observable.create(this, null); private _isEditingName = Observable.create(this, false); private _nameEdit = Observable.create(this, ''); private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val)); private _allowGoogleLogin = Computed.create(this, (use) => use(this._userObs)?.allowGoogleLogin ?? false) .onWrite((val) => this._updateAllowGooglelogin(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'), cssEmail(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(''); }, }, { size: '5' }, // Lower size so that input can shrink below ~152px. dom.on('input', (_ev, el) => this._nameEdit.set(el.value)), cssFlexGrow.cls(''), ), cssTextBtn( cssIcon('Settings'), 'Save', // No need to save on 'click'. The transient input already does it on close. ), ] : [ cssName(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'), cssLoginMethod(user.loginMethod), user.loginMethod === 'Email + Password' ? cssTextBtn('Change Password', dom.on('click', () => this._showChangePasswordDialog()), ) : null, testId('login-method'), ), user.loginMethod !== 'Email + Password' ? null : dom.frag( cssDataRow( labeledSquareCheckbox( this._allowGoogleLogin, 'Allow signing in to this account with Google', testId('allow-google-login-checkbox'), ), testId('allow-google-login'), ), 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, user), ), cssHeader('API'), cssDataRow(cssSubHeader('API Key'), cssContent( dom.create(ApiKey, { apiKey: this._apiKey, onCreate: () => this._createApiKey(), onDelete: () => this._deleteApiKey(), anonymous: false, inputArgs: [{ size: '5' }], // Lower size so that input can shrink below ~152px. }) )), ), 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 _fetchAll() { await Promise.all([ 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(); } private async _updateAllowGooglelogin(allowGoogleLogin: boolean) { await this._appModel.api.updateAllowGoogleLogin(allowGoogleLogin); await this._fetchUserProfile(); } private _showChangePasswordDialog() { return buildChangePasswordDialog(); } } /** * We allow alphanumeric characters and certain common whitelisted characters (except at the start), * plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be * more precise about what exactly to allow). */ // eslint-disable-next-line no-control-regex const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/; /** * Test name against various rules to check if it is a valid username. */ export function checkName(name: string): boolean { return VALID_NAME_REGEXP.test(name); } /** * Builds dom to show marning messages to the user. */ function buildNameWarningsDom() { return cssWarning( "Names only allow letters, numbers and certain special characters", testId('username-warning'), ); } 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: 16px; `); 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, ` min-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: 16px; background-color: transparent; border: none; padding: 0; text-align: left; min-width: 110px; &: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; `); const cssFlexGrow = styled('div', ` flex-grow: 1; `); const cssName = styled(cssFlexGrow, ` word-break: break-word; `); const cssEmail = styled('div', ` word-break: break-word; `); const cssLoginMethod = styled(cssFlexGrow, ` word-break: break-word; `); const cssWarning = styled('div', ` color: red; `);