(core) Implement DiscourseConnect to enable easy sign-in to community forum

Summary:
- Update cookie module, to support modern sameSite settings
- Add a new cookie, grist_sid_status with less-sensitive value, to let less-trusted subdomains know if user is signed in
- The new cookie is kept in-sync with the session cookie.
- For a user signed in once, allow auto-signin is appropriate.
- For a user signed in with multiple accounts, show a page to select which account to use.
- Move css stylings for rendering users to a separate module.

Test Plan: Added a test case with a simulated Discourse page to test redirects and account-selection page.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3047
This commit is contained in:
Dmitry S
2021-10-01 10:24:23 -04:00
parent b3b7410ede
commit 1517dca644
18 changed files with 423 additions and 165 deletions

View File

@@ -2,7 +2,8 @@ import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState';
import {createUserImage} from 'app/client/ui/UserImage';
import * as um from 'app/client/ui/UserManager';
import {cssMemberImage, cssMemberListItem, cssMemberPrimary,
cssMemberSecondary, cssMemberText} from 'app/client/ui/UserItem';
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
@@ -24,14 +25,14 @@ const roleNames: {[role: string]: string} = {
function buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOpenController) {
const isCurrentUser = Boolean(currentUser && user.id === currentUser.id);
return cssUserItem(
um.cssMemberImage(
cssMemberImage(
createUserImage(user, 'large')
),
um.cssMemberText(
um.cssMemberPrimary(user.name || dom('span', user.email),
cssMemberText(
cssMemberPrimary(user.name || dom('span', user.email),
cssRole('(', roleNames[user.access!] || user.access, ')', testId('acl-user-access')),
),
user.name ? um.cssMemberSecondary(user.email) : null
user.name ? cssMemberSecondary(user.email) : null
),
basicButton(cssUserButton.cls(''), icon('Copy'), 'Copy Email',
testId('acl-user-copy'),
@@ -84,7 +85,7 @@ const cssUsers = styled('div', `
max-width: unset;
`);
const cssUserItem = styled(um.cssMemberListItem, `
const cssUserItem = styled(cssMemberListItem, `
width: auto;
padding: 8px 16px;
align-items: center;

View File

@@ -1,7 +1,8 @@
import {getHomeUrl, reportError} from 'app/client/models/AppModel';
import {BillingModel} from 'app/client/models/BillingModel';
import {createUserImage} from 'app/client/ui/UserImage';
import * as um from 'app/client/ui/UserManager';
import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMemberImage, cssMemberListItem,
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssRemoveIcon} from 'app/client/ui/UserItem';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars';
import {normalizeEmail} from 'app/common/emails';
@@ -39,9 +40,9 @@ export class BillingPlanManagers extends Disposable {
dom.forEach(this._managers, manager => this._buildManagerRow(manager)),
),
cssEmailInputRow(
um.cssEmailInputContainer({style: `flex: 1 1 0; margin: 0 7px 0 0;`},
um.cssMailIcon('Mail'),
this._emailElem = um.cssEmailInput(this._email, {onInput: true, isValid: this._isValid},
cssEmailInputContainer({style: `flex: 1 1 0; margin: 0 7px 0 0;`},
cssMailIcon('Mail'),
this._emailElem = cssEmailInput(this._email, {onInput: true, isValid: this._isValid},
{type: "email", placeholder: "Enter email address"},
dom.on('keyup', (e: KeyboardEvent) => {
switch (e.keyCode) {
@@ -51,8 +52,8 @@ export class BillingPlanManagers extends Disposable {
}),
dom.boolAttr('disabled', this._loading)
),
um.cssEmailInputContainer.cls('-green', enableAdd),
um.cssEmailInputContainer.cls('-disabled', this._loading),
cssEmailInputContainer.cls('-green', enableAdd),
cssEmailInputContainer.cls('-disabled', this._loading),
testId('bpm-manager-new')
),
bigPrimaryButton('Add Billing Contact',
@@ -66,17 +67,17 @@ export class BillingPlanManagers extends Disposable {
private _buildManagerRow(manager: FullUser) {
const isCurrentUser = this._currentValidUser && manager.id === this._currentValidUser.id;
return um.cssMemberListItem({style: 'width: auto;'},
um.cssMemberImage(
return cssMemberListItem({style: 'width: auto;'},
cssMemberImage(
createUserImage(manager, 'large')
),
um.cssMemberText(
um.cssMemberPrimary(manager.name || dom('span', manager.email, testId('bpm-email'))),
manager.name ? um.cssMemberSecondary(manager.email, testId('bpm-email')) : null
cssMemberText(
cssMemberPrimary(manager.name || dom('span', manager.email, testId('bpm-email'))),
manager.name ? cssMemberSecondary(manager.email, testId('bpm-email')) : null
),
um.cssMemberBtn(
um.cssRemoveIcon('Remove', testId('bpm-manager-delete')),
um.cssMemberBtn.cls('-disabled', (use) => Boolean(use(this._loading) || isCurrentUser)),
cssMemberBtn(
cssRemoveIcon('Remove', testId('bpm-manager-delete')),
cssMemberBtn.cls('-disabled', (use) => Boolean(use(this._loading) || isCurrentUser)),
// Click handler.
dom.on('click', () => this._loading.get() || isCurrentUser || this._remove(manager))
),

120
app/client/ui/UserItem.ts Normal file
View File

@@ -0,0 +1,120 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {input, styled} from 'grainjs';
import {cssMenuItem} from 'popweasel';
// Styled elements used for rendering a user, e.g. in the UserManager, Billing, etc.
// There is a general structure, but enough small variation that there is no helper at this point.
//
// cssMemberListItem(
// cssMemberImage(
// createUserImage(getFullUser(member), 'large')
// ),
// cssMemberText(
// cssMemberPrimary(NAME),
// cssMemberSecondary(EMAIL),
// )
// )
export const cssMemberListItem = styled('div', `
display: flex;
width: 460px;
height: 64px;
margin: 0 auto;
padding: 12px 0;
`);
export const cssMemberImage = styled('div', `
width: 40px;
height: 40px;
margin: 0 4px;
border-radius: 20px;
background-color: ${colors.lightGreen};
background-size: cover;
.${cssMemberListItem.className}-removed & {
opacity: 0.4;
}
`);
export const cssMemberText = styled('div', `
display: flex;
flex-direction: column;
justify-content: center;
margin: 2px 12px;
flex: 1 1 0;
min-width: 0px;
font-size: ${vars.mediumFontSize};
.${cssMemberListItem.className}-removed & {
opacity: 0.4;
}
`);
export const cssMemberPrimary = styled('span', `
font-weight: bold;
color: ${colors.dark};
padding: 2px 0;
.${cssMenuItem.className}-sel & {
color: white;
}
`);
export const cssMemberSecondary = styled('span', `
color: ${colors.slate};
/* the following just undo annoying bootstrap styles that apply to all labels */
margin: 0px;
font-weight: normal;
padding: 2px 0;
white-space: nowrap;
.${cssMenuItem.className}-sel & {
color: white;
}
`);
export const cssMemberBtn = styled('div', `
width: 16px;
height: 16px;
cursor: pointer;
&-disabled {
opacity: 0.3;
cursor: default;
}
`);
export const cssRemoveIcon = styled(icon, `
margin: 12px 0;
`);
export const cssEmailInputContainer = styled('div', `
position: relative;
display: flex;
height: 42px;
padding: 0 3px;
margin: 16px 63px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
font-size: ${vars.mediumFontSize};
outline: none;
&-green {
border: 1px solid ${colors.lightGreen};
}
`);
export const cssEmailInput = styled(input, `
flex: 1 1 0;
line-height: 42px;
font-size: ${vars.mediumFontSize};
font-family: ${vars.fontFamily};
outline: none;
border: none;
`);
export const cssMailIcon = styled(icon, `
margin: 12px 8px 12px 13px;
background-color: ${colors.slate};
`);

View File

@@ -10,7 +10,7 @@ import * as roles from 'app/common/roles';
import {tbind} from 'app/common/tbind';
import {PermissionData, UserAPI} from 'app/common/UserAPI';
import {computed, Computed, Disposable, observable, Observable} from 'grainjs';
import {dom, DomElementArg, input, styled} from 'grainjs';
import {dom, DomElementArg, styled} from 'grainjs';
import {cssMenuItem} from 'popweasel';
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
@@ -24,6 +24,8 @@ import {getResourceParent, ResourceType} from 'app/client/models/UserManagerMode
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMemberImage, cssMemberListItem,
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssRemoveIcon} from 'app/client/ui/UserItem';
import {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
@@ -451,27 +453,6 @@ const cssOptionBtn = styled('span', `
cursor: pointer;
`);
export const cssMemberListItem = styled('div', `
display: flex;
width: 460px;
height: 64px;
margin: 0 auto;
padding: 12px 0;
`);
export const cssMemberImage = styled('div', `
width: 40px;
height: 40px;
margin: 0 4px;
border-radius: 20px;
background-color: ${colors.lightGreen};
background-size: cover;
.${cssMemberListItem.className}-removed & {
opacity: 0.4;
}
`);
const cssPublicMemberIcon = styled(icon, `
width: 32px;
height: 32px;
@@ -479,88 +460,6 @@ const cssPublicMemberIcon = styled(icon, `
--icon-color: ${colors.lightGreen};
`);
export const cssMemberText = styled('div', `
display: flex;
flex-direction: column;
justify-content: center;
margin: 2px 12px;
flex: 1 1 0;
min-width: 0px;
font-size: ${vars.mediumFontSize};
.${cssMemberListItem.className}-removed & {
opacity: 0.4;
}
`);
export const cssMemberPrimary = styled('span', `
font-weight: bold;
color: ${colors.dark};
padding: 2px 0;
.${cssMenuItem.className}-sel & {
color: white;
}
`);
export const cssMemberSecondary = styled('span', `
color: ${colors.slate};
/* the following just undo annoying bootstrap styles that apply to all labels */
margin: 0px;
font-weight: normal;
padding: 2px 0;
white-space: nowrap;
.${cssMenuItem.className}-sel & {
color: white;
}
`);
export const cssEmailInputContainer = styled('div', `
position: relative;
display: flex;
height: 42px;
padding: 0 3px;
margin: 16px 63px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
font-size: ${vars.mediumFontSize};
outline: none;
&-green {
border: 1px solid ${colors.lightGreen};
}
`);
export const cssMailIcon = styled(icon, `
margin: 12px 8px 12px 13px;
background-color: ${colors.slate};
`);
export const cssEmailInput = styled(input, `
flex: 1 1 0;
line-height: 42px;
font-size: ${vars.mediumFontSize};
font-family: ${vars.fontFamily};
outline: none;
border: none;
`);
export const cssMemberBtn = styled('div', `
width: 16px;
height: 16px;
cursor: pointer;
&-disabled {
opacity: 0.3;
cursor: default;
}
`);
export const cssRemoveIcon = styled(icon, `
margin: 12px 0;
`);
const cssUndoIcon = styled(icon, `
margin: 12px 0;
`);

View File

@@ -8,7 +8,10 @@ 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, bigBasicButtonLink, bigPrimaryButton, bigPrimaryButtonLink,
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";
@@ -92,6 +95,7 @@ export class WelcomePage extends Disposable {
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
)),
));
@@ -336,8 +340,50 @@ export class WelcomePage extends Disposable {
}
});
}
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;