(core) Improve the look and behavior of /welcome/teams page (also shown for /welcome/start)

Summary:
- Move css module for the login page css to core/, to be reusable in core/ pages.
- Move /welcome/teams implementation to WelcomeSitePicker.ts
- List users for personal sites, as well as team sites.
- Add org param to setSessionActive() API method and end endpoint, to allow
  switching the specified org to another user.
- Add a little safety to getOrgUrl() function.

Test Plan: Added a test case for the new behaviors of the /welcome/teams page.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3914
This commit is contained in:
Dmitry S
2023-06-13 19:32:29 -04:00
parent 812cded291
commit 2740884e3c
12 changed files with 390 additions and 75 deletions

View File

@@ -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;
`);

View File

@@ -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;
}
`);

View File

@@ -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};
`);

View File

@@ -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');