import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs"; import { submitForm } from "app/client/lib/uploads"; import { AppModel, reportError } from "app/client/models/AppModel"; 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 { createUserImage } from 'app/client/ui/UserImage'; import { cssMemberImage, cssMemberListItem, cssMemberPrimary, cssMemberSecondary, cssMemberText } from 'app/client/ui/UserItem'; import { basicButtonLink, 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"; // 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'); } 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, onSuccess?: (v: any) => void, onError?: (e: Error) => void): (elem: HTMLFormElement) => void { return dom.on('submit', async (e, form) => { e.preventDefault(); // TODO: catch isn't needed, so either remove or propagate errors from _submitForm. _submitForm(form, pending, onSuccess, onError).catch(reportError); }); } export class WelcomePage extends Disposable { private _currentUserName = this._appModel.currentUser && this._appModel.currentUser.name || ''; private _orgs: Organization[]; private _orgsLoaded = Observable.create(this, false); constructor(private _appModel: AppModel) { super(); } public buildDom() { return pagePanels({ leftPanel: { panelWidth: Observable.create(this, 240), panelOpen: Observable.create(this, false), hideOpener: true, header: dom.create(AppHeader, '', this._appModel), content: null, }, headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)], contentMain: this.buildPageContent() }); } public buildPageContent(): Element { return cssScrollContainer(cssContainer( cssTitle('Welcome to Grist'), 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)) : state.welcome === 'select-account' ? dom.create(this._buildAccountPicker.bind(this)) : null )), )); } private _buildNameForm(owner: MultiHolder) { let inputEl: HTMLInputElement; let form: HTMLFormElement; const value = Observable.create(owner, checkName(this._currentUserName) ? this._currentUserName : ''); const isNameValid = Computed.create(owner, value, (use, val) => checkName(val)); const pending = Observable.create(owner, false); // delayed focus setTimeout(() => inputEl.focus(), 10); return form = dom( 'form', { method: "post", action: location.href }, handleSubmit(pending), cssLabel('Your full name, as you\'d like it displayed to your collaborators.'), 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), cssButtonGroup( bigPrimaryButton( 'Continue', dom.boolAttr('disabled', (use) => Boolean(use(value) && !use(isNameValid)) || use(pending)), testId('continue-button') ), ) ); } 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. */ private _buildInfoForm(owner: MultiHolder) { const allFilled = Observable.create(owner, false); const whereObs = Observable.create(owner, ''); const pending = Observable.create(owner, false); return forms.form({method: "post", action: location.href }, handleSubmit(pending), (elem) => { setTimeout(() => elem.focus(), 0); }, forms.text('Please help us serve you better by answering a few questions.'), forms.question( forms.text('Where do you plan to use Grist?'), forms.checkboxItem([{name: 'use_work'}], 'Work'), forms.checkboxItem([{name: 'use_school'}], 'School'), forms.checkboxItem([{name: 'use_personal'}], 'Personal'), forms.textBox({name: 'company'}, dom.hide(use => !use(whereObs)), dom.attr('placeholder', (use) => use(whereObs) === 'school' ? 'Your School' : 'Your Company') ), ), forms.question( forms.text('What brings you to Grist?'), forms.checkboxItem([{name: 'reason_problem'}], 'Solve a particular problem or need'), forms.checkboxItem([{name: 'reason_tool'}], 'Find a better tool than the one I am using'), forms.checkboxItem([{name: 'reason_curious'}], 'Just curious about a new product'), forms.checkboxOther([{name: 'reason_other'}], {name: 'other_reason', placeholder: 'Other...'}), ), forms.question( forms.text('What kind of industry do you work in?'), forms.textBox({name: 'industry', placeholder: 'Your answer'}), ), forms.question( forms.text('What is your role?'), forms.textBox({name: 'role', placeholder: 'Your answer'}), ), dom.on('change', (e, form) => { allFilled.set(forms.isFormFilled(form, ['use_*', 'reason_*', 'industry', 'role'])); whereObs.set(form.use_work.checked ? 'work' : form.use_school.checked ? 'school' : ''); }), cssButtonGroup( cssButtonGroup.cls('-right'), bigBasicButton('Continue', cssButton.cls('-primary', allFilled), dom.boolAttr('disabled', pending), {tabIndex: '0'}, testId('continue-button')), ), testId('info-form'), ); } private async _fetchOrgs() { this._orgs = await this._appModel.api.getOrgs(true); this._orgsLoaded.set(true); } private _buildOrgPicker(): DomContents { this._fetchOrgs().catch(reportError); return dom.maybe(this._orgsLoaded, () => { let orgs = this._orgs; if (orgs && orgs.length > 1) { // Let's make sure that the first org is not the personal org. if (orgs[0].owner) { orgs = [...orgs.slice(1), orgs[0]]; } return [ cssParagraph( "You've been added to a team. ", "Go to the team site, or to your personal site." ), cssParagraph( "You can always switch sites using the account menu in the top-right corner." ), orgs.map((org, i) => ( cssOrgButton( getOrgName(org), urlState().setLinkUrl({org: org.domain || undefined}), testId('org'), i ? cssButton.cls('-primary', false) : null ) )), ]; } }); } private _buildAccountPicker(): DomContents { function addUserToLink(email: string): string { const next = new URLSearchParams(location.search).get('next') || ''; const url = new URL(next, location.href); url.searchParams.set('user', email); return url.toString(); } return [ cssParagraph( "Select an account to continue with.", ), dom.maybe(this._appModel.topAppModel.users, users => users.map(user => basicButtonLink( cssUserItem.cls(''), cssMemberListItem( cssMemberImage( createUserImage(user, 'large') ), cssMemberText( cssMemberPrimary(user.name || dom('span', user.email, testId('select-email'))), user.name ? cssMemberSecondary(user.email, testId('select-email')) : null ), ), {href: addUserToLink(user.email)}, testId('select-user'), )), ), ]; } } const cssUserItem = styled('div', ` margin: 0 0 8px; align-items: center; &:first-of-type { margin-top: 16px; } &:hover { background-color: ${colors.lightGrey}; } `); const cssScrollContainer = styled('div', ` display: flex; overflow-y: auto; flex-direction: column; `); const cssContainer = styled('div', ` max-width: 450px; align-self: center; margin: 60px; display: flex; flex-direction: column; &:after { content: ""; height: 8px; } @media ${mediaSmall} { & { margin: 24px; } } `); const cssFlexSpace = styled('div', ` flex: 1 1 0px; `); const cssTitle = styled('div', ` height: 32px; line-height: 32px; margin: 0 0 28px 0; color: ${colors.dark}; font-size: ${vars.xxxlargeFontSize}; font-weight: ${vars.headerControlTextWeight}; `); const textStyle = ` font-weight: normal; font-size: ${vars.mediumFontSize}; color: ${colors.dark}; `; 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; } `); const cssWarning = styled('div', ` color: red; `); const cssInput = styled(input, BillingPageCss.inputStyle); const cssOrgButton = styled(bigPrimaryButtonLink, ` margin: 0 0 8px; width: 200px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; &:first-of-type { margin-top: 16px; } `); /** * We allow alphanumeric characters and certain common whitelisted characters (except at the start), * plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be * more precise about what exactly to allow). */ // eslint-disable-next-line no-control-regex const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/; /** * Test name against various rules to check if it is a valid username. Returned obj has `.valid` set * to true if all passes, otherwise it has the `.flag` set the the first failing test. */ export function checkName(name: string): boolean { return VALID_NAME_REGEXP.test(name); } /** * Builds dom to show marning messages to the user. */ export function buildNameWarningsDom() { return cssWarning( "Names only allow letters, numbers and certain special characters", testId('username-warning'), ); }