diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index a9271ee8..d1216898 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -52,6 +52,11 @@ export function getLoginUrl(nextUrl: string = _getCurrentUrl()): string { return _getLoginLogoutUrl('login', nextUrl); } +// Get url for the signup page, which will then redirect to nextUrl (current page by default). +export function getSignupUrl(nextUrl: string = _getCurrentUrl()): string { + return _getLoginLogoutUrl('signup', nextUrl); +} + // Get url for the logout page, which will then redirect to nextUrl (signed-out page by default). export function getLogoutUrl(nextUrl: string = getSignedOutUrl()): string { return _getLoginLogoutUrl('logout', nextUrl); @@ -79,7 +84,7 @@ function _getCurrentUrl(): string { } // Helper for getLoginUrl()/getLogoutUrl(). -function _getLoginLogoutUrl(method: 'login'|'logout'|'signin', nextUrl: string): string { +function _getLoginLogoutUrl(method: 'login'|'logout'|'signin'|'signup', nextUrl: string): string { const startUrl = new URL(window.location.href); startUrl.pathname = '/' + method; startUrl.searchParams.set('next', nextUrl); diff --git a/app/client/ui/WelcomePage.ts b/app/client/ui/WelcomePage.ts index 0761d3a2..50bd2d68 100644 --- a/app/client/ui/WelcomePage.ts +++ b/app/client/ui/WelcomePage.ts @@ -2,20 +2,29 @@ import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder import { submitForm } from "app/client/lib/uploads"; import { AppModel, reportError } from "app/client/models/AppModel"; -import { urlState } from "app/client/models/gristUrlState"; +import { getLoginUrl, getSignupUrl, urlState } from "app/client/models/gristUrlState"; import { AccountWidget } from "app/client/ui/AccountWidget"; import { appHeader } from 'app/client/ui/AppHeader'; import * as BillingPageCss from "app/client/ui/BillingPageCss"; import * as forms from "app/client/ui/forms"; import { pagePanels } from "app/client/ui/PagePanels"; -import { bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink, cssButton } from "app/client/ui2018/buttons"; +import { bigBasicButton, bigBasicButtonLink, bigPrimaryButton, bigPrimaryButtonLink, + cssButton } from "app/client/ui2018/buttons"; import { colors, mediaSmall, testId, vars } from "app/client/ui2018/cssVars"; import { getOrgName, Organization } from "app/common/UserAPI"; -async function _submitForm(form: HTMLFormElement, pending: Observable) { - if (pending.get()) { return; } - pending.set(true); - const result = await submitForm(form).finally(() => pending.set(false)); +// Redirect from ..../welcome/thing to .../welcome/${name} +function _redirectToSiblingPage(name: string) { + const url = new URL(location.href); + const parts = url.pathname.split('/'); + parts.pop(); + parts.push(name); + url.pathname = parts.join('/'); + window.location.assign(url.href); +} + +// Redirect to result.redirectUrl is set, otherwise fail +function _redirectOnSuccess(result: any) { const redirectUrl = result.redirectUrl; if (!redirectUrl) { throw new Error('form failed to redirect'); @@ -23,11 +32,28 @@ async function _submitForm(form: HTMLFormElement, pending: Observable) window.location.assign(redirectUrl); } + +async function _submitForm(form: HTMLFormElement, pending: Observable, + onSuccess: (v: any) => void = _redirectOnSuccess, + onError: (e: Error) => void = reportError) { + try { + if (pending.get()) { return; } + pending.set(true); + const result = await submitForm(form).finally(() => pending.set(false)); + onSuccess(result); + } catch (err) { + onError(err?.details?.userError || err); + } +} + // If a 'pending' observable is given, it will be set to true while waiting for the submission. -function handleSubmit(pending: Observable): (elem: HTMLFormElement) => void { +function handleSubmit(pending: Observable, + onSuccess?: (v: any) => void, + onError?: (e: Error) => void): (elem: HTMLFormElement) => void { return dom.on('submit', async (e, form) => { e.preventDefault(); - _submitForm(form, pending).catch(reportError); + // TODO: catch isn't needed, so either remove or propagate errors from _submitForm. + _submitForm(form, pending, onSuccess, onError).catch(reportError); }); } @@ -61,6 +87,8 @@ export class WelcomePage extends Disposable { testId('welcome-page'), domComputed(urlState().state, (state) => ( + state.welcome === 'signup' ? dom.create(this._buildSignupForm.bind(this)) : + state.welcome === 'verify' ? dom.create(this._buildVerifyForm.bind(this)) : state.welcome === 'user' ? dom.create(this._buildNameForm.bind(this)) : state.welcome === 'info' ? dom.create(this._buildInfoForm.bind(this)) : state.welcome === 'teams' ? dom.create(this._buildOrgPicker.bind(this)) : @@ -87,6 +115,7 @@ export class WelcomePage extends Disposable { inputEl = cssInput( value, { onInput: true, }, { name: "username" }, + // TODO: catch isn't needed, so either remove or propagate errors from _submitForm. dom.onKeyDown({Enter: () => isNameValid.get() && _submitForm(form, pending).catch(reportError)}), ), dom.maybe((use) => use(value) && !use(isNameValid), buildNameWarningsDom), @@ -100,6 +129,123 @@ export class WelcomePage extends Disposable { ); } + private _buildSignupForm(owner: MultiHolder) { + let inputEl: HTMLInputElement; + const pending = Observable.create(owner, false); + + // delayed focus + setTimeout(() => inputEl.focus(), 10); + + // We expect to have an email query parameter on welcome/signup. + // TODO: make form work without email parameter - except the real todo is: + // TODO: replace this form with Amplify. + const url = new URL(location.href); + const email = Observable.create(owner, url.searchParams.get('email') || ''); + const password = Observable.create(owner, ''); + + const action = new URL(window.location.href); + action.pathname = '/signup/register'; + + return dom( + 'form', + { method: "post", action: action.href }, + handleSubmit(pending, () => _redirectToSiblingPage('verify')), + dom('p', + `Welcome Sumo-ling! ` + // This flow currently only used with AppSumo. + `Your Grist site is almost ready. Let's get your account set up and verified. ` + + `If you already have a Grist account as `, + dom('b', email.get()), + ` you can just `, + dom('a', {href: getLoginUrl(urlState().makeUrl({}))}, 'log in'), + ` now. Otherwise, please pick a password.` + ), + cssSeparatedLabel('The email address you activated Grist with:'), + cssInput( + email, { onInput: true, }, + { name: "emailShow" }, + dom.boolAttr('disabled', true), + dom.attr('type', 'email'), + ), + // Duplicate email as a hidden form since disabled input won't get submitted + // for some reason. + cssInput( + email, { onInput: true, }, + { name: "email" }, + dom.boolAttr('hidden', true), + dom.attr('type', 'email'), + ), + cssSeparatedLabel('A password to use with Grist:'), + inputEl = cssInput( + password, { onInput: true, }, + { name: "password" }, + dom.attr('type', 'password'), + ), + cssButtonGroup( + bigPrimaryButton( + 'Continue', + testId('continue-button') + ), + bigBasicButtonLink('Did this already', dom.on('click', () => { + _redirectToSiblingPage('verify'); + })) + ), + ); + } + + private _buildVerifyForm(owner: MultiHolder) { + let inputEl: HTMLInputElement; + const pending = Observable.create(owner, false); + + // delayed focus + setTimeout(() => inputEl.focus(), 10); + + const action = new URL(window.location.href); + action.pathname = '/signup/verify'; + + const url = new URL(location.href); + const email = Observable.create(owner, url.searchParams.get('email') || ''); + const code = Observable.create(owner, url.searchParams.get('code') || ''); + return dom( + 'form', + { method: "post", action: action.href }, + handleSubmit(pending, (result) => { + if (result.act === 'confirmed') { + const verified = new URL(window.location.href); + verified.pathname = '/verified'; + window.location.assign(verified.href); + } else if (result.act === 'resent') { + // just to give a sense that something happened... + window.location.reload(); + } + }), + dom('p', + `Please check your email for a 6-digit verification code, and enter it here.`), + dom('p', + `If you've any trouble, try our full set of sign-up options. Do take care to use ` + + `the email address you activated with: `, + dom('b', email.get())), + cssSeparatedLabel('Confirmation code'), + inputEl = cssInput( + code, { onInput: true, }, + { name: "code" }, + dom.attr('type', 'number'), + ), + cssInput( + email, { onInput: true, }, + { name: "email" }, + dom.boolAttr('hidden', true), + ), + cssButtonGroup( + bigPrimaryButton( + dom.domComputed(code, c => c ? + 'Apply verification code' : 'Resend verification email') + ), + bigBasicButtonLink('More sign-up options', + {href: getSignupUrl()}) + ) + ); + } + /** * Builds a form to ask the new user a few questions. */ @@ -235,11 +381,15 @@ const textStyle = ` `; const cssLabel = styled('label', textStyle); +// TODO: there's probably a much better way to style labels with a bit of +// space between them and things they are not the label for? +const cssSeparatedLabel = styled('label', textStyle + ' margin-top: 20px;'); const cssParagraph = styled('p', textStyle); const cssButtonGroup = styled('div', ` margin-top: 24px; display: flex; + justify-content: space-evenly; &-right { justify-content: flex-end; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 2ee9b55b..c3d8f210 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -14,7 +14,7 @@ export type IDocPage = number | 'new' | 'code' | 'acl'; export const HomePage = StringUnion('all', 'workspace', 'trash'); export type IHomePage = typeof HomePage.type; -export const WelcomePage = StringUnion('user', 'info', 'teams'); +export const WelcomePage = StringUnion('user', 'info', 'teams', 'signup', 'verify'); export type WelcomePage = typeof WelcomePage.type; // Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience. diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 10dd06a8..932c5ac1 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -989,6 +989,11 @@ export class FlexServer implements GristServer { this._redirectToLoginWithoutExceptionsMiddleware, ]; + // These are some special-purpose pre-sign-up welcome pages, with no middleware. + this.app.get(['/welcome/signup', '/welcome/verify'], expressWrap(async (req, resp, next) => { + return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}, googleTagManager: true}); + })); + this.app.get('/welcome/:page', ...middleware, expressWrap(async (req, resp, next) => { return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}, googleTagManager: true}); }));