(core) Enable MFA configuration (and add SMS)

Summary:
Enables configuration of multi-factor authentication from the
account page (for users who sign in with email/password), and adds
SMS as an authentication method.

Test Plan: Project, browser and server tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3215
This commit is contained in:
George Gevoian
2022-01-19 11:41:06 -08:00
parent 1b4580d92e
commit 0d005eb78d
13 changed files with 839 additions and 345 deletions

View File

@@ -1,17 +1,5 @@
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 {setupPage} from 'app/client/ui/setupPage';
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),
]));
setupPage((appModel) => dom.create(AccountPage, appModel));

View File

@@ -1,17 +1,4 @@
import {TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {createErrPage} from 'app/client/ui/errorPages';
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';
import {setupPage} from 'app/client/ui/setupPage';
// 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) => [
createErrPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),
]));
setupPage((appModel) => createErrPage(appModel));

View File

@@ -0,0 +1,54 @@
import {reportError} from 'app/client/models/errors';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, Observable} from 'grainjs';
/**
* Handles submission of an HTML form element.
*
* When the form is submitted, `onSubmit` will be called, followed by
* either `onSuccess` or `onError`, depending on whether `onSubmit` threw any
* unhandled errors. The `pending` observable is set to true until `onSubmit`
* resolves.
*/
export function handleSubmit<T>(
pending: Observable<boolean>,
onSubmit: (fields: { [key: string]: string }, form: HTMLFormElement) => Promise<T> = submitForm,
onSuccess: (v: T) => void = () => { /* noop */ },
onError: (e: unknown) => void = (e) => reportError(e as string | Error)
): (elem: HTMLFormElement) => void {
return dom.on('submit', async (e, form) => {
e.preventDefault();
try {
if (pending.get()) { return; }
pending.set(true);
const result = await onSubmit(formDataToObj(form), form).finally(() => pending.set(false));
onSuccess(result);
} catch (err) {
onError(err);
}
});
}
/**
* Convert a form to a JSON-stringifiable object, ignoring any File fields.
*/
export function formDataToObj(formElem: HTMLFormElement): { [key: string]: string } {
// Use FormData to collect values (rather than e.g. finding <input> elements) to ensure we get
// values from all form items correctly (e.g. checkboxes and textareas).
const formData = new FormData(formElem);
const data: { [key: string]: string } = {};
for (const [name, value] of formData.entries()) {
if (typeof value === 'string') {
data[name] = value;
}
}
return data;
}
/**
* Submit a form using BaseAPI. Send inputs as JSON, and interpret any reply as JSON.
*/
export async function submitForm(fields: { [key: string]: string }, form: HTMLFormElement): Promise<any> {
return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(fields)});
}

View File

@@ -10,7 +10,6 @@
import {DocComm} from 'app/client/components/DocComm';
import {UserError} from 'app/client/models/errors';
import {FileDialogOptions, openFilePicker} from 'app/client/ui/FileDialog';
import {BaseAPI} from 'app/common/BaseAPI';
import {GristLoadConfig} from 'app/common/gristUrls';
import {byteString, safeJsonParse} from 'app/common/gutil';
import {FetchUrlOptions, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads';
@@ -184,25 +183,3 @@ export function isDriveUrl(url: string) {
const match = /^https:\/\/(docs|drive).google.com\/(spreadsheets|file)\/d\/([^/]*)/i.exec(url);
return !!match;
}
/**
* Convert a form to a JSON-stringifiable object, ignoring any File fields.
*/
export function formDataToObj(formElem: HTMLFormElement): {[key: string]: string} {
// Use FormData to collect values (rather than e.g. finding <input> elements) to ensure we get
// values from all form items correctly (e.g. checkboxes and textareas).
const formData = new FormData(formElem);
const data: {[key: string]: string} = {};
for (const [name, value] of formData.entries()) {
if (typeof value === 'string') {
data[name] = value;
}
}
return data;
}
// Submit a form using BaseAPI. Send inputs as JSON, and interpret any reply as JSON.
export async function submitForm(form: HTMLFormElement): Promise<any> {
const data = formDataToObj(form);
return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(data)});
}

View File

@@ -3,7 +3,7 @@ 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 {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';
@@ -13,7 +13,7 @@ import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/b
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, UserMFAPreferences} from 'app/common/UserAPI';
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-account-page-');
@@ -24,7 +24,7 @@ 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 _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));
@@ -86,26 +86,24 @@ export class AccountPage extends Disposable {
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,
// }),
// ),
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, {
appModel: this._appModel,
onChange: () => this._fetchUserMfaPreferences(),
}),
),
cssHeader('API'),
cssDataRow(cssSubHeader('API Key'), cssContent(
dom.create(ApiKey, {
@@ -152,13 +150,14 @@ export class AccountPage extends Disposable {
this._userObs.set(await this._appModel.api.getUserProfile());
}
// private async _fetchUserMfaPreferences() {
// this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences());
// }
private async _fetchUserMfaPreferences() {
this._userMfaPreferences.set(null);
this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences());
}
private async _fetchAll() {
await Promise.all([
// this._fetchUserMfaPreferences(),
this._fetchUserMfaPreferences(),
this._fetchApiKey(),
this._fetchUserProfile(),
]);
@@ -263,7 +262,7 @@ const cssWarnings = styled(buildNameWarningsDom, `
margin: -8px 0 0 110px;
`);
// const cssDescription = styled('div', `
// color: #8a8a8a;
// font-size: 13px;
// `);
const cssDescription = styled('div', `
color: #8a8a8a;
font-size: 13px;
`);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs";
import { submitForm } from "app/client/lib/uploads";
import { handleSubmit, submitForm } from "app/client/lib/formUtils";
import { AppModel, reportError } from "app/client/models/AppModel";
import { getLoginUrl, getSignupUrl, urlState } from "app/client/models/gristUrlState";
import { AccountWidget } from "app/client/ui/AccountWidget";
@@ -35,29 +35,12 @@ function _redirectOnSuccess(result: any) {
window.location.assign(redirectUrl);
}
async function _submitForm(form: HTMLFormElement, pending: Observable<boolean>,
onSuccess: (v: any) => void = _redirectOnSuccess,
onError: (e: Error) => void = reportError) {
try {
if (pending.get()) { return; }
pending.set(true);
const result = await submitForm(form).finally(() => pending.set(false));
onSuccess(result);
} catch (err) {
onError(err?.details?.userError || err);
}
}
// If a 'pending' observable is given, it will be set to true while waiting for the submission.
function handleSubmit(pending: Observable<boolean>,
onSuccess?: (v: any) => void,
onError?: (e: Error) => void): (elem: HTMLFormElement) => void {
return dom.on('submit', async (e, form) => {
e.preventDefault();
// TODO: catch isn't needed, so either remove or propagate errors from _submitForm.
_submitForm(form, pending, onSuccess, onError).catch(reportError);
});
function handleSubmitForm(
pending: Observable<boolean>,
onSuccess: (v: any) => void = _redirectOnSuccess,
onError?: (e: unknown) => void
): (elem: HTMLFormElement) => void {
return handleSubmit(pending, submitForm, onSuccess, onError);
}
export class WelcomePage extends Disposable {
@@ -114,13 +97,13 @@ export class WelcomePage extends Disposable {
return form = dom(
'form',
{ method: "post", action: location.href },
handleSubmit(pending),
handleSubmitForm(pending),
cssLabel('Your full name, as you\'d like it displayed to your collaborators.'),
inputEl = cssInput(
value, { onInput: true, },
{ name: "username" },
// TODO: catch isn't needed, so either remove or propagate errors from _submitForm.
dom.onKeyDown({Enter: () => isNameValid.get() && _submitForm(form, pending).catch(reportError)}),
dom.onKeyDown({Enter: () => isNameValid.get() && form.requestSubmit()}),
),
dom.maybe((use) => use(value) && !use(isNameValid), buildNameWarningsDom),
cssButtonGroup(
@@ -153,7 +136,7 @@ export class WelcomePage extends Disposable {
return dom(
'form',
{ method: "post", action: action.href },
handleSubmit(pending, () => _redirectToSiblingPage('verify')),
handleSubmitForm(pending, () => _redirectToSiblingPage('verify')),
dom('p',
`Welcome Sumo-ling! ` + // This flow currently only used with AppSumo.
`Your Grist site is almost ready. Let's get your account set up and verified. ` +
@@ -212,7 +195,7 @@ export class WelcomePage extends Disposable {
return dom(
'form',
{ method: "post", action: action.href },
handleSubmit(pending, (result) => {
handleSubmitForm(pending, (result) => {
if (result.act === 'confirmed') {
const verified = new URL(window.location.href);
verified.pathname = '/verified';
@@ -259,7 +242,7 @@ export class WelcomePage extends Disposable {
const pending = Observable.create(owner, false);
return forms.form({method: "post", action: location.href },
handleSubmit(pending),
handleSubmitForm(pending),
(elem) => { setTimeout(() => elem.focus(), 0); },
forms.text('Please help us serve you better by answering a few questions.'),
forms.question(

View File

@@ -0,0 +1,21 @@
import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {dom, DomContents} from 'grainjs';
/**
* Sets up error handling and global styles, and replaces the DOM body with
* the result of calling `buildPage`.
*/
export function setupPage(buildPage: (appModel: AppModel) => DomContents) {
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
attachCssRootVars(topAppModel.productFlavor);
addViewportTag();
dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [
buildPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),
]));
}

View File

@@ -66,6 +66,8 @@ export type IconName = "ChartArea" |
"Log" |
"Mail" |
"Minus" |
"MobileChat" |
"MobileChat2" |
"NewNotification" |
"Notification" |
"Offline" |
@@ -177,6 +179,8 @@ export const IconList: IconName[] = ["ChartArea",
"Log",
"Mail",
"Minus",
"MobileChat",
"MobileChat2",
"NewNotification",
"Notification",
"Offline",