mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -65,8 +65,8 @@ export function getMainOrgUrl(): string { return urlState().makeUrl({}); }
|
||||
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).
|
||||
export function getLoginUrl(nextUrl: string = _getCurrentUrl()): string {
|
||||
return _getLoginLogoutUrl('login', nextUrl);
|
||||
export function getLoginUrl(nextUrl: string | null = _getCurrentUrl()): string {
|
||||
return _getLoginLogoutUrl('login', nextUrl ?? undefined);
|
||||
}
|
||||
|
||||
// Get url for the signup page, which will then redirect to nextUrl (current page by default).
|
||||
@@ -101,10 +101,10 @@ function _getCurrentUrl(): string {
|
||||
}
|
||||
|
||||
// Helper for getLoginUrl()/getLogoutUrl().
|
||||
function _getLoginLogoutUrl(method: 'login'|'logout'|'signin'|'signup', nextUrl: string): string {
|
||||
function _getLoginLogoutUrl(method: 'login'|'logout'|'signin'|'signup', nextUrl?: string): string {
|
||||
const startUrl = new URL(window.location.href);
|
||||
startUrl.pathname = addOrgToPath('', window.location.href) + '/' + method;
|
||||
startUrl.searchParams.set('next', nextUrl);
|
||||
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
|
||||
return startUrl.href;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export class AccountWidget extends Disposable {
|
||||
cssEmail(user.email, testId('usermenu-email'))
|
||||
)
|
||||
),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'profile'}), 'Profile Settings'),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), 'Profile Settings'),
|
||||
|
||||
documentSettingsItem,
|
||||
|
||||
|
||||
@@ -196,11 +196,11 @@ export class WelcomePage extends Disposable {
|
||||
'form',
|
||||
{ method: "post", action: action.href },
|
||||
handleSubmitForm(pending, (result) => {
|
||||
if (result.act === 'confirmed') {
|
||||
if (result.status === 'confirmed') {
|
||||
const verified = new URL(window.location.href);
|
||||
verified.pathname = '/verified';
|
||||
window.location.assign(verified.href);
|
||||
} else if (result.act === 'resent') {
|
||||
} else if (result.status === 'resent') {
|
||||
// just to give a sense that something happened...
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
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 {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
@@ -15,7 +15,6 @@ export function createErrPage(appModel: AppModel) {
|
||||
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
|
||||
const message = gristConfig.errMessage;
|
||||
return gristConfig.errPage === 'signed-out' ? createSignedOutPage(appModel) :
|
||||
gristConfig.errPage === 'verified' ? createVerifiedPage(appModel) :
|
||||
gristConfig.errPage === 'not-found' ? createNotFoundPage(appModel, message) :
|
||||
gristConfig.errPage === 'access-denied' ? createForbiddenPage(appModel, message) :
|
||||
createOtherErrorPage(appModel, message);
|
||||
@@ -56,18 +55,6 @@ export function createSignedOutPage(appModel: AppModel) {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a page that shows the user is verified.
|
||||
*/
|
||||
export function createVerifiedPage(appModel: AppModel) {
|
||||
return pagePanelsError(appModel, 'Verified', [
|
||||
cssErrorText("Your email is now verified."),
|
||||
cssButtonWrap(bigPrimaryButtonLink(
|
||||
'Sign in', {href: getLoginUrl(getMainOrgUrl())}, testId('error-signin')
|
||||
))
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a "Page not found" page.
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,10 @@ export type IconName = "ChartArea" |
|
||||
"FieldText" |
|
||||
"FieldTextbox" |
|
||||
"FieldToggle" |
|
||||
"LoginStreamline" |
|
||||
"LoginUnify" |
|
||||
"LoginVisualize" |
|
||||
"GoogleLogo" |
|
||||
"GristLogo" |
|
||||
"ThumbPreview" |
|
||||
"BarcodeQR" |
|
||||
@@ -143,6 +147,10 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"FieldText",
|
||||
"FieldTextbox",
|
||||
"FieldToggle",
|
||||
"LoginStreamline",
|
||||
"LoginUnify",
|
||||
"LoginVisualize",
|
||||
"GoogleLogo",
|
||||
"GristLogo",
|
||||
"ThumbPreview",
|
||||
"BarcodeQR",
|
||||
|
||||
@@ -164,10 +164,12 @@ export const testId: TestId = makeTestId('test-');
|
||||
|
||||
// Min width for normal screen layout (in px). Note: <768px is bootstrap's definition of small
|
||||
// screen (covers phones, including landscape, but not tablets).
|
||||
const largeScreenWidth = 992;
|
||||
const mediumScreenWidth = 768;
|
||||
const smallScreenWidth = 576; // Anything below this is extra-small (e.g. portrait phones).
|
||||
|
||||
// Fractional width for max-query follows https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints
|
||||
export const mediaMedium = `(max-width: ${largeScreenWidth - 0.02}px)`;
|
||||
export const mediaSmall = `(max-width: ${mediumScreenWidth - 0.02}px)`;
|
||||
export const mediaNotSmall = `(min-width: ${mediumScreenWidth}px)`;
|
||||
export const mediaXSmall = `(max-width: ${smallScreenWidth - 0.02}px)`;
|
||||
|
||||
@@ -22,9 +22,12 @@ export type IHomePage = typeof HomePage.type;
|
||||
export const WelcomePage = StringUnion('user', 'info', 'teams', 'signup', 'verify', 'select-account');
|
||||
export type WelcomePage = typeof WelcomePage.type;
|
||||
|
||||
export const AccountPage = StringUnion('profile');
|
||||
export const AccountPage = StringUnion('account');
|
||||
export type AccountPage = typeof AccountPage.type;
|
||||
|
||||
export const LoginPage = StringUnion('signup', 'verified');
|
||||
export type LoginPage = typeof LoginPage.type;
|
||||
|
||||
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
|
||||
export const InterfaceStyle = StringUnion('light', 'full');
|
||||
export type InterfaceStyle = typeof InterfaceStyle.type;
|
||||
@@ -68,6 +71,7 @@ export interface IGristUrlState {
|
||||
docPage?: IDocPage;
|
||||
account?: AccountPage;
|
||||
billing?: BillingPage;
|
||||
login?: LoginPage;
|
||||
welcome?: WelcomePage;
|
||||
welcomeTour?: boolean;
|
||||
docTour?: boolean;
|
||||
@@ -76,6 +80,7 @@ export interface IGristUrlState {
|
||||
billingPlan?: string;
|
||||
billingTask?: BillingTask;
|
||||
embed?: boolean;
|
||||
next?: string;
|
||||
style?: InterfaceStyle;
|
||||
compare?: string;
|
||||
linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs.
|
||||
@@ -188,12 +193,16 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
||||
parts.push(`p/${state.homePage}`);
|
||||
}
|
||||
|
||||
if (state.account) { parts.push('account'); }
|
||||
if (state.account) {
|
||||
parts.push(state.account === 'account' ? 'account' : `account/${state.account}`);
|
||||
}
|
||||
|
||||
if (state.billing) {
|
||||
parts.push(state.billing === 'billing' ? 'billing' : `billing/${state.billing}`);
|
||||
}
|
||||
|
||||
if (state.login) { parts.push(state.login); }
|
||||
|
||||
if (state.welcome) {
|
||||
parts.push(`welcome/${state.welcome}`);
|
||||
}
|
||||
@@ -274,13 +283,22 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
||||
}
|
||||
}
|
||||
if (map.has('m')) { state.mode = OpenDocMode.parse(map.get('m')); }
|
||||
if (map.has('account')) { state.account = AccountPage.parse('account') || 'profile'; }
|
||||
if (map.has('account')) { state.account = AccountPage.parse(map.get('account')) || 'account'; }
|
||||
if (map.has('billing')) { state.billing = BillingSubPage.parse(map.get('billing')) || 'billing'; }
|
||||
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')) || 'user'; }
|
||||
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
|
||||
if (sp.has('billingTask')) {
|
||||
state.params!.billingTask = BillingTask.parse(sp.get('billingTask'));
|
||||
}
|
||||
|
||||
if (map.has('signup')) {
|
||||
state.login = 'signup';
|
||||
} else if (map.has('verified')) {
|
||||
state.login = 'verified';
|
||||
}
|
||||
|
||||
if (sp.has('next')) { state.params!.next = sp.get('next')!; }
|
||||
|
||||
if (sp.has('style')) {
|
||||
state.params!.style = InterfaceStyle.parse(sp.get('style'));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}` : '',
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user