diff --git a/app/client/ui/LoginPagesCss.ts b/app/client/ui/LoginPagesCss.ts new file mode 100644 index 00000000..7d7e3708 --- /dev/null +++ b/app/client/ui/LoginPagesCss.ts @@ -0,0 +1,216 @@ +import {bigPrimaryButton as gristBigPrimaryButton, + bigPrimaryButtonLink as gristBigPrimaryButtonLink, + textButton as gristTextButton} from 'app/client/ui2018/buttons'; +import {colors, mediaXSmall, theme} from 'app/client/ui2018/cssVars'; +import {textInput} from 'app/client/ui/inputs'; +import {styled} from 'grainjs'; + +export const text = styled('div', ` + color: ${theme.text}; + font-weight: 400; + line-height: 20px; + font-size: 14px; +`); + +export const lightText = styled(text, ` + color: ${theme.lightText}; +`); + +export const lightColor = styled('span', ` + color: ${theme.lightText}; +`); + +export const centeredText = styled(text, ` + text-align: center; +`); + +export const lightlyBolded = styled('span', ` + font-weight: 500; +`); + +export const input = textInput; + +export const codeInput = styled(input, ` + width: 200px; +`); + +export const label = styled('label', ` + color: ${theme.text}; + display: inline-block; + line-height: 20px; + font-size: 14px; + font-weight: 500; +`); + +export const formLabel = styled(label, ` + margin-bottom: 8px; +`); + +export const googleButton = styled('button', ` + /* Resets */ + position: relative; + border-style: none; + + /* Vars */ + display: flex; + justify-content: center; + align-items: center; + height: 48px; + gap: 12px; + font-size: 15px; + font-weight: 500; + line-height: 16px; + padding: 16px; + color: ${colors.dark}; + background-color: ${colors.lightGrey}; + border: 1px solid ${colors.darkGrey}; + border-radius: 4px; + cursor: pointer; + width: 100%; + + &:hover { + background-color: ${colors.mediumGrey}; + } +`); + +export const image = styled('div', ` + display: inline-block; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +`); + +export const gristLogo = styled(image, ` + width: 100%; + height: 32px; + background-image: var(--icon-GristLogo); +`); + +export const googleLogo = styled(image, ` + width: 24px; + height: 24px; + background-image: var(--icon-GoogleLogo); +`); + +export const loginMethodsSeparator = styled('div', ` + display: flex; + align-items: center; + gap: 8px; + margin: 24px 0px 24px 0px; +`); + +export const horizontalLine = styled('hr', ` + border: 1px solid ${theme.loginPageLine}; + flex-grow: 1; +`); + +/** + * TODO: Consider using our own outline. + * + * We revert here to improve accessibility on the login pages. We could also + * leave the default outline alone, since it doesn't seem to appear on + * click anymore (in modern browsers, at least). + */ +export const bigPrimaryButton = styled(gristBigPrimaryButton, ` + outline: revert; + font-weight: 500; + height: 48px; + font-size: 15px; + line-height: 16px; +`); + +export const bigPrimaryButtonLink = styled(gristBigPrimaryButtonLink, ` + outline: revert; + padding: 16px 32px 16px 32px; + font-weight: 500; + font-size: 15px; + line-height: 16px; +`); + +export const textButton = styled(gristTextButton, ` + outline: revert; + font-size: 14px; +`); + +export const pageContainer = styled('div', ` + min-height: 100%; + background-color: ${theme.loginPageBackdrop}; + + @media ${mediaXSmall} { + & { + background-color: ${theme.loginPageBg}; + } + } +`); + +export const centeredFlexContainer = styled('div', ` + display: flex; + justify-content: center; +`); + +export const formContainer = styled('div', ` + background-color: ${theme.loginPageBg}; + max-width: 576px; + width: 100%; + margin: 60px 25px 60px 25px; + padding: 40px 56px 40px 56px; + border-radius: 8px; + + @media ${mediaXSmall} { + & { + margin: 0px; + padding: 25px 20px 25px 20px; + } + } +`); + +export const formHeading = styled('div', ` + font-weight: 500; + font-size: 32px; + line-height: 40px; + margin-bottom: 8px; + color: ${theme.text}; + + @media ${mediaXSmall} { + & { + font-size: 24px; + line-height: 32px; + margin-bottom: 16px; + } + } +`); + +export const formInstructions = styled('div', ` + margin-bottom: 32px; +`); + +export const formError = styled(text, ` + color: ${theme.errorText}; + margin-bottom: 16px; +`); + +export const centeredFormError = styled(formError, ` + text-align: center; +`); + +export const formButtons = styled('div', ` + margin: 32px 0px 0px 0px; +`); + +export const formFooter = styled(text, ` + margin-top: 24px; +`); + +export const formBody = styled('div', ``); + +export const resendCode = styled(text, ` + margin-top: 16px; +`); + +export const spinner = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 250px; +`); diff --git a/app/client/ui/WelcomePage.ts b/app/client/ui/WelcomePage.ts index deaa7b45..d67498fb 100644 --- a/app/client/ui/WelcomePage.ts +++ b/app/client/ui/WelcomePage.ts @@ -1,7 +1,7 @@ import { Disposable, dom, domComputed, DomContents, MultiHolder, Observable, styled } from "grainjs"; import { handleSubmit, submitForm } from "app/client/lib/formUtils"; -import { AppModel, reportError } from "app/client/models/AppModel"; +import { AppModel } 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'; @@ -10,11 +10,11 @@ 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, bigBasicButtonLink, bigPrimaryButton, bigPrimaryButtonLink, - cssButton } from "app/client/ui2018/buttons"; +import { buildWelcomeSitePicker } from 'app/client/ui/WelcomeSitePicker'; +import { basicButtonLink, bigBasicButtonLink, bigPrimaryButton } from "app/client/ui2018/buttons"; import { mediaSmall, testId, theme, vars } from "app/client/ui2018/cssVars"; import { cssLink } from "app/client/ui2018/links"; -import { getOrgName, Organization } from "app/common/UserAPI"; +import { WelcomePage as WelcomePageEnum } from 'app/common/gristUrls'; // Redirect from ..../welcome/thing to .../welcome/${name} function _redirectToSiblingPage(name: string) { @@ -36,14 +36,15 @@ function handleSubmitForm( export class WelcomePage extends Disposable { - private _orgs: Organization[]; - private _orgsLoaded = Observable.create(this, false); - constructor(private _appModel: AppModel) { super(); } public buildDom() { + return domComputed(urlState().state, state => this._buildDomInPagePanels(state.welcome)); + } + + private _buildDomInPagePanels(page?: WelcomePageEnum) { return pagePanels({ leftPanel: { panelWidth: Observable.create(this, 240), @@ -53,22 +54,21 @@ export class WelcomePage extends Disposable { content: null, }, headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)], - contentMain: this.buildPageContent() + contentMain: ( + page === 'teams' ? dom.create(buildWelcomeSitePicker, this._appModel) : + this._buildPageContent(page) + ), }); } - public buildPageContent(): Element { + private _buildPageContent(page?: WelcomePageEnum): 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 === 'teams' ? dom.create(this._buildOrgPicker.bind(this)) : - state.welcome === 'select-account' ? dom.create(this._buildAccountPicker.bind(this)) : - null - )), + page === 'signup' ? dom.create(this._buildSignupForm.bind(this)) : + page === 'verify' ? dom.create(this._buildVerifyForm.bind(this)) : + page === 'select-account' ? dom.create(this._buildAccountPicker.bind(this)) : + null )); } @@ -189,44 +189,6 @@ export class WelcomePage extends Disposable { ); } - 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') || ''; @@ -333,15 +295,3 @@ const cssInput = styled(textInput, ` padding: 13px; border-radius: 3px; `); - -const cssOrgButton = styled(bigPrimaryButtonLink, ` - margin: 0 0 8px; - width: 200px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - - &:first-of-type { - margin-top: 16px; - } -`); diff --git a/app/client/ui/WelcomeSitePicker.ts b/app/client/ui/WelcomeSitePicker.ts new file mode 100644 index 00000000..9d896c3c --- /dev/null +++ b/app/client/ui/WelcomeSitePicker.ts @@ -0,0 +1,116 @@ +import { makeT } from 'app/client/lib/localization'; +import { AppModel } from "app/client/models/AppModel"; +import { urlState} from "app/client/models/gristUrlState"; +import { createUserImage } from 'app/client/ui/UserImage'; +import { bigBasicButtonLink } from "app/client/ui2018/buttons"; +import { testId, theme } from "app/client/ui2018/cssVars"; +import { FullUser } from 'app/common/LoginSessionAPI'; +import { getOrgName } from "app/common/UserAPI"; +import * as css from 'app/client/ui/LoginPagesCss'; +import { Computed, dom, DomContents, IDisposableOwner, styled } from "grainjs"; + +const t = makeT('WelcomeSitePicker'); + +export function buildWelcomeSitePicker(owner: IDisposableOwner, appModel: AppModel): DomContents { + // We assume that there is a single domain for personal orgs, and will show a button to open + // that domain with each of the currently signed-in users. + const personalOrg = Computed.create(owner, (use) => + use(appModel.topAppModel.orgs).find(o => Boolean(o.owner))?.domain || undefined); + + return cssPageContainer( + testId('welcome-page'), + css.centeredFlexContainer( + css.formContainer( + css.gristLogo(), + cssHeading(t('Welcome back')), + cssMessage(t('You have access to the following Grist sites.')), + cssColumns( + cssColumn( + cssColumnLabel(css.horizontalLine(), css.lightText('Personal'), css.horizontalLine()), + dom.forEach(appModel.topAppModel.users, (user) => ( + cssOrgButton( + cssPersonalOrg( + createUserImage(user, 'small'), + dom('div', user.email, testId('personal-org-email')), + ), + dom.attr('href', (use) => urlState().makeUrl({org: use(personalOrg)})), + dom.on('click', (ev) => { void(switchToPersonalUrl(ev, appModel, personalOrg.get(), user)); }), + testId('personal-org'), + ) + )), + ), + cssColumn( + cssColumnLabel(css.horizontalLine(), css.lightText('Team'), css.horizontalLine()), + dom.forEach(appModel.topAppModel.orgs, (org) => ( + org.owner || !org.domain ? null : cssOrgButton( + getOrgName(org), + urlState().setLinkUrl({org: org.domain}), + testId('org'), + ) + )), + ) + ), + cssMessage(t("You can always switch sites using the account menu.")), + ) + ) + ); +} + +// TODO This works but not for opening a link in a new tab. We currently lack and endpoint that +// would enable opening a link as a particular user, or to switch user and open as them. +async function switchToPersonalUrl(ev: MouseEvent, appModel: AppModel, org: string|undefined, user: FullUser) { + // Only handle plain-vanilla clicks. + if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; } + ev.preventDefault(); + // Set the active session for the given org, then load its home page. + await appModel.api.setSessionActive(user.email, org); + window.location.assign(urlState().makeUrl({org})); +} + +const cssPageContainer = styled(css.pageContainer, ` + overflow: auto; + padding-bottom: 40px; +`); + +const cssHeading = styled(css.formHeading, ` + margin-top: 16px; + text-align: center; +`); + +const cssMessage = styled(css.centeredText, ` + margin: 24px 0; +`); + +const cssColumns = styled('div', ` + display: flex; + flex-wrap: wrap; + gap: 32px; +`); + +const cssColumn = styled('div', ` + flex: 1 0 0px; + min-width: 200px; + position: relative; +`); + +const cssColumnLabel = styled('div', ` + display: flex; + align-items: center; + gap: 8px; +`); + +const cssOrgButton = styled(bigBasicButtonLink, ` + display: block; + margin: 8px 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +`); + +const cssPersonalOrg = styled('div', ` + display: flex; + align-items: center; + margin-left: -8px; + gap: 8px; + color: ${theme.lightText}; +`); diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 2595271e..195cfda7 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -788,6 +788,11 @@ export const theme = { undefined, colors.darkGrey), highlightedCodeBgDisabled: new CustomProp('theme-highlighted-code-bg-disabled', undefined, colors.mediumGreyOpaque), + + /* Login Page */ + loginPageBg: new CustomProp('theme-login-page-bg', undefined, 'white'), + loginPageBackdrop: new CustomProp('theme-login-page-backdrop', undefined, '#F5F8FA'), + loginPageLine: new CustomProp('theme-login-page-line', undefined, colors.lightGrey), }; const cssColors = values(colors).map(v => v.decl()).join('\n'); diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index 0eb48028..4bb4f66d 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -386,6 +386,9 @@ export const ThemeColors = t.iface([], { "highlighted-code-fg": "string", "highlighted-code-border": "string", "highlighted-code-bg-disabled": "string", + "login-page-bg": "string", + "login-page-backdrop": "string", + "login-page-line": "string", }); const exportedTypeSuite: t.ITypeSuite = { diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index af4f4292..e6f55aaf 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -504,6 +504,11 @@ export interface ThemeColors { 'highlighted-code-fg': string; 'highlighted-code-border': string; 'highlighted-code-bg-disabled': string; + + /* Login Page */ + 'login-page-bg': string; + 'login-page-backdrop': string; + 'login-page-line': string; } export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index cf8ad551..e80c9064 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -333,7 +333,7 @@ export interface DocStateComparisonDetails { export interface UserAPI { getSessionActive(): Promise; - setSessionActive(email: string): Promise; + setSessionActive(email: string, org?: string): Promise; getSessionAll(): Promise<{users: FullUser[], orgs: Organization[]}>; getOrgs(merged?: boolean): Promise; getWorkspace(workspaceId: number): Promise; @@ -487,8 +487,8 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { return this.requestJson(`${this._url}/api/session/access/active`, {method: 'GET'}); } - public async setSessionActive(email: string): Promise { - const body = JSON.stringify({ email }); + public async setSessionActive(email: string, org?: string): Promise { + const body = JSON.stringify({ email, org }); return this.requestJson(`${this._url}/api/session/access/active`, {method: 'POST', body}); } diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index 8b960e7d..84f1aa28 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -483,4 +483,9 @@ export const GristDark: ThemeColors = { 'highlighted-code-fg': '#A4A4A4', 'highlighted-code-border': '#69697D', 'highlighted-code-bg-disabled': '#555563', + + /* Login Page */ + 'login-page-bg': '#32323F', + 'login-page-backdrop': '#404150', + 'login-page-line': '#57575F', }; diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index d9b75599..fc0af490 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -483,4 +483,9 @@ export const GristLight: ThemeColors = { 'highlighted-code-fg': '#929299', 'highlighted-code-border': '#D9D9D9', 'highlighted-code-bg-disabled': '#E8E8E8', + + /* Login Page */ + 'login-page-bg': 'white', + 'login-page-backdrop': '#F5F8FA', + 'login-page-line': '#F7F7F7', }; diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 736fdc43..99f30b7c 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -13,7 +13,7 @@ import {expressWrap} from 'app/server/lib/expressWrap'; import {RequestWithOrg} from 'app/server/lib/extractOrg'; import log from 'app/server/lib/log'; import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, - isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; + isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {User} from './entity/User'; @@ -487,16 +487,20 @@ export class ApiServer { // POST /api/session/access/active // Body params: email (required) + // Body params: org (optional) - string subdomain or 'current', for which org's active user to modify. // Sets active user for active org this._app.post('/api/session/access/active', expressWrap(async (req, res) => { const mreq = req as RequestWithLogin; - const domain = getOrgFromRequest(mreq); + let domain = optStringParam(req.body.org); + if (!domain || domain === 'current') { + domain = getOrgFromRequest(mreq) || ''; + } const email = req.body.email; if (!email) { throw new ApiError('email required', 400); } try { // Modify session copy in request. Will be saved to persistent storage before responding // by express-session middleware. - linkOrgWithEmail(mreq.session, req.body.email, domain || ''); + linkOrgWithEmail(mreq.session, req.body.email, domain); clearSessionCacheIfNeeded(req, {sessionID: mreq.sessionID}); return sendOkReply(req, res, {email}); } catch (e) { diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index cee0def6..b2922c23 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -1,5 +1,5 @@ import {ApiError} from 'app/common/ApiError'; -import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain} from 'app/common/gristUrls'; +import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; import * as gutil from 'app/common/gutil'; import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; @@ -70,7 +70,8 @@ export function addOrgToPath(req: RequestWithOrg, path: string): string { * Get url to the org associated with the request. */ export function getOrgUrl(req: Request, path: string = '/') { - return getOriginUrl(req) + addOrgToPathIfNeeded(req, path); + // Be careful to include a leading slash in path, to ensure we don't modify the origin or org. + return getOriginUrl(req) + addOrgToPathIfNeeded(req, sanitizePathTail(path)); } /** diff --git a/static/locales/en.client.json b/static/locales/en.client.json index b8adc530..32d48834 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -1030,5 +1030,10 @@ }, "GridView": { "Click to insert": "Click to insert" + }, + "WelcomeSitePicker": { + "Welcome back": "Welcome back", + "You can always switch sites using the account menu.": "You can always switch sites using the account menu.", + "You have access to the following Grist sites.": "You have access to the following Grist sites." } }