mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
0f4f0d3dad
Summary: Moves some auth-related UI components, like MFAConfig, out of core, and adds a new ChangePasswordDialog component for allowing direct password changes, replacing the old reset password link to hosted Cognito. Updates all MFA endpoints to use SRP for authentication. Also refactors MFAConfig into smaller files, and polishes up some parts of the UI to be more consistent with the login pages. Test Plan: New server and deployment tests. Updated existing tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3311
307 lines
9.0 KiB
TypeScript
307 lines
9.0 KiB
TypeScript
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<string>(this, '');
|
|
private _userObs = Observable.create<FullUser|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));
|
|
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;
|
|
`);
|