(core) Migrate to SRP and add change password dialog

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
This commit is contained in:
George Gevoian
2022-03-16 19:32:17 -07:00
parent 7ba4dff18f
commit 0f4f0d3dad
9 changed files with 44 additions and 1204 deletions

View File

@@ -1,4 +1,5 @@
import {reportError} from 'app/client/models/errors';
import {ApiError} from 'app/common/ApiError';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, Observable} from 'grainjs';
@@ -52,3 +53,20 @@ export function formDataToObj(formElem: HTMLFormElement): { [key: string]: strin
export async function submitForm(fields: { [key: string]: string }, form: HTMLFormElement): Promise<any> {
return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(fields)});
}
/**
* Sets the error details on `errObs` if `err` is a 4XX error (except 401). Otherwise, reports the
* error via the Notifier instance.
*/
export function handleFormError(err: unknown, errObs: Observable<string|null>) {
if (
err instanceof ApiError &&
err.status !== 401 &&
err.status >= 400 &&
err.status < 500
) {
errObs.set(err.details?.userError ?? err.message);
} else {
reportError(err as Error|string);
}
}

View File

@@ -84,13 +84,6 @@ export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string
return _getLoginLogoutUrl('signin', nextUrl);
}
// Get url for the reset password page.
export function getResetPwdUrl(): string {
const startUrl = new URL(window.location.href);
startUrl.pathname = '/resetPassword';
return startUrl.href;
}
// Returns the URL for the "you are signed out" page.
export function getSignedOutUrl(): string { return getMainOrgUrl() + "signed-out"; }

View File

@@ -1,7 +1,10 @@
{
"extends": "../../buildtools/tsconfig-base.json",
"include": [
"**/*",
"../../stubs/app/client/**/*"
],
"references": [
{ "path": "../common" },
{ "path": "../../stubs/app" },
]
}

View File

@@ -1,19 +1,18 @@
import {AppModel, reportError} from 'app/client/models/AppModel';
import {getResetPwdUrl, urlState} from 'app/client/models/gristUrlState';
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 {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
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 {FullUser} from 'app/common/UserAPI';
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-account-page-');
@@ -24,7 +23,6 @@ const testId = makeTestId('test-account-page-');
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));
@@ -93,9 +91,8 @@ export class AccountPage extends Disposable {
cssDataRow(
cssSubHeader('Login Method'),
cssLoginMethod(user.loginMethod),
user.loginMethod === 'Email + Password' ? cssTextBtn(
cssIcon('Settings'), 'Reset',
dom.on('click', () => confirmPwdResetModal(user.email)),
user.loginMethod === 'Email + Password' ? cssTextBtn('Change Password',
dom.on('click', () => this._showChangePasswordDialog()),
) : null,
testId('login-method'),
),
@@ -114,10 +111,7 @@ export class AccountPage extends Disposable {
"to ensure that you're the only person who can access your account, even if someone " +
"knows your password."
),
dom.create(MFAConfig, this._userMfaPreferences, {
appModel: this._appModel,
onChange: () => this._fetchUserMfaPreferences(),
}),
dom.create(MFAConfig, user),
),
cssHeader('API'),
cssDataRow(cssSubHeader('API Key'), cssContent(
@@ -166,21 +160,11 @@ export class AccountPage extends Disposable {
this._userObs.set(await this._appModel.api.getUserProfile());
}
private async _fetchUserMfaPreferences() {
this._userMfaPreferences.set(null);
this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences());
}
private async _fetchAll() {
await Promise.all([
this._fetchApiKey(),
this._fetchUserProfile(),
]);
const user = this._userObs.get();
if (user?.loginMethod === 'Email + Password') {
await this._fetchUserMfaPreferences();
}
}
private async _updateUserName(val: string) {
@@ -195,26 +179,10 @@ export class AccountPage extends Disposable {
await this._appModel.api.updateAllowGoogleLogin(allowGoogleLogin);
await this._fetchUserProfile();
}
}
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()),
),
),
];
});
private _showChangePasswordDialog() {
return buildChangePasswordDialog();
}
}
/**
@@ -292,7 +260,7 @@ const cssTextBtn = styled('button', `
border: none;
padding: 0;
text-align: left;
min-width: 90px;
min-width: 110px;
&:hover {
color: ${colors.darkGreen};

File diff suppressed because it is too large Load Diff