(core) Add new Grist sign-up page

Summary:
Available at login.getgrist.com/signup, the new sign-up page
includes similar options available on the hosted Cognito sign-up
page, such as support for registering with Google. All previous
redirects to Cognito for sign-up should now redirect to the new
Grist sign-up page.

Login is still handled with the hosted Cognito login page, and there
is a link to go there from the new sign-up page.

Test Plan: Browser, project and server tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3249
This commit is contained in:
George Gevoian
2022-02-10 22:03:30 -08:00
parent d51180d349
commit 99f3422217
19 changed files with 162 additions and 39 deletions

View File

@@ -1,3 +1,4 @@
import {ApiError} from 'app/common/ApiError';
import {BillingTask} from 'app/common/BillingAPI';
import {delay} from 'app/common/delay';
import {DocCreationInfo} from 'app/common/DocListAPI';
@@ -40,7 +41,7 @@ import {IBilling} from 'app/server/lib/IBilling';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import {INotifier} from 'app/server/lib/INotifier';
import * as log from 'app/server/lib/log';
import {getLoginSystem} from 'app/server/lib/logins';
import {getLoginSubdomain, getLoginSystem} from 'app/server/lib/logins';
import {IPermitStore} from 'app/server/lib/Permit';
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
@@ -237,6 +238,14 @@ export class FlexServer implements GristServer {
return this.server ? (this.server.address() as AddressInfo).port : this.port;
}
/**
* Get a url to an org that should be accessible by all signed-in users. For now, this
* returns the base URL of the personal org (typically docs[-s]).
*/
public getMergedOrgUrl(req: RequestWithLogin, pathname: string = '/'): string {
return this._getOrgRedirectUrl(req, this._dbManager.mergedOrgDomain(), pathname);
}
public getPermitStore(): IPermitStore {
if (!this._internalPermitStore) { throw new Error('no permit store available'); }
return this._internalPermitStore;
@@ -805,22 +814,50 @@ export class FlexServer implements GristServer {
// should be factored out of it.
this.addComm();
/**
* Gets the URL to redirect back to after successful sign-up or login.
*
* Note that in the test env, this will redirect further.
*/
const getNextUrl = async (mreq: RequestWithLogin): Promise<string> => {
// If a "next" query param is present, check if it's safe to use, and return it if so.
const next = optStringParam(mreq.query.next);
if (next) {
if (!(await this._hosts.isSafeRedirectUrl(next))) {
throw new ApiError('Invalid redirect URL', 400);
}
return next;
}
// Otherwise, return the "/" path on the request host. If the host is the Grist
// login host, return the "/" path on the merged org instead.
const loginSubdomain = getLoginSubdomain();
return !loginSubdomain || mreq.org !== loginSubdomain ?
getOrgUrl(mreq) : this.getMergedOrgUrl(mreq);
};
async function redirectToLoginOrSignup(
this: FlexServer, signUp: boolean|null, req: express.Request, resp: express.Response,
) {
const mreq = req as RequestWithLogin;
// If sign-up request is to the Grist login host, serve a sign-up page instead of redirecting.
const loginSubdomain = getLoginSubdomain();
if (signUp && loginSubdomain && mreq.org === loginSubdomain) {
return this._sendAppPage(req, resp, {path: 'login.html', status: 200, config: {}});
}
// 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.
forceSessionChange(mreq.session);
// Redirect to "/" on our requested hostname (in test env, this will redirect further)
const next = getOrgUrl(req);
if (signUp === null) {
// Like redirectToLogin in Authorizer, redirect to sign up if it doesn't look like the
// user has ever logged in on this browser.
signUp = (mreq.session.users === undefined);
}
const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl;
resp.redirect(await getRedirectUrl(req, new URL(next)));
resp.redirect(await getRedirectUrl(req, new URL(await getNextUrl(mreq))));
}
this.app.get('/login', expressWrap(redirectToLoginOrSignup.bind(this, false)));
@@ -899,10 +936,9 @@ export class FlexServer implements GristServer {
this.app.get('/signed-out', expressWrap((req, resp) =>
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'signed-out'}})));
// Add a static "verified" page. This is where an email verification link will land,
// on success. TODO: rename simple static pages from "error" to something more generic.
// Add a static "verified" page. This is where an email verification link will land, on success.
this.app.get('/verified', expressWrap((req, resp) =>
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'verified'}})));
this._sendAppPage(req, resp, {path: 'login.html', status: 200, config: {}})));
const comment = await this._loginMiddleware.addEndpoints(this.app);
this.info.push(['loginMiddlewareComment', comment]);
@@ -1127,8 +1163,7 @@ export class FlexServer implements GristServer {
redirectPath = '/welcome/teams';
}
const mergedOrgDomain = this._dbManager.mergedOrgDomain();
const redirectUrl = this._getOrgRedirectUrl(mreq, mergedOrgDomain, redirectPath);
const redirectUrl = this.getMergedOrgUrl(mreq, redirectPath);
resp.json({redirectUrl});
}),
// Add a final error handler that reports errors as JSON.
@@ -1466,7 +1501,7 @@ export class FlexServer implements GristServer {
// Redirect anonymous users to the merged org.
if (!mreq.userIsAuthorized) {
const redirectUrl = this._getOrgRedirectUrl(mreq, this._dbManager.mergedOrgDomain());
const redirectUrl = this.getMergedOrgUrl(mreq);
log.debug(`Redirecting anonymous user to: ${redirectUrl}`);
return resp.redirect(redirectUrl);
}

View File

@@ -4,6 +4,7 @@ import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization';
import { Workspace } from 'app/gen-server/entity/Workspace';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { RequestWithLogin } from 'app/server/lib/Authorizer';
import * as Comm from 'app/server/lib/Comm';
import { Hosts } from 'app/server/lib/extractOrg';
import { ICreate } from 'app/server/lib/ICreate';
@@ -23,6 +24,7 @@ export interface GristServer {
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
getDocUrl(docId: string): Promise<string>;
getOrgUrl(orgKey: string|number): Promise<string>;
getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string;
getResourceUrl(resource: Organization|Workspace|Document): Promise<string>;
getGristConfig(): GristLoadConfig;
getPermitStore(): IPermitStore;

View File

@@ -29,8 +29,8 @@ const buildJsonErrorHandler = (options: JsonErrorHandlerOptions = {}): express.E
return (err, req, res, _next) => {
const mreq = req as RequestWithLogin;
log.warn(
"Error during api call to %s: (%s) user %d%s%s",
req.path, err.message, mreq.userId,
"Error during api call to %s: (%s)%s%s%s",
req.path, err.message, mreq.userId !== undefined ? ` user ${mreq.userId}` : '',
options.shouldLogParams !== false ? ` params ${JSON.stringify(req.params)}` : '',
options.shouldLogBody !== false ? ` body ${JSON.stringify(req.body)}` : '',
);

View File

@@ -131,6 +131,27 @@ export class Hosts {
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() {
this._host2org.clear();
this._org2host.clear();