mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
54
app/client/lib/formUtils.ts
Normal file
54
app/client/lib/formUtils.ts
Normal 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)});
|
||||
}
|
||||
@@ -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)});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
21
app/client/ui/setupPage.ts
Normal file
21
app/client/ui/setupPage.ts
Normal 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),
|
||||
]));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user