mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add Grist forgot password page
Summary: The page isn't yet linked to from anywhere in the UI, but will be soon, once the new login page is ready. The page can still be accessed at login-[s].getgrist.com/forgot-password, and the flow is similar to the one used by Cognito's hosted UI. Also refactors much of the existing login app code into smaller files with less duplication, tweaks password validation to be closer to Cognito's requirements, and polishes various parts of the UI, like the verified page CSS, and the form inputs. Test Plan: Browser, server and project tests. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3296
This commit is contained in:
parent
aa3fe975e7
commit
9522438967
@ -103,7 +103,7 @@ function _getCurrentUrl(): string {
|
|||||||
// Helper for getLoginUrl()/getLogoutUrl().
|
// 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);
|
const startUrl = new URL(window.location.href);
|
||||||
startUrl.pathname = addOrgToPath('', window.location.href) + '/' + method;
|
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + method;
|
||||||
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
|
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
|
||||||
return startUrl.href;
|
return startUrl.href;
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,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');
|
export const LoginPage = StringUnion('signup', '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.
|
||||||
@ -252,7 +252,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
|||||||
// the minimum length of a urlId prefix is longer than the maximum length
|
// the minimum length of a urlId prefix is longer than the maximum length
|
||||||
// of any of the valid keys in the url.
|
// of any of the valid keys in the url.
|
||||||
for (const key of map.keys()) {
|
for (const key of map.keys()) {
|
||||||
if (key.length >= MIN_URLID_PREFIX_LENGTH) {
|
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
|
||||||
map.set('doc', key);
|
map.set('doc', key);
|
||||||
map.set('slug', map.get(key)!);
|
map.set('slug', map.get(key)!);
|
||||||
map.delete(key);
|
map.delete(key);
|
||||||
@ -296,6 +296,8 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
|||||||
state.login = 'signup';
|
state.login = 'signup';
|
||||||
} else if (map.has('verified')) {
|
} else if (map.has('verified')) {
|
||||||
state.login = 'verified';
|
state.login = 'verified';
|
||||||
|
} else if (map.has('forgot-password')) {
|
||||||
|
state.login = 'forgot-password';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sp.has('next')) { state.params!.next = sp.get('next')!; }
|
if (sp.has('next')) { state.params!.next = sp.get('next')!; }
|
||||||
@ -522,7 +524,7 @@ export function isClient() {
|
|||||||
export function getKnownOrg(): string|null {
|
export function getKnownOrg(): string|null {
|
||||||
if (isClient()) {
|
if (isClient()) {
|
||||||
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
||||||
return (gristConfig && gristConfig.org) || null;
|
return (gristConfig && gristConfig.singleOrg) || null;
|
||||||
} else {
|
} else {
|
||||||
return process.env.GRIST_SINGLE_ORG || null;
|
return process.env.GRIST_SINGLE_ORG || null;
|
||||||
}
|
}
|
||||||
@ -545,7 +547,7 @@ export function getSingleOrg(): string|null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if org must be encoded in path, not in domain. Determined from
|
* Returns true if org must be encoded in path, not in domain. Determined from
|
||||||
* gristConfig on the client. On on the server returns true if the host is
|
* gristConfig on the client. On the server, returns true if the host is
|
||||||
* supplied and is 'localhost', or if GRIST_ORG_IN_PATH is set to 'true'.
|
* supplied and is 'localhost', or if GRIST_ORG_IN_PATH is set to 'true'.
|
||||||
*/
|
*/
|
||||||
export function isOrgInPathOnly(host?: string): boolean {
|
export function isOrgInPathOnly(host?: string): boolean {
|
||||||
|
@ -41,14 +41,13 @@ import {IBilling} from 'app/server/lib/IBilling';
|
|||||||
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
|
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
|
||||||
import {INotifier} from 'app/server/lib/INotifier';
|
import {INotifier} from 'app/server/lib/INotifier';
|
||||||
import * as log from 'app/server/lib/log';
|
import * as log from 'app/server/lib/log';
|
||||||
import {getLoginSubdomain, getLoginSystem} from 'app/server/lib/logins';
|
import {getLoginSystem} from 'app/server/lib/logins';
|
||||||
import {IPermitStore} from 'app/server/lib/Permit';
|
import {IPermitStore} from 'app/server/lib/Permit';
|
||||||
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
|
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
|
||||||
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
|
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
|
||||||
import {PluginManager} from 'app/server/lib/PluginManager';
|
import {PluginManager} from 'app/server/lib/PluginManager';
|
||||||
import {adaptServerUrl, addOrgToPath, addOrgToPathIfNeeded, addPermit, getScope,
|
import {adaptServerUrl, addOrgToPath, addPermit, getOrgUrl, getScope, optStringParam,
|
||||||
optStringParam, RequestWithGristInfo, stringParam,
|
RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
|
||||||
TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
|
|
||||||
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
|
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
|
||||||
import {getDatabaseUrl} from 'app/server/lib/serverUtils';
|
import {getDatabaseUrl} from 'app/server/lib/serverUtils';
|
||||||
import {Sessions} from 'app/server/lib/Sessions';
|
import {Sessions} from 'app/server/lib/Sessions';
|
||||||
@ -291,6 +290,11 @@ export class FlexServer implements GristServer {
|
|||||||
return this._notifier;
|
return this._notifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void> {
|
||||||
|
if (!this._sendAppPage) { throw new Error('no _sendAppPage method available'); }
|
||||||
|
return this._sendAppPage(req, resp, options);
|
||||||
|
}
|
||||||
|
|
||||||
public addLogging() {
|
public addLogging() {
|
||||||
if (this._check('logging')) { return; }
|
if (this._check('logging')) { return; }
|
||||||
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
|
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
|
||||||
@ -836,26 +840,22 @@ export class FlexServer implements GristServer {
|
|||||||
this.addComm();
|
this.addComm();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the URL to redirect back to after successful sign-up or login.
|
* Gets the URL to redirect back to after successful sign-up, login, or logout.
|
||||||
*
|
*
|
||||||
* Note that in the test env, this will redirect further.
|
* Note that in the test env, this will redirect further.
|
||||||
*/
|
*/
|
||||||
const getNextUrl = async (mreq: RequestWithLogin): Promise<string> => {
|
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);
|
const next = optStringParam(mreq.query.next);
|
||||||
if (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))) {
|
if (!(await this._hosts.isSafeRedirectUrl(next))) {
|
||||||
throw new ApiError('Invalid redirect URL', 400);
|
throw new ApiError('Invalid redirect URL', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
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(
|
async function redirectToLoginOrSignup(
|
||||||
@ -863,12 +863,6 @@ export class FlexServer implements GristServer {
|
|||||||
) {
|
) {
|
||||||
const mreq = req as RequestWithLogin;
|
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 -
|
// 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 come back from Cognito.
|
||||||
forceSessionChange(mreq.session);
|
forceSessionChange(mreq.session);
|
||||||
@ -881,9 +875,13 @@ export class FlexServer implements GristServer {
|
|||||||
resp.redirect(await getRedirectUrl(req, new URL(await getNextUrl(mreq))));
|
resp.redirect(await getRedirectUrl(req, new URL(await getNextUrl(mreq))));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.app.get('/login', expressWrap(redirectToLoginOrSignup.bind(this, false)));
|
const middleware = this._loginMiddleware.getLoginOrSignUpMiddleware ?
|
||||||
this.app.get('/signup', expressWrap(redirectToLoginOrSignup.bind(this, true)));
|
this._loginMiddleware.getLoginOrSignUpMiddleware() :
|
||||||
this.app.get('/signin', expressWrap(redirectToLoginOrSignup.bind(this, null)));
|
[];
|
||||||
|
|
||||||
|
this.app.get('/login', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, false)));
|
||||||
|
this.app.get('/signup', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, true)));
|
||||||
|
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.
|
||||||
@ -935,16 +933,14 @@ export class FlexServer implements GristServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.app.get('/logout', expressWrap(async (req, resp) => {
|
this.app.get('/logout', expressWrap(async (req, resp) => {
|
||||||
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
|
const mreq = req as RequestWithLogin;
|
||||||
|
const scopedSession = this._sessions.getOrCreateSessionFromRequest(mreq);
|
||||||
// If 'next' param is missing, redirect to "/" on our requested hostname.
|
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(await getNextUrl(mreq)));
|
||||||
const next = optStringParam(req.query.next) || getOrgUrl(req);
|
|
||||||
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(next));
|
|
||||||
|
|
||||||
// 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 = (req as any).session;
|
const expressSession = mreq.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.
|
||||||
@ -957,10 +953,6 @@ export class FlexServer implements GristServer {
|
|||||||
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'}})));
|
||||||
|
|
||||||
// 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: 'login.html', status: 200, config: {}})));
|
|
||||||
|
|
||||||
const comment = await this._loginMiddleware.addEndpoints(this.app);
|
const comment = await this._loginMiddleware.addEndpoints(this.app);
|
||||||
this.info.push(['loginMiddlewareComment', comment]);
|
this.info.push(['loginMiddlewareComment', comment]);
|
||||||
|
|
||||||
@ -1714,11 +1706,6 @@ function trustOriginHandler(req: express.Request, res: express.Response, next: e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get url to the org associated with the request.
|
|
||||||
function getOrgUrl(req: express.Request) {
|
|
||||||
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Cache-Control header to "no-cache"
|
// Set Cache-Control header to "no-cache"
|
||||||
function noCaching(req: express.Request, res: express.Response, next: express.NextFunction) {
|
function noCaching(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
res.header("Cache-Control", "no-cache");
|
res.header("Cache-Control", "no-cache");
|
||||||
|
@ -11,6 +11,7 @@ import { ICreate } from 'app/server/lib/ICreate';
|
|||||||
import { IDocStorageManager } from 'app/server/lib/IDocStorageManager';
|
import { IDocStorageManager } from 'app/server/lib/IDocStorageManager';
|
||||||
import { INotifier } from 'app/server/lib/INotifier';
|
import { INotifier } from 'app/server/lib/INotifier';
|
||||||
import { IPermitStore } from 'app/server/lib/Permit';
|
import { IPermitStore } from 'app/server/lib/Permit';
|
||||||
|
import { ISendAppPageOptions } from 'app/server/lib/sendAppPage';
|
||||||
import { Sessions } from 'app/server/lib/Sessions';
|
import { Sessions } from 'app/server/lib/Sessions';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ export interface GristServer {
|
|||||||
getNotifier(): INotifier;
|
getNotifier(): INotifier;
|
||||||
getDocTemplate(): Promise<DocTemplate>;
|
getDocTemplate(): Promise<DocTemplate>;
|
||||||
getTag(): string;
|
getTag(): string;
|
||||||
|
sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GristLoginSystem {
|
export interface GristLoginSystem {
|
||||||
@ -51,6 +53,9 @@ export interface GristLoginMiddleware {
|
|||||||
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.
|
||||||
|
getLoginOrSignUpMiddleware?(): express.RequestHandler[];
|
||||||
|
|
||||||
// Returns arbitrary string for log.
|
// Returns arbitrary string for log.
|
||||||
addEndpoints(app: express.Express): Promise<string>;
|
addEndpoints(app: express.Express): Promise<string>;
|
||||||
}
|
}
|
||||||
|
@ -64,6 +64,13 @@ export function addOrgToPath(req: RequestWithOrg, path: string): string {
|
|||||||
return req.org ? `/o/${req.org}${path}` : path;
|
return req.org ? `/o/${req.org}${path}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get url to the org associated with the request.
|
||||||
|
*/
|
||||||
|
export function getOrgUrl(req: Request) {
|
||||||
|
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, '/');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true for requests from permitted origins. For such requests, an
|
* Returns true for requests from permitted origins. For such requests, an
|
||||||
* "Access-Control-Allow-Origin" header is added to the response. Vary: Origin
|
* "Access-Control-Allow-Origin" header is added to the response. Vary: Origin
|
||||||
|
@ -7,7 +7,3 @@ export async function getLoginSystem(): Promise<GristLoginSystem> {
|
|||||||
if (saml) { return saml; }
|
if (saml) { return saml; }
|
||||||
return getMinimalLoginSystem();
|
return getMinimalLoginSystem();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLoginSubdomain(): string | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user