mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add new Grist login page
Summary: Adds a new Grist login page to the login app, and replaces the server-side Cognito Google Sign-In flow with Google's own OAuth flow. Test Plan: Browser and server tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3332
This commit is contained in:
parent
8fdfb02646
commit
6305811ca6
@ -183,7 +183,6 @@ GRIST_HOME_INCLUDE_STATIC | if set, home server also serves static resources
|
|||||||
GRIST_HOST | hostname to use when listening on a port.
|
GRIST_HOST | hostname to use when listening on a port.
|
||||||
GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*.
|
GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*.
|
||||||
GRIST_INST_DIR | path to Grist instance configuration files, for Grist server.
|
GRIST_INST_DIR | path to Grist instance configuration files, for Grist server.
|
||||||
GRIST_LOGIN_REDIRECT_HOST | host of cognito-based login helper, if applicable (usually login.getgrist.com).
|
|
||||||
GRIST_MANAGED_WORKERS | if set, Grist can assume that if a url targeted at a doc worker returns a 404, that worker is gone
|
GRIST_MANAGED_WORKERS | if set, Grist can assume that if a url targeted at a doc worker returns a 404, that worker is gone
|
||||||
GRIST_MAX_UPLOAD_ATTACHMENT_MB | max allowed size for attachments (0 or empty for unlimited).
|
GRIST_MAX_UPLOAD_ATTACHMENT_MB | max allowed size for attachments (0 or empty for unlimited).
|
||||||
GRIST_MAX_UPLOAD_IMPORT_MB | max allowed size for imports (except .grist files) (0 or empty for unlimited).
|
GRIST_MAX_UPLOAD_IMPORT_MB | max allowed size for imports (except .grist files) (0 or empty for unlimited).
|
||||||
|
@ -24,7 +24,8 @@
|
|||||||
*/
|
*/
|
||||||
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
|
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
|
||||||
import {UrlState} from 'app/client/lib/UrlState';
|
import {UrlState} from 'app/client/lib/UrlState';
|
||||||
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
|
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState,
|
||||||
|
parseFirstUrlPart} from 'app/common/gristUrls';
|
||||||
import {addOrgToPath} from 'app/common/urlUtils';
|
import {addOrgToPath} from 'app/common/urlUtils';
|
||||||
import {Document} from 'app/common/UserAPI';
|
import {Document} from 'app/common/UserAPI';
|
||||||
import isEmpty = require('lodash/isEmpty');
|
import isEmpty = require('lodash/isEmpty');
|
||||||
@ -64,39 +65,40 @@ export function getMainOrgUrl(): string { return urlState().makeUrl({}); }
|
|||||||
// When on a document URL, returns the URL with just the doc ID, omitting other bits (like page).
|
// When on a document URL, returns the URL with just the doc ID, omitting other bits (like page).
|
||||||
export function getCurrentDocUrl(): string { return urlState().makeUrl({docPage: undefined}); }
|
export function getCurrentDocUrl(): string { return urlState().makeUrl({docPage: undefined}); }
|
||||||
|
|
||||||
// Get url for the login page, which will then redirect to nextUrl (current page by default).
|
// Get url for the login page, which will then redirect to `nextUrl` (current page by default).
|
||||||
export function getLoginUrl(nextUrl: string | null = _getCurrentUrl()): string {
|
export function getLoginUrl(nextUrl: string | null = _getCurrentUrl()): string {
|
||||||
return _getLoginLogoutUrl('login', nextUrl ?? undefined);
|
return _getLoginLogoutUrl('login', nextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get url for the signup page, which will then redirect to nextUrl (current page by default).
|
// Get url for the signup page, which will then redirect to `nextUrl` (current page by default).
|
||||||
export function getSignupUrl(nextUrl: string = _getCurrentUrl()): string {
|
export function getSignupUrl(nextUrl: string = _getCurrentUrl()): string {
|
||||||
return _getLoginLogoutUrl('signup', nextUrl);
|
return _getLoginLogoutUrl('signup', nextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get url for the logout page, which will then redirect to nextUrl (signed-out page by default).
|
// Get url for the logout page.
|
||||||
export function getLogoutUrl(nextUrl: string = getSignedOutUrl()): string {
|
export function getLogoutUrl(): string {
|
||||||
return _getLoginLogoutUrl('logout', nextUrl);
|
return _getLoginLogoutUrl('logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get url for the login page, which will then redirect to nextUrl (current page by default).
|
// Get url for the signin page, which will then redirect to `nextUrl` (current page by default).
|
||||||
export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string {
|
export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string {
|
||||||
return _getLoginLogoutUrl('signin', nextUrl);
|
return _getLoginLogoutUrl('signin', nextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the URL for the "you are signed out" page.
|
// Returns the relative URL (i.e. path) of the current page, except when it's the
|
||||||
export function getSignedOutUrl(): string { return getMainOrgUrl() + "signed-out"; }
|
// "/signed-out" page, in which case it returns the home page ("/").
|
||||||
|
// This is a good URL to use for a post-login redirect.
|
||||||
// Helper which returns the URL of the current page, except when it's the "/signed-out" page, in
|
|
||||||
// which case returns the org URL. This is a good URL to use for a post-login redirect.
|
|
||||||
function _getCurrentUrl(): string {
|
function _getCurrentUrl(): string {
|
||||||
return window.location.pathname.endsWith("/signed-out") ? getMainOrgUrl() : window.location.href;
|
if (window.location.pathname.endsWith('/signed-out')) { return '/'; }
|
||||||
|
|
||||||
|
const {pathname, search} = new URL(window.location.href);
|
||||||
|
return parseFirstUrlPart('o', pathname).path + search;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper for getLoginUrl()/getLogoutUrl().
|
// Returns the URL for the given login page, with 'next' param optionally set.
|
||||||
function _getLoginLogoutUrl(method: 'login'|'logout'|'signin'|'signup', nextUrl?: string): string {
|
function _getLoginLogoutUrl(page: 'login'|'logout'|'signin'|'signup', nextUrl?: string | null): string {
|
||||||
const startUrl = new URL(window.location.href);
|
const startUrl = new URL(window.location.href);
|
||||||
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + method;
|
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + page;
|
||||||
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
|
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
|
||||||
return startUrl.href;
|
return startUrl.href;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {beaconOpenMessage} from 'app/client/lib/helpScout';
|
import {beaconOpenMessage} from 'app/client/lib/helpScout';
|
||||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||||
import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel';
|
import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel';
|
||||||
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
|
import {getLoginUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||||
import {BillingForm, IFormData} from 'app/client/ui/BillingForm';
|
import {BillingForm, IFormData} from 'app/client/ui/BillingForm';
|
||||||
import * as css from 'app/client/ui/BillingPageCss';
|
import * as css from 'app/client/ui/BillingPageCss';
|
||||||
@ -499,7 +499,7 @@ export class BillingPage extends Disposable {
|
|||||||
// If the user is not logged in and selects the free plan, provide a login link that
|
// If the user is not logged in and selects the free plan, provide a login link that
|
||||||
// redirects back to the free org.
|
// redirects back to the free org.
|
||||||
return css.upgradeBtn('Sign up',
|
return css.upgradeBtn('Sign up',
|
||||||
{href: getLoginUrl(getMainOrgUrl())},
|
{href: getLoginUrl()},
|
||||||
testId('plan-btn')
|
testId('plan-btn')
|
||||||
);
|
);
|
||||||
} else if ((!selectedPlan && plan.amount === 0) || (selectedPlan && plan.id === selectedPlan.id)) {
|
} else if ((!selectedPlan && plan.amount === 0) || (selectedPlan && plan.id === selectedPlan.id)) {
|
||||||
|
@ -98,7 +98,7 @@ export class WelcomePage extends Disposable {
|
|||||||
`If you already have a Grist account as `,
|
`If you already have a Grist account as `,
|
||||||
dom('b', email.get()),
|
dom('b', email.get()),
|
||||||
` you can just `,
|
` you can just `,
|
||||||
dom('a', {href: getLoginUrl(urlState().makeUrl({}))}, 'log in'),
|
dom('a', {href: getLoginUrl()}, 'log in'),
|
||||||
` now. Otherwise, please pick a password.`
|
` now. Otherwise, please pick a password.`
|
||||||
),
|
),
|
||||||
cssSeparatedLabel('The email address you activated Grist with:'),
|
cssSeparatedLabel('The email address you activated Grist with:'),
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
import {parseSubdomain} from 'app/common/gristUrls';
|
|
||||||
|
|
||||||
// This interface is used by the standalone login-connect tool for knowing where to redirect to,
|
|
||||||
// by Client.ts to construct this info, and by CognitoClient to decide what to do.
|
|
||||||
|
|
||||||
export interface LoginState {
|
|
||||||
// Locally-running Grist uses localPort, while hosted uses subdomain. Login-connect uses this to
|
|
||||||
// redirect back to the localhost or to the subdomain.
|
|
||||||
localPort?: number;
|
|
||||||
subdomain?: string;
|
|
||||||
baseDomain?: string; // the domain with the (left-most) subdomain removed, e.g. ".getgrist.com".
|
|
||||||
// undefined on localhost.
|
|
||||||
|
|
||||||
// Standalone version sets clientId, used later to find the LoginSession. Hosted and dev
|
|
||||||
// versions rely on the browser cookies instead, specifically on the session cookie.
|
|
||||||
clientId?: string;
|
|
||||||
|
|
||||||
// Hosted and dev versions set redirectUrl and redirect to it when login or logout completes.
|
|
||||||
// Standalone version omits redirectUrl, and serves a page which closes the window.
|
|
||||||
redirectUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allowed localhost addresses.
|
|
||||||
export const localhostRegex = /^localhost(?::(\d+))?$/i;
|
|
||||||
|
|
||||||
export function getLoginState(reqHost: string): LoginState|null {
|
|
||||||
const {org, base} = parseSubdomain(reqHost);
|
|
||||||
const matchPort = localhostRegex.exec(reqHost);
|
|
||||||
return org ? {subdomain: org, baseDomain: base} :
|
|
||||||
matchPort ? {localPort: matchPort[1] ? parseInt(matchPort[1], 10) : 80} : null;
|
|
||||||
}
|
|
@ -2,7 +2,6 @@ import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
|
|||||||
import {OpenDocMode} from 'app/common/DocListAPI';
|
import {OpenDocMode} from 'app/common/DocListAPI';
|
||||||
import {EngineCode} from 'app/common/DocumentSettings';
|
import {EngineCode} from 'app/common/DocumentSettings';
|
||||||
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
|
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
|
||||||
import {localhostRegex} from 'app/common/LoginState';
|
|
||||||
import {LocalPlugin} from 'app/common/plugin';
|
import {LocalPlugin} from 'app/common/plugin';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
import {UIRowId} from 'app/common/UIRowId';
|
import {UIRowId} from 'app/common/UIRowId';
|
||||||
@ -34,7 +33,7 @@ export type WelcomePage = typeof WelcomePage.type;
|
|||||||
export const AccountPage = StringUnion('account');
|
export const AccountPage = StringUnion('account');
|
||||||
export type AccountPage = typeof AccountPage.type;
|
export type AccountPage = typeof AccountPage.type;
|
||||||
|
|
||||||
export const LoginPage = StringUnion('signup', 'verified', 'forgot-password');
|
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
|
||||||
export type LoginPage = typeof LoginPage.type;
|
export type LoginPage = typeof LoginPage.type;
|
||||||
|
|
||||||
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
|
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
|
||||||
@ -89,7 +88,7 @@ export interface IGristUrlState {
|
|||||||
billingPlan?: string;
|
billingPlan?: string;
|
||||||
billingTask?: BillingTask;
|
billingTask?: BillingTask;
|
||||||
embed?: boolean;
|
embed?: boolean;
|
||||||
next?: string;
|
state?: string;
|
||||||
style?: InterfaceStyle;
|
style?: InterfaceStyle;
|
||||||
compare?: string;
|
compare?: string;
|
||||||
linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs.
|
linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs.
|
||||||
@ -302,13 +301,16 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
|||||||
|
|
||||||
if (map.has('signup')) {
|
if (map.has('signup')) {
|
||||||
state.login = 'signup';
|
state.login = 'signup';
|
||||||
|
} else if (map.has('login')) {
|
||||||
|
state.login = 'login';
|
||||||
} else if (map.has('verified')) {
|
} else if (map.has('verified')) {
|
||||||
state.login = 'verified';
|
state.login = 'verified';
|
||||||
} else if (map.has('forgot-password')) {
|
} else if (map.has('forgot-password')) {
|
||||||
state.login = 'forgot-password';
|
state.login = 'forgot-password';
|
||||||
}
|
}
|
||||||
|
if (sp.has('state')) {
|
||||||
if (sp.has('next')) { state.params!.next = sp.get('next')!; }
|
state.params!.state = sp.get('state')!;
|
||||||
|
}
|
||||||
|
|
||||||
if (sp.has('style')) {
|
if (sp.has('style')) {
|
||||||
state.params!.style = InterfaceStyle.parse(sp.get('style'));
|
state.params!.style = InterfaceStyle.parse(sp.get('style'));
|
||||||
@ -407,6 +409,9 @@ export function parseSubdomain(host: string|undefined): {org?: string, base?: st
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allowed localhost addresses.
|
||||||
|
const localhostRegex = /^localhost(?::(\d+))?$/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like parseSubdomain, but throws an error if neither of these cases apply:
|
* Like parseSubdomain, but throws an error if neither of these cases apply:
|
||||||
* - host can be parsed into a valid subdomain and a valid base domain.
|
* - host can be parsed into a valid subdomain and a valid base domain.
|
||||||
@ -566,7 +571,7 @@ export function isOrgInPathOnly(host?: string): boolean {
|
|||||||
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
||||||
return (gristConfig && gristConfig.pathOnly) || false;
|
return (gristConfig && gristConfig.pathOnly) || false;
|
||||||
} else {
|
} else {
|
||||||
if (host && host.match(/^localhost(:[0-9]+)?$/)) { return true; }
|
if (host && host.match(localhostRegex)) { return true; }
|
||||||
return (process.env.GRIST_ORG_IN_PATH === 'true');
|
return (process.env.GRIST_ORG_IN_PATH === 'true');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -921,3 +921,12 @@ export function getDistinctValues<T>(values: readonly T[], count: number = Infin
|
|||||||
}
|
}
|
||||||
return distinct;
|
return distinct;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that variable `name` has a non-nullish `value`.
|
||||||
|
*/
|
||||||
|
export function assertIsDefined<T>(name: string, value: T): asserts value is NonNullable<T> {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
throw new Error(`Expected '${name}' to be defined, but received ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -93,6 +93,9 @@ class DummyDocWorkerMap implements IDocWorkerMap {
|
|||||||
},
|
},
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
_permits.clear();
|
_permits.clear();
|
||||||
|
},
|
||||||
|
getKeyPrefix() {
|
||||||
|
return formatPermitKey('', prefix);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this._permitStores.set(prefix, store);
|
this._permitStores.set(prefix, store);
|
||||||
@ -461,6 +464,9 @@ export class DocWorkerMap implements IDocWorkerMap {
|
|||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
// nothing to do
|
// nothing to do
|
||||||
|
},
|
||||||
|
getKeyPrefix() {
|
||||||
|
return formatPermitKey('', prefix);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -282,7 +282,9 @@ export class ScopedSession {
|
|||||||
const session = prev || await this._getSession();
|
const session = prev || await this._getSession();
|
||||||
if (!session.users) { session.users = []; }
|
if (!session.users) { session.users = []; }
|
||||||
if (!session.orgToUser) { session.orgToUser = {}; }
|
if (!session.orgToUser) { session.orgToUser = {}; }
|
||||||
let index = session.users.findIndex(u => Boolean(u.profile && u.profile.email === profile.email));
|
let index = session.users.findIndex(u => {
|
||||||
|
return Boolean(u.profile && normalizeEmail(u.profile.email) === normalizeEmail(profile.email));
|
||||||
|
});
|
||||||
if (index < 0) { index = session.users.length; }
|
if (index < 0) { index = session.users.length; }
|
||||||
session.orgToUser[this._org] = index;
|
session.orgToUser[this._org] = index;
|
||||||
session.users[index] = user;
|
session.users[index] = user;
|
||||||
|
@ -2,7 +2,6 @@ import {ApiError} from 'app/common/ApiError';
|
|||||||
import {BrowserSettings} from 'app/common/BrowserSettings';
|
import {BrowserSettings} from 'app/common/BrowserSettings';
|
||||||
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
||||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
import {getLoginState, LoginState} from 'app/common/LoginState';
|
|
||||||
import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI';
|
import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI';
|
||||||
import {User} from 'app/gen-server/entity/User';
|
import {User} from 'app/gen-server/entity/User';
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
@ -75,7 +74,6 @@ export class Client {
|
|||||||
private _destroyTimer: NodeJS.Timer|null = null;
|
private _destroyTimer: NodeJS.Timer|null = null;
|
||||||
private _destroyed: boolean = false;
|
private _destroyed: boolean = false;
|
||||||
private _websocket: any;
|
private _websocket: any;
|
||||||
private _loginState: LoginState|null = null;
|
|
||||||
private _org: string|null = null;
|
private _org: string|null = null;
|
||||||
private _profile: UserProfile|null = null;
|
private _profile: UserProfile|null = null;
|
||||||
private _userId: number|null = null;
|
private _userId: number|null = null;
|
||||||
@ -95,9 +93,6 @@ export class Client {
|
|||||||
|
|
||||||
public toString() { return `Client ${this.clientId} #${this._counter}`; }
|
public toString() { return `Client ${this.clientId} #${this._counter}`; }
|
||||||
|
|
||||||
// Returns the LoginState object that's encoded and passed via login pages to login-connect.
|
|
||||||
public getLoginState(): LoginState|null { return this._loginState; }
|
|
||||||
|
|
||||||
public setCounter(counter: string) {
|
public setCounter(counter: string) {
|
||||||
this._counter = counter;
|
this._counter = counter;
|
||||||
}
|
}
|
||||||
@ -112,8 +107,6 @@ export class Client {
|
|||||||
|
|
||||||
public setConnection(websocket: any, reqHost: string, browserSettings: BrowserSettings) {
|
public setConnection(websocket: any, reqHost: string, browserSettings: BrowserSettings) {
|
||||||
this._websocket = websocket;
|
this._websocket = websocket;
|
||||||
// Set this._loginState, used by CognitoClient to construct login/logout URLs.
|
|
||||||
this._loginState = getLoginState(reqHost);
|
|
||||||
this.browserSettings = browserSettings;
|
this.browserSettings = browserSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
|
||||||
import {BillingTask} from 'app/common/BillingAPI';
|
import {BillingTask} from 'app/common/BillingAPI';
|
||||||
import {delay} from 'app/common/delay';
|
import {delay} from 'app/common/delay';
|
||||||
import {DocCreationInfo} from 'app/common/DocListAPI';
|
import {DocCreationInfo} from 'app/common/DocListAPI';
|
||||||
@ -305,8 +304,7 @@ export class FlexServer implements GristServer {
|
|||||||
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
|
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
|
||||||
// Add a timestamp token that matches exactly the formatting of non-morgan logs.
|
// Add a timestamp token that matches exactly the formatting of non-morgan logs.
|
||||||
morganLogger.token('logTime', (req: Request) => log.timestamp());
|
morganLogger.token('logTime', (req: Request) => log.timestamp());
|
||||||
// Add an optional gristInfo token that can replace the url, if the url is sensitive
|
// Add an optional gristInfo token that can replace the url, if the url is sensitive.
|
||||||
// (this is the case for some cognito login urls).
|
|
||||||
morganLogger.token('gristInfo', (req: RequestWithGristInfo) =>
|
morganLogger.token('gristInfo', (req: RequestWithGristInfo) =>
|
||||||
req.gristInfo || req.originalUrl || req.url);
|
req.gristInfo || req.originalUrl || req.url);
|
||||||
morganLogger.token('host', (req: express.Request) => req.get('host'));
|
morganLogger.token('host', (req: express.Request) => req.get('host'));
|
||||||
@ -850,49 +848,32 @@ export class FlexServer implements GristServer {
|
|||||||
// should be factored out of it.
|
// should be factored out of it.
|
||||||
this.addComm();
|
this.addComm();
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the URL to redirect back to after successful sign-up, login, or logout.
|
|
||||||
*
|
|
||||||
* Note that in the test env, this will redirect further.
|
|
||||||
*/
|
|
||||||
const getNextUrl = async (mreq: RequestWithLogin): Promise<string> => {
|
|
||||||
const next = optStringParam(mreq.query.next);
|
|
||||||
|
|
||||||
// If a "next" query param isn't present, return the URL of the request org.
|
|
||||||
if (next === undefined) { return getOrgUrl(mreq); }
|
|
||||||
|
|
||||||
// Check that the "next" param has a valid host (native or custom) before returning it.
|
|
||||||
if (!(await this._hosts.isSafeRedirectUrl(next))) {
|
|
||||||
throw new ApiError('Invalid redirect URL', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function redirectToLoginOrSignup(
|
async function redirectToLoginOrSignup(
|
||||||
this: FlexServer, signUp: boolean|null, req: express.Request, resp: express.Response,
|
this: FlexServer, signUp: boolean|null, req: express.Request, resp: express.Response,
|
||||||
) {
|
) {
|
||||||
const mreq = req as RequestWithLogin;
|
const mreq = req as RequestWithLogin;
|
||||||
|
|
||||||
// This will ensure that express-session will set our cookie if it hasn't already -
|
// This will ensure that express-session will set our cookie if it hasn't already -
|
||||||
// we'll need it when we come back from Cognito.
|
// we'll need it when we redirect back.
|
||||||
forceSessionChange(mreq.session);
|
forceSessionChange(mreq.session);
|
||||||
|
// Redirect to the requested URL after successful login.
|
||||||
|
const nextPath = optStringParam(req.query.next);
|
||||||
|
const nextUrl = new URL(getOrgUrl(req, nextPath));
|
||||||
if (signUp === null) {
|
if (signUp === null) {
|
||||||
// Like redirectToLogin in Authorizer, redirect to sign up if it doesn't look like the
|
// Like redirectToLogin in Authorizer, redirect to sign up if it doesn't look like the
|
||||||
// user has ever logged in on this browser.
|
// user has ever logged in on this browser.
|
||||||
signUp = (mreq.session.users === undefined);
|
signUp = (mreq.session.users === undefined);
|
||||||
}
|
}
|
||||||
const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl;
|
const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl;
|
||||||
resp.redirect(await getRedirectUrl(req, new URL(await getNextUrl(mreq))));
|
resp.redirect(await getRedirectUrl(req, nextUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
const middleware = this._loginMiddleware.getLoginOrSignUpMiddleware ?
|
const signinMiddleware = this._loginMiddleware.getLoginOrSignUpMiddleware ?
|
||||||
this._loginMiddleware.getLoginOrSignUpMiddleware() :
|
this._loginMiddleware.getLoginOrSignUpMiddleware() :
|
||||||
[];
|
[];
|
||||||
|
this.app.get('/login', ...signinMiddleware, expressWrap(redirectToLoginOrSignup.bind(this, false)));
|
||||||
this.app.get('/login', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, false)));
|
this.app.get('/signup', ...signinMiddleware, expressWrap(redirectToLoginOrSignup.bind(this, true)));
|
||||||
this.app.get('/signup', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, true)));
|
this.app.get('/signin', ...signinMiddleware, expressWrap(redirectToLoginOrSignup.bind(this, null)));
|
||||||
this.app.get('/signin', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, null)));
|
|
||||||
|
|
||||||
if (allowTestLogin()) {
|
if (allowTestLogin()) {
|
||||||
// This is an endpoint for the dev environment that lets you log in as anyone.
|
// This is an endpoint for the dev environment that lets you log in as anyone.
|
||||||
@ -943,15 +924,18 @@ export class FlexServer implements GristServer {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.app.get('/logout', expressWrap(async (req, resp) => {
|
const logoutMiddleware = this._loginMiddleware.getLogoutMiddleware ?
|
||||||
const mreq = req as RequestWithLogin;
|
this._loginMiddleware.getLogoutMiddleware() :
|
||||||
const scopedSession = this._sessions.getOrCreateSessionFromRequest(mreq);
|
[];
|
||||||
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(await getNextUrl(mreq)));
|
this.app.get('/logout', ...logoutMiddleware, expressWrap(async (req, resp) => {
|
||||||
|
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
|
||||||
|
const signedOutUrl = new URL(getOrgUrl(req) + 'signed-out');
|
||||||
|
const redirectUrl = await this._getLogoutRedirectUrl(req, signedOutUrl);
|
||||||
|
|
||||||
// Clear session so that user needs to log in again at the next request.
|
// Clear session so that user needs to log in again at the next request.
|
||||||
// SAML logout in theory uses userSession, so clear it AFTER we compute the URL.
|
// SAML logout in theory uses userSession, so clear it AFTER we compute the URL.
|
||||||
// Express-session will save these changes.
|
// Express-session will save these changes.
|
||||||
const expressSession = mreq.session;
|
const expressSession = (req as RequestWithLogin).session;
|
||||||
if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }
|
if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }
|
||||||
await scopedSession.clearScopedSession(req);
|
await scopedSession.clearScopedSession(req);
|
||||||
// TODO: limit cache clearing to specific user.
|
// TODO: limit cache clearing to specific user.
|
||||||
@ -959,8 +943,8 @@ export class FlexServer implements GristServer {
|
|||||||
resp.redirect(redirectUrl);
|
resp.redirect(redirectUrl);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add a static "signed-out" page. This is where logout typically lands (after redirecting
|
// Add a static "signed-out" page. This is where logout typically lands (e.g. after redirecting
|
||||||
// through Cognito or SAML).
|
// through SAML).
|
||||||
this.app.get('/signed-out', expressWrap((req, resp) =>
|
this.app.get('/signed-out', expressWrap((req, resp) =>
|
||||||
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'signed-out'}})));
|
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'signed-out'}})));
|
||||||
|
|
||||||
|
@ -51,10 +51,10 @@ export interface GristLoginMiddleware {
|
|||||||
getLoginRedirectUrl(req: express.Request, target: URL): Promise<string>;
|
getLoginRedirectUrl(req: express.Request, target: URL): Promise<string>;
|
||||||
getSignUpRedirectUrl(req: express.Request, target: URL): Promise<string>;
|
getSignUpRedirectUrl(req: express.Request, target: URL): Promise<string>;
|
||||||
getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise<string>;
|
getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise<string>;
|
||||||
|
|
||||||
// Optional middleware for the GET /login, /signup, and /signin routes.
|
// Optional middleware for the GET /login, /signup, and /signin routes.
|
||||||
getLoginOrSignUpMiddleware?(): express.RequestHandler[];
|
getLoginOrSignUpMiddleware?(): express.RequestHandler[];
|
||||||
|
// Optional middleware for the GET /logout route.
|
||||||
|
getLogoutMiddleware?(): express.RequestHandler[];
|
||||||
// Returns arbitrary string for log.
|
// Returns arbitrary string for log.
|
||||||
addEndpoints(app: express.Express): Promise<string>;
|
addEndpoints(app: express.Express): Promise<string>;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { UserProfile } from 'app/common/UserAPI';
|
import {UserProfile} from 'app/common/UserAPI';
|
||||||
import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer';
|
import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer';
|
||||||
import { fromCallback } from 'app/server/lib/serverUtils';
|
import {fromCallback} from 'app/server/lib/serverUtils';
|
||||||
import { Request } from 'express';
|
import {Request} from 'express';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a login system that supports a single hard-coded user.
|
* Return a login system that supports a single hard-coded user.
|
||||||
|
@ -49,6 +49,9 @@ export interface IPermitStore {
|
|||||||
|
|
||||||
// Close down the permit store.
|
// Close down the permit store.
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
|
||||||
|
// Get the permit key prefix.
|
||||||
|
getKeyPrefix(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPermitStores {
|
export interface IPermitStores {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer';
|
import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer';
|
||||||
import { Request } from 'express';
|
import {Request} from 'express';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a login system for testing. Just enough to use the test/login endpoint
|
* Return a login system for testing. Just enough to use the test/login endpoint
|
||||||
@ -9,9 +9,7 @@ export async function getTestLoginSystem(): Promise<GristLoginSystem> {
|
|||||||
return {
|
return {
|
||||||
async getMiddleware(gristServer: GristServer) {
|
async getMiddleware(gristServer: GristServer) {
|
||||||
async function getLoginRedirectUrl(req: Request, url: URL) {
|
async function getLoginRedirectUrl(req: Request, url: URL) {
|
||||||
// The "gristlogin" query parameter does nothing except make tests
|
const target = new URL(gristServer.getHomeUrl(req, 'test/login'));
|
||||||
// that expect hosted cognito happy (they check for gristlogin in url).
|
|
||||||
const target = new URL(gristServer.getHomeUrl(req, 'test/login?gristlogin=1'));
|
|
||||||
target.searchParams.append('next', url.href);
|
target.searchParams.append('next', url.href);
|
||||||
return target.href || url.href;
|
return target.href || url.href;
|
||||||
}
|
}
|
||||||
|
@ -131,27 +131,6 @@ export class Hosts {
|
|||||||
return this._redirectHost.bind(this);
|
return this._redirectHost.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if `url` either points to a native Grist host (e.g. foo.getgrist.com),
|
|
||||||
* or a custom host for an existing Grist org.
|
|
||||||
*/
|
|
||||||
public async isSafeRedirectUrl(url: string): Promise<boolean> {
|
|
||||||
const {host, protocol} = new URL(url);
|
|
||||||
if (!['http:', 'https:'].includes(protocol)) { return false; }
|
|
||||||
|
|
||||||
switch (this._getHostType(host)) {
|
|
||||||
case 'native': { return true; }
|
|
||||||
case 'custom': {
|
|
||||||
const org = await mapGetOrSet(this._host2org, host, async () => {
|
|
||||||
const o = await this._dbManager.connection.manager.findOne(Organization, {host});
|
|
||||||
return o?.domain ?? undefined;
|
|
||||||
});
|
|
||||||
return org !== undefined;
|
|
||||||
}
|
|
||||||
default: { return false; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public close() {
|
public close() {
|
||||||
this._host2org.clear();
|
this._host2org.clear();
|
||||||
this._org2host.clear();
|
this._org2host.clear();
|
||||||
|
@ -68,8 +68,8 @@ export function addOrgToPath(req: RequestWithOrg, path: string): string {
|
|||||||
/**
|
/**
|
||||||
* Get url to the org associated with the request.
|
* Get url to the org associated with the request.
|
||||||
*/
|
*/
|
||||||
export function getOrgUrl(req: Request) {
|
export function getOrgUrl(req: Request, path: string = '/') {
|
||||||
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, '/');
|
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -66,6 +66,7 @@
|
|||||||
"mocha-webdriver": "0.2.9",
|
"mocha-webdriver": "0.2.9",
|
||||||
"moment-locales-webpack-plugin": "^1.2.0",
|
"moment-locales-webpack-plugin": "^1.2.0",
|
||||||
"nodemon": "^2.0.4",
|
"nodemon": "^2.0.4",
|
||||||
|
"otplib": "12.0.1",
|
||||||
"selenium-webdriver": "3.6.0",
|
"selenium-webdriver": "3.6.0",
|
||||||
"source-map-loader": "^0.2.4",
|
"source-map-loader": "^0.2.4",
|
||||||
"stats-webpack-plugin": "^0.7.0",
|
"stats-webpack-plugin": "^0.7.0",
|
||||||
|
@ -210,6 +210,8 @@ const exampleUsers: {[user: string]: {[org: string]: string}} = {
|
|||||||
Fish: 'viewers',
|
Fish: 'viewers',
|
||||||
Abyss: 'owners',
|
Abyss: 'owners',
|
||||||
},
|
},
|
||||||
|
// User Ham has two-factor authentication enabled on staging/prod.
|
||||||
|
Ham: {},
|
||||||
// User support@ owns a workspace "Examples & Templates" in its personal org. It can be shared
|
// User support@ owns a workspace "Examples & Templates" in its personal org. It can be shared
|
||||||
// with everyone@ to let all users see it (this is not done here to avoid impacting all tests).
|
// with everyone@ to let all users see it (this is not done here to avoid impacting all tests).
|
||||||
Support: { Supportland: 'owners' },
|
Support: { Supportland: 'owners' },
|
||||||
|
@ -24,7 +24,7 @@ describe('ActionLog', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
before(async function() {
|
before(async function() {
|
||||||
const session = await gu.session().login();
|
const session = await gu.session().user('user4').login();
|
||||||
await session.tempDoc(cleanup, 'Hello.grist');
|
await session.tempDoc(cleanup, 'Hello.grist');
|
||||||
await gu.dismissWelcomeTourIfNeeded();
|
await gu.dismissWelcomeTourIfNeeded();
|
||||||
});
|
});
|
||||||
|
@ -27,9 +27,9 @@ describe('HomeIntro', function() {
|
|||||||
const signUp = await driver.findContent('.test-welcome-text a', 'sign up');
|
const signUp = await driver.findContent('.test-welcome-text a', 'sign up');
|
||||||
assert.include(await signUp.getAttribute('href'), '/signin');
|
assert.include(await signUp.getAttribute('href'), '/signin');
|
||||||
|
|
||||||
// Check that the link takes us to a login page (either Cognito or Grist, depending on session).
|
// Check that the link takes us to a Grist login page.
|
||||||
await signUp.click();
|
await signUp.click();
|
||||||
await gu.checkSigninPage();
|
await gu.checkLoginPage();
|
||||||
await driver.navigate().back();
|
await driver.navigate().back();
|
||||||
await gu.waitForDocMenuToLoad();
|
await gu.waitForDocMenuToLoad();
|
||||||
});
|
});
|
||||||
|
@ -41,9 +41,9 @@ export const simulateLogin = homeUtil.simulateLogin.bind(homeUtil);
|
|||||||
export const removeLogin = homeUtil.removeLogin.bind(homeUtil);
|
export const removeLogin = homeUtil.removeLogin.bind(homeUtil);
|
||||||
export const setValue = homeUtil.setValue.bind(homeUtil);
|
export const setValue = homeUtil.setValue.bind(homeUtil);
|
||||||
export const isOnLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
export const isOnLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
||||||
|
export const isOnGristLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
||||||
export const checkLoginPage = homeUtil.checkLoginPage.bind(homeUtil);
|
export const checkLoginPage = homeUtil.checkLoginPage.bind(homeUtil);
|
||||||
export const checkGristLoginPage = homeUtil.checkGristLoginPage.bind(homeUtil);
|
export const checkGristLoginPage = homeUtil.checkGristLoginPage.bind(homeUtil);
|
||||||
export const checkSigninPage = homeUtil.checkSigninPage.bind(homeUtil);
|
|
||||||
|
|
||||||
export const fixturesRoot: string = testUtils.fixturesRoot;
|
export const fixturesRoot: string = testUtils.fixturesRoot;
|
||||||
|
|
||||||
@ -1333,6 +1333,7 @@ export enum TestUserEnum {
|
|||||||
user1 = 'chimpy',
|
user1 = 'chimpy',
|
||||||
user2 = 'charon',
|
user2 = 'charon',
|
||||||
user3 = 'kiwi',
|
user3 = 'kiwi',
|
||||||
|
user4 = 'ham',
|
||||||
owner = 'chimpy',
|
owner = 'chimpy',
|
||||||
anon = 'anon',
|
anon = 'anon',
|
||||||
support = 'support',
|
support = 'support',
|
||||||
|
@ -6,6 +6,7 @@ import * as fse from 'fs-extra';
|
|||||||
import defaults = require('lodash/defaults');
|
import defaults = require('lodash/defaults');
|
||||||
import {WebElement} from 'mocha-webdriver';
|
import {WebElement} from 'mocha-webdriver';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
import {authenticator} from 'otplib';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {WebDriver} from 'selenium-webdriver';
|
import {WebDriver} from 'selenium-webdriver';
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ export class HomeUtil {
|
|||||||
* (after having been created if necessary), so that their home api can be later
|
* (after having been created if necessary), so that their home api can be later
|
||||||
* instantiated without page loads.
|
* instantiated without page loads.
|
||||||
* When testing against an external server, the simulated login is in fact genuine,
|
* When testing against an external server, the simulated login is in fact genuine,
|
||||||
* done via Cognito.
|
* done via the Grist login page.
|
||||||
*/
|
*/
|
||||||
public async simulateLogin(name: string, email: string, org: string = "", options: {
|
public async simulateLogin(name: string, email: string, org: string = "", options: {
|
||||||
loginMethod?: UserProfile['loginMethod'],
|
loginMethod?: UserProfile['loginMethod'],
|
||||||
@ -75,26 +76,22 @@ export class HomeUtil {
|
|||||||
}
|
}
|
||||||
// Make sure we revisit page in case login is changing.
|
// Make sure we revisit page in case login is changing.
|
||||||
await this.driver.get('about:blank');
|
await this.driver.get('about:blank');
|
||||||
// When running against an external server, we log in through Cognito.
|
// When running against an external server, we log in through the Grist login page.
|
||||||
await this.driver.get(this.server.getUrl(org, ""));
|
await this.driver.get(this.server.getUrl(org, ""));
|
||||||
if (!(await this.isOnSigninPage())) {
|
if (!await this.isOnLoginPage()) {
|
||||||
// Explicitly click sign-in link if necessary.
|
// Explicitly click sign-in link if necessary.
|
||||||
await this.driver.findWait('.test-user-signin', 4000).click();
|
await this.driver.findWait('.test-user-signin', 4000).click();
|
||||||
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
|
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
|
||||||
}
|
}
|
||||||
// Check if we need to switch to Cognito login from the Grist sign-up page.
|
|
||||||
if (await this.isOnGristLoginPage()) {
|
|
||||||
await this.driver.findWait('a[href*="login?"]', 4000).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill the login form (either on /test/login or Cognito).
|
// Fill the login form (either test or Grist).
|
||||||
if (await this.isOnTestLoginPage()) {
|
if (await this.isOnTestLoginPage()) {
|
||||||
await this.fillTestLoginForm(email, name);
|
await this.fillTestLoginForm(email, name);
|
||||||
} else {
|
} else {
|
||||||
await this.fillLoginForm(email);
|
await this.fillGristLoginForm(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await this.isWelcomePage()) && (options.freshAccount || options.isFirstLogin)) {
|
if (!await this.isWelcomePage() && (options.freshAccount || options.isFirstLogin)) {
|
||||||
await this._recreateCurrentUser(email, org, name);
|
await this._recreateCurrentUser(email, org, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,29 +145,43 @@ export class HomeUtil {
|
|||||||
await form.find('input[name="signInSubmitButton"]').click();
|
await form.find('input[name="signInSubmitButton"]').click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill up a Cognito login page. If on a signup page, switch to a login page.
|
/**
|
||||||
// TEST_ACCOUNT_PASSWORD must be set, or a password provided. Should be on a Cognito
|
* Fill up the Grist login page form, and submit. If called with a user that
|
||||||
// login/signup page before calling this method.
|
* has TOTP-based 2FA enabled, TEST_ACCOUNT_TOTP_SECRET must be set for a valid
|
||||||
public async fillLoginForm(email: string, password?: string) {
|
* code to be submitted on the following form.
|
||||||
|
*
|
||||||
|
* Should be on the Grist login or sign-up page before calling this method. If
|
||||||
|
* `password` is not passed in, TEST_ACCOUNT_PASSWORD must be set.
|
||||||
|
*/
|
||||||
|
public async fillGristLoginForm(email: string, password?: string) {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
password = process.env.TEST_ACCOUNT_PASSWORD;
|
password = process.env.TEST_ACCOUNT_PASSWORD;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
throw new Error('TEST_ACCOUNT_PASSWORD not set');
|
throw new Error('TEST_ACCOUNT_PASSWORD not set');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.checkLoginPage();
|
await this.checkGristLoginPage();
|
||||||
if ((await this.driver.getCurrentUrl()).match(/signup\?/)) {
|
if ((await this.driver.getCurrentUrl()).match(/signup\?/)) {
|
||||||
await this.driver.findWait('a[href*="login?"]', 4000).click();
|
await this.driver.findWait('a[href*="login?"]', 4000).click();
|
||||||
}
|
}
|
||||||
// There are two login forms, one hidden, one shown. Pick the one that is shown.
|
|
||||||
const block =
|
await this.driver.findWait('input[name="email"]', 4000).sendKeys(email);
|
||||||
(await this.driver.find('div.modal-content-desktop').isDisplayed()) ?
|
await this.driver.find('input[name="password"]').sendKeys(password);
|
||||||
(await this.driver.find('div.modal-content-desktop')) :
|
await this.driver.find('.test-lp-sign-in').click();
|
||||||
(await this.driver.find('div.modal-content-mobile'));
|
await this.driver.wait(async () => !await this.isOnGristLoginPage(), 4000);
|
||||||
await block.findWait('input[name="username"]', 4000);
|
if (!await this.driver.findContent('.test-mfa-title', 'Almost there!').isPresent()) {
|
||||||
await this.setValue(block.findWait('input[name="username"]', 4000), email);
|
return;
|
||||||
await this.setValue(block.findWait('input[name="password"]', 4000), password);
|
}
|
||||||
await block.find('input[name="signInSubmitButton"]').click();
|
|
||||||
|
const secret = process.env.TEST_ACCOUNT_TOTP_SECRET;
|
||||||
|
if (!secret) { throw new Error('TEST_ACCOUNT_TOTP_SECRET not set'); }
|
||||||
|
|
||||||
|
const code = authenticator.generate(secret);
|
||||||
|
await this.driver.find('input[name="verificationCode"]').sendKeys(code);
|
||||||
|
await this.driver.find('.test-mfa-submit').click();
|
||||||
|
await this.driver.wait(async () => {
|
||||||
|
return !await this.driver.findContent('.test-mfa-title', 'Almost there!').isPresent();
|
||||||
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -275,18 +286,19 @@ export class HomeUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether we are currently on the Cognito or test login page.
|
* Returns whether we are currently on any login page (including the test page).
|
||||||
*/
|
*/
|
||||||
public async isOnLoginPage() {
|
public async isOnLoginPage() {
|
||||||
const url = await this.driver.getCurrentUrl();
|
return await this.isOnGristLoginPage() || await this.isOnTestLoginPage();
|
||||||
return /^https:\/\/gristlogin/.test(url) || await this.isOnTestLoginPage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether we are currently on a Grist login page.
|
* Returns whether we are currently on a Grist login page.
|
||||||
*/
|
*/
|
||||||
public async isOnGristLoginPage() {
|
public async isOnGristLoginPage() {
|
||||||
return /^https:\/\/login(-s)?\.getgrist\.com/.test(await this.driver.getCurrentUrl());
|
const isOnSignupPage = await this.driver.find('.test-sp-heading').isPresent();
|
||||||
|
const isOnLoginPage = await this.driver.find('.test-lp-heading').isPresent();
|
||||||
|
return isOnSignupPage || isOnLoginPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -297,14 +309,7 @@ export class HomeUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether we are currently on any sign-in page (e.g. Cognito, Grist, test).
|
* Waits for browser to navigate to any login page (including the test page).
|
||||||
*/
|
|
||||||
public async isOnSigninPage() {
|
|
||||||
return await this.isOnLoginPage() || await this.isOnGristLoginPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waits for browser to navigate to the Cognito login page.
|
|
||||||
*/
|
*/
|
||||||
public async checkLoginPage(waitMs: number = 2000) {
|
public async checkLoginPage(waitMs: number = 2000) {
|
||||||
await this.driver.wait(this.isOnLoginPage.bind(this), waitMs);
|
await this.driver.wait(this.isOnLoginPage.bind(this), waitMs);
|
||||||
@ -317,13 +322,6 @@ export class HomeUtil {
|
|||||||
await this.driver.wait(this.isOnGristLoginPage.bind(this), waitMs);
|
await this.driver.wait(this.isOnGristLoginPage.bind(this), waitMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Waits for browser to navigate to any sign-in page (e.g. Cognito, Grist, test).
|
|
||||||
*/
|
|
||||||
public async checkSigninPage(waitMs: number = 4000) {
|
|
||||||
await this.driver.wait(this.isOnSigninPage.bind(this), waitMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete and recreate the user, via the specified org. The specified user must be
|
* Delete and recreate the user, via the specified org. The specified user must be
|
||||||
* currently logged in!
|
* currently logged in!
|
||||||
@ -335,11 +333,11 @@ export class HomeUtil {
|
|||||||
await this.driver.findWait('.test-user-signin', 4000).click();
|
await this.driver.findWait('.test-user-signin', 4000).click();
|
||||||
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
|
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
|
||||||
await this.checkLoginPage();
|
await this.checkLoginPage();
|
||||||
// Fill the login form (either on /test/login or Cognito).
|
// Fill the login form (either test or Grist).
|
||||||
if (await this.isOnTestLoginPage()) {
|
if (await this.isOnTestLoginPage()) {
|
||||||
await this.fillTestLoginForm(email, name);
|
await this.fillTestLoginForm(email, name);
|
||||||
} else {
|
} else {
|
||||||
await this.fillLoginForm(email);
|
await this.fillGristLoginForm(email);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
52
yarn.lock
52
yarn.lock
@ -85,6 +85,44 @@
|
|||||||
node-addon-api "2.0.0"
|
node-addon-api "2.0.0"
|
||||||
node-pre-gyp "^0.11.0"
|
node-pre-gyp "^0.11.0"
|
||||||
|
|
||||||
|
"@otplib/core@^12.0.1":
|
||||||
|
version "12.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@otplib/core/-/core-12.0.1.tgz#73720a8cedce211fe5b3f683cd5a9c098eaf0f8d"
|
||||||
|
integrity sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==
|
||||||
|
|
||||||
|
"@otplib/plugin-crypto@^12.0.1":
|
||||||
|
version "12.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz#2b42c624227f4f9303c1c041fca399eddcbae25e"
|
||||||
|
integrity sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core" "^12.0.1"
|
||||||
|
|
||||||
|
"@otplib/plugin-thirty-two@^12.0.1":
|
||||||
|
version "12.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz#5cc9b56e6e89f2a1fe4a2b38900ca4e11c87aa9e"
|
||||||
|
integrity sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core" "^12.0.1"
|
||||||
|
thirty-two "^1.0.2"
|
||||||
|
|
||||||
|
"@otplib/preset-default@^12.0.1":
|
||||||
|
version "12.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@otplib/preset-default/-/preset-default-12.0.1.tgz#cb596553c08251e71b187ada4a2246ad2a3165ba"
|
||||||
|
integrity sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core" "^12.0.1"
|
||||||
|
"@otplib/plugin-crypto" "^12.0.1"
|
||||||
|
"@otplib/plugin-thirty-two" "^12.0.1"
|
||||||
|
|
||||||
|
"@otplib/preset-v11@^12.0.1":
|
||||||
|
version "12.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@otplib/preset-v11/-/preset-v11-12.0.1.tgz#4c7266712e7230500b421ba89252963c838fc96d"
|
||||||
|
integrity sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core" "^12.0.1"
|
||||||
|
"@otplib/plugin-crypto" "^12.0.1"
|
||||||
|
"@otplib/plugin-thirty-two" "^12.0.1"
|
||||||
|
|
||||||
"@popperjs/core@2.3.3":
|
"@popperjs/core@2.3.3":
|
||||||
version "2.3.3"
|
version "2.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.3.3.tgz#8731722aeb7330e8fd9eb5d424be6b98dea7d6da"
|
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.3.3.tgz#8731722aeb7330e8fd9eb5d424be6b98dea7d6da"
|
||||||
@ -5293,6 +5331,15 @@ osenv@^0.1.4:
|
|||||||
os-homedir "^1.0.0"
|
os-homedir "^1.0.0"
|
||||||
os-tmpdir "^1.0.0"
|
os-tmpdir "^1.0.0"
|
||||||
|
|
||||||
|
otplib@12.0.1:
|
||||||
|
version "12.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/otplib/-/otplib-12.0.1.tgz#c1d3060ab7aadf041ed2960302f27095777d1f73"
|
||||||
|
integrity sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core" "^12.0.1"
|
||||||
|
"@otplib/preset-default" "^12.0.1"
|
||||||
|
"@otplib/preset-v11" "^12.0.1"
|
||||||
|
|
||||||
p-cancelable@^1.0.0:
|
p-cancelable@^1.0.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
|
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
|
||||||
@ -6946,6 +6993,11 @@ thenify-all@^1.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise "^1.0.0"
|
any-promise "^1.0.0"
|
||||||
|
|
||||||
|
thirty-two@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
|
||||||
|
integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno=
|
||||||
|
|
||||||
throttleit@0.0.2:
|
throttleit@0.0.2:
|
||||||
version "0.0.2"
|
version "0.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf"
|
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf"
|
||||||
|
Loading…
Reference in New Issue
Block a user