mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
b3b7410ede
commit
1517dca644
@ -2,7 +2,8 @@ import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
|||||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {createUserImage} from 'app/client/ui/UserImage';
|
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 {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
|
||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
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) {
|
function buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOpenController) {
|
||||||
const isCurrentUser = Boolean(currentUser && user.id === currentUser.id);
|
const isCurrentUser = Boolean(currentUser && user.id === currentUser.id);
|
||||||
return cssUserItem(
|
return cssUserItem(
|
||||||
um.cssMemberImage(
|
cssMemberImage(
|
||||||
createUserImage(user, 'large')
|
createUserImage(user, 'large')
|
||||||
),
|
),
|
||||||
um.cssMemberText(
|
cssMemberText(
|
||||||
um.cssMemberPrimary(user.name || dom('span', user.email),
|
cssMemberPrimary(user.name || dom('span', user.email),
|
||||||
cssRole('(', roleNames[user.access!] || user.access, ')', testId('acl-user-access')),
|
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',
|
basicButton(cssUserButton.cls(''), icon('Copy'), 'Copy Email',
|
||||||
testId('acl-user-copy'),
|
testId('acl-user-copy'),
|
||||||
@ -84,7 +85,7 @@ const cssUsers = styled('div', `
|
|||||||
max-width: unset;
|
max-width: unset;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssUserItem = styled(um.cssMemberListItem, `
|
const cssUserItem = styled(cssMemberListItem, `
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import {getHomeUrl, reportError} from 'app/client/models/AppModel';
|
import {getHomeUrl, reportError} from 'app/client/models/AppModel';
|
||||||
import {BillingModel} from 'app/client/models/BillingModel';
|
import {BillingModel} from 'app/client/models/BillingModel';
|
||||||
import {createUserImage} from 'app/client/ui/UserImage';
|
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 {bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
import {testId} from 'app/client/ui2018/cssVars';
|
import {testId} from 'app/client/ui2018/cssVars';
|
||||||
import {normalizeEmail} from 'app/common/emails';
|
import {normalizeEmail} from 'app/common/emails';
|
||||||
@ -39,9 +40,9 @@ export class BillingPlanManagers extends Disposable {
|
|||||||
dom.forEach(this._managers, manager => this._buildManagerRow(manager)),
|
dom.forEach(this._managers, manager => this._buildManagerRow(manager)),
|
||||||
),
|
),
|
||||||
cssEmailInputRow(
|
cssEmailInputRow(
|
||||||
um.cssEmailInputContainer({style: `flex: 1 1 0; margin: 0 7px 0 0;`},
|
cssEmailInputContainer({style: `flex: 1 1 0; margin: 0 7px 0 0;`},
|
||||||
um.cssMailIcon('Mail'),
|
cssMailIcon('Mail'),
|
||||||
this._emailElem = um.cssEmailInput(this._email, {onInput: true, isValid: this._isValid},
|
this._emailElem = cssEmailInput(this._email, {onInput: true, isValid: this._isValid},
|
||||||
{type: "email", placeholder: "Enter email address"},
|
{type: "email", placeholder: "Enter email address"},
|
||||||
dom.on('keyup', (e: KeyboardEvent) => {
|
dom.on('keyup', (e: KeyboardEvent) => {
|
||||||
switch (e.keyCode) {
|
switch (e.keyCode) {
|
||||||
@ -51,8 +52,8 @@ export class BillingPlanManagers extends Disposable {
|
|||||||
}),
|
}),
|
||||||
dom.boolAttr('disabled', this._loading)
|
dom.boolAttr('disabled', this._loading)
|
||||||
),
|
),
|
||||||
um.cssEmailInputContainer.cls('-green', enableAdd),
|
cssEmailInputContainer.cls('-green', enableAdd),
|
||||||
um.cssEmailInputContainer.cls('-disabled', this._loading),
|
cssEmailInputContainer.cls('-disabled', this._loading),
|
||||||
testId('bpm-manager-new')
|
testId('bpm-manager-new')
|
||||||
),
|
),
|
||||||
bigPrimaryButton('Add Billing Contact',
|
bigPrimaryButton('Add Billing Contact',
|
||||||
@ -66,17 +67,17 @@ export class BillingPlanManagers extends Disposable {
|
|||||||
|
|
||||||
private _buildManagerRow(manager: FullUser) {
|
private _buildManagerRow(manager: FullUser) {
|
||||||
const isCurrentUser = this._currentValidUser && manager.id === this._currentValidUser.id;
|
const isCurrentUser = this._currentValidUser && manager.id === this._currentValidUser.id;
|
||||||
return um.cssMemberListItem({style: 'width: auto;'},
|
return cssMemberListItem({style: 'width: auto;'},
|
||||||
um.cssMemberImage(
|
cssMemberImage(
|
||||||
createUserImage(manager, 'large')
|
createUserImage(manager, 'large')
|
||||||
),
|
),
|
||||||
um.cssMemberText(
|
cssMemberText(
|
||||||
um.cssMemberPrimary(manager.name || dom('span', manager.email, testId('bpm-email'))),
|
cssMemberPrimary(manager.name || dom('span', manager.email, testId('bpm-email'))),
|
||||||
manager.name ? um.cssMemberSecondary(manager.email, testId('bpm-email')) : null
|
manager.name ? cssMemberSecondary(manager.email, testId('bpm-email')) : null
|
||||||
),
|
),
|
||||||
um.cssMemberBtn(
|
cssMemberBtn(
|
||||||
um.cssRemoveIcon('Remove', testId('bpm-manager-delete')),
|
cssRemoveIcon('Remove', testId('bpm-manager-delete')),
|
||||||
um.cssMemberBtn.cls('-disabled', (use) => Boolean(use(this._loading) || isCurrentUser)),
|
cssMemberBtn.cls('-disabled', (use) => Boolean(use(this._loading) || isCurrentUser)),
|
||||||
// Click handler.
|
// Click handler.
|
||||||
dom.on('click', () => this._loading.get() || isCurrentUser || this._remove(manager))
|
dom.on('click', () => this._loading.get() || isCurrentUser || this._remove(manager))
|
||||||
),
|
),
|
||||||
|
120
app/client/ui/UserItem.ts
Normal file
120
app/client/ui/UserItem.ts
Normal 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};
|
||||||
|
`);
|
@ -10,7 +10,7 @@ import * as roles from 'app/common/roles';
|
|||||||
import {tbind} from 'app/common/tbind';
|
import {tbind} from 'app/common/tbind';
|
||||||
import {PermissionData, UserAPI} from 'app/common/UserAPI';
|
import {PermissionData, UserAPI} from 'app/common/UserAPI';
|
||||||
import {computed, Computed, Disposable, observable, Observable} from 'grainjs';
|
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 {cssMenuItem} from 'popweasel';
|
||||||
|
|
||||||
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
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 {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||||
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
|
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 {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
@ -451,27 +453,6 @@ const cssOptionBtn = styled('span', `
|
|||||||
cursor: pointer;
|
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, `
|
const cssPublicMemberIcon = styled(icon, `
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@ -479,88 +460,6 @@ const cssPublicMemberIcon = styled(icon, `
|
|||||||
--icon-color: ${colors.lightGreen};
|
--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, `
|
const cssUndoIcon = styled(icon, `
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
`);
|
`);
|
||||||
|
@ -8,7 +8,10 @@ import { AppHeader } from 'app/client/ui/AppHeader';
|
|||||||
import * as BillingPageCss from "app/client/ui/BillingPageCss";
|
import * as BillingPageCss from "app/client/ui/BillingPageCss";
|
||||||
import * as forms from "app/client/ui/forms";
|
import * as forms from "app/client/ui/forms";
|
||||||
import { pagePanels } from "app/client/ui/PagePanels";
|
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";
|
cssButton } from "app/client/ui2018/buttons";
|
||||||
import { colors, mediaSmall, testId, vars } from "app/client/ui2018/cssVars";
|
import { colors, mediaSmall, testId, vars } from "app/client/ui2018/cssVars";
|
||||||
import { getOrgName, Organization } from "app/common/UserAPI";
|
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 === 'user' ? dom.create(this._buildNameForm.bind(this)) :
|
||||||
state.welcome === 'info' ? dom.create(this._buildInfoForm.bind(this)) :
|
state.welcome === 'info' ? dom.create(this._buildInfoForm.bind(this)) :
|
||||||
state.welcome === 'teams' ? dom.create(this._buildOrgPicker.bind(this)) :
|
state.welcome === 'teams' ? dom.create(this._buildOrgPicker.bind(this)) :
|
||||||
|
state.welcome === 'select-account' ? dom.create(this._buildAccountPicker.bind(this)) :
|
||||||
null
|
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', `
|
const cssScrollContainer = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
@ -17,7 +17,7 @@ export type IDocPage = number | 'new' | 'code' | 'acl' | 'GristDocTour';
|
|||||||
export const HomePage = StringUnion('all', 'workspace', 'templates', 'trash');
|
export const HomePage = StringUnion('all', 'workspace', 'templates', 'trash');
|
||||||
export type IHomePage = typeof HomePage.type;
|
export type IHomePage = typeof HomePage.type;
|
||||||
|
|
||||||
export const WelcomePage = StringUnion('user', 'info', 'teams', 'signup', 'verify');
|
export const WelcomePage = StringUnion('user', 'info', 'teams', 'signup', 'verify', 'select-account');
|
||||||
export type WelcomePage = typeof WelcomePage.type;
|
export type WelcomePage = typeof WelcomePage.type;
|
||||||
|
|
||||||
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
|
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
|
||||||
|
1
app/server/declarations.d.ts
vendored
1
app/server/declarations.d.ts
vendored
@ -83,6 +83,7 @@ declare module "mime-types";
|
|||||||
declare module "morgan";
|
declare module "morgan";
|
||||||
declare module "cookie";
|
declare module "cookie";
|
||||||
declare module "cookie-parser";
|
declare module "cookie-parser";
|
||||||
|
declare module "on-headers";
|
||||||
declare module "@gristlabs/express-session";
|
declare module "@gristlabs/express-session";
|
||||||
|
|
||||||
// Used for command line path tweaks.
|
// Used for command line path tweaks.
|
||||||
|
@ -6,14 +6,17 @@ import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
|
|||||||
import {Document} from 'app/gen-server/entity/Document';
|
import {Document} from 'app/gen-server/entity/Document';
|
||||||
import {User} from 'app/gen-server/entity/User';
|
import {User} from 'app/gen-server/entity/User';
|
||||||
import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
import {getSessionProfiles, getSessionUser, linkOrgWithEmail, SessionObj,
|
import {getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,
|
||||||
SessionUserObj} from 'app/server/lib/BrowserSession';
|
SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession';
|
||||||
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
||||||
import {COOKIE_MAX_AGE, getAllowedOrgForSessionID} from 'app/server/lib/gristSessions';
|
import {COOKIE_MAX_AGE, getAllowedOrgForSessionID, getCookieDomain,
|
||||||
|
cookieName as sessionCookieName} from 'app/server/lib/gristSessions';
|
||||||
import * as log from 'app/server/lib/log';
|
import * as log from 'app/server/lib/log';
|
||||||
import {IPermitStore, Permit} from 'app/server/lib/Permit';
|
import {IPermitStore, Permit} from 'app/server/lib/Permit';
|
||||||
import {allowHost} from 'app/server/lib/requestUtils';
|
import {allowHost} from 'app/server/lib/requestUtils';
|
||||||
|
import * as cookie from 'cookie';
|
||||||
import {NextFunction, Request, RequestHandler, Response} from 'express';
|
import {NextFunction, Request, RequestHandler, Response} from 'express';
|
||||||
|
import * as onHeaders from 'on-headers';
|
||||||
|
|
||||||
export interface RequestWithLogin extends Request {
|
export interface RequestWithLogin extends Request {
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
@ -189,7 +192,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
|
|
||||||
// See if we have a profile linked with the active organization already.
|
// See if we have a profile linked with the active organization already.
|
||||||
// TODO: implement userSelector for rest API, to allow "sticky" user selection on pages.
|
// TODO: implement userSelector for rest API, to allow "sticky" user selection on pages.
|
||||||
let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, '');
|
let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, mreq.query.user || '');
|
||||||
|
|
||||||
if (!sessionUser) {
|
if (!sessionUser) {
|
||||||
// No profile linked yet, so let's elect one.
|
// No profile linked yet, so let's elect one.
|
||||||
@ -497,3 +500,38 @@ export function getTransitiveHeaders(req: Request): {[key: string]: string} {
|
|||||||
...(Origin ? { Origin } : undefined),
|
...(Origin ? { Origin } : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const signInStatusCookieName = sessionCookieName + '_status';
|
||||||
|
|
||||||
|
// We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin.
|
||||||
|
// Its value is SignInStatus ("S", "M" or unset). This middleware keeps this cookie in sync with
|
||||||
|
// the session state.
|
||||||
|
//
|
||||||
|
// Note that this extra cookie isn't strictly necessary today: since it has similar settings to
|
||||||
|
// the session cookie, subdomains can infer status from that one. It is here in anticipation that
|
||||||
|
// we make sessions a host-only cookie, to avoid exposing it to externally-hosted subdomains of
|
||||||
|
// getgrist.com. In that case, the sign-in status cookie would remain a 2nd-level domain cookie.
|
||||||
|
export function signInStatusMiddleware(req: Request, resp: Response, next: NextFunction) {
|
||||||
|
const mreq = req as RequestWithLogin;
|
||||||
|
|
||||||
|
let origSignInStatus: SignInStatus = '';
|
||||||
|
if (req.headers.cookie) {
|
||||||
|
const cookies = cookie.parse(req.headers.cookie);
|
||||||
|
origSignInStatus = cookies[signInStatusCookieName] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onHeaders(resp, () => {
|
||||||
|
const newSignInStatus = getSignInStatus(mreq.session);
|
||||||
|
if (newSignInStatus !== origSignInStatus) {
|
||||||
|
// If not signed-in any more, set a past date to delete this cookie.
|
||||||
|
const expires = (newSignInStatus && mreq.session.cookie.expires) || new Date(0);
|
||||||
|
resp.append('Set-Cookie', cookie.serialize(signInStatusCookieName, newSignInStatus, {
|
||||||
|
httpOnly: false, // make available to client-side scripts
|
||||||
|
expires,
|
||||||
|
domain: getCookieDomain(req),
|
||||||
|
sameSite: 'lax', // same setting as for grist-sid is fine here.
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
@ -2,6 +2,8 @@ import {normalizeEmail} from 'app/common/emails';
|
|||||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
import {SessionStore} from 'app/server/lib/gristSessions';
|
import {SessionStore} from 'app/server/lib/gristSessions';
|
||||||
import * as log from 'app/server/lib/log';
|
import * as log from 'app/server/lib/log';
|
||||||
|
import {fromCallback} from 'app/server/lib/serverUtils';
|
||||||
|
import {Request} from 'express';
|
||||||
|
|
||||||
// Part of a session related to a single user.
|
// Part of a session related to a single user.
|
||||||
export interface SessionUserObj {
|
export interface SessionUserObj {
|
||||||
@ -47,6 +49,18 @@ export interface SessionObj {
|
|||||||
alive?: boolean;
|
alive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin.
|
||||||
|
// The values are:
|
||||||
|
// - "S": the user is signed in once; in this case an automatic signin can be unambiguous and seamless.
|
||||||
|
// - "M": the user is signed in multiple times.
|
||||||
|
// - "": the user is not signed in.
|
||||||
|
export type SignInStatus = 'S'|'M'|'';
|
||||||
|
|
||||||
|
export function getSignInStatus(sessionObj: SessionObj|null): SignInStatus {
|
||||||
|
const length = sessionObj?.users?.length;
|
||||||
|
return !length ? "" : (length === 1 ? 'S' : 'M');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the available user profiles from the session.
|
* Extract the available user profiles from the session.
|
||||||
*
|
*
|
||||||
@ -146,14 +160,14 @@ export class ScopedSession {
|
|||||||
// email addresses. This will update the one with a matching email address, or add a new one.
|
// email addresses. This will update the one with a matching email address, or add a new one.
|
||||||
// This is mainly used to know which emails are logged in in this session; fields like name and
|
// This is mainly used to know which emails are logged in in this session; fields like name and
|
||||||
// picture URL come from the database instead.
|
// picture URL come from the database instead.
|
||||||
public async updateUserProfile(profile: UserProfile|null): Promise<void> {
|
public async updateUserProfile(req: Request, profile: UserProfile|null): Promise<void> {
|
||||||
if (profile) {
|
if (profile) {
|
||||||
await this.operateOnScopedSession(async user => {
|
await this.operateOnScopedSession(req, async user => {
|
||||||
user.profile = profile;
|
user.profile = profile;
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.clearScopedSession();
|
await this.clearScopedSession(req);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,16 +183,16 @@ export class ScopedSession {
|
|||||||
* @return a pair [prev, current] with the state of the single user entry before and after the operation.
|
* @return a pair [prev, current] with the state of the single user entry before and after the operation.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
public async operateOnScopedSession(op: (user: SessionUserObj) =>
|
public async operateOnScopedSession(req: Request, op: (user: SessionUserObj) =>
|
||||||
Promise<SessionUserObj>): Promise<[SessionUserObj, SessionUserObj]> {
|
Promise<SessionUserObj>): Promise<[SessionUserObj, SessionUserObj]> {
|
||||||
const session = await this._getSession();
|
const session = await this._getSession();
|
||||||
const user = await this.getScopedSession(session);
|
const user = await this.getScopedSession(session);
|
||||||
const oldUser = JSON.parse(JSON.stringify(user)); // Old version to compare against.
|
const oldUser = JSON.parse(JSON.stringify(user)); // Old version to compare against.
|
||||||
const newUser = await op(JSON.parse(JSON.stringify(user))); // Modify a scratch version.
|
const newUser = await op(JSON.parse(JSON.stringify(user))); // Modify a scratch version.
|
||||||
if (Object.keys(newUser).length === 0) {
|
if (Object.keys(newUser).length === 0) {
|
||||||
await this.clearScopedSession(session);
|
await this.clearScopedSession(req, session);
|
||||||
} else {
|
} else {
|
||||||
await this._updateScopedSession(newUser, session);
|
await this._updateScopedSession(req, newUser, session);
|
||||||
}
|
}
|
||||||
return [oldUser, newUser];
|
return [oldUser, newUser];
|
||||||
}
|
}
|
||||||
@ -187,10 +201,10 @@ export class ScopedSession {
|
|||||||
* This clears the current user entry from the session.
|
* This clears the current user entry from the session.
|
||||||
* @param prev: if supplied, this session object is used rather than querying the session again.
|
* @param prev: if supplied, this session object is used rather than querying the session again.
|
||||||
*/
|
*/
|
||||||
public async clearScopedSession(prev?: SessionObj): Promise<void> {
|
public async clearScopedSession(req: Request, prev?: SessionObj): Promise<void> {
|
||||||
const session = prev || await this._getSession();
|
const session = prev || await this._getSession();
|
||||||
this._clearUser(session);
|
this._clearUser(session);
|
||||||
await this._setSession(session);
|
await this._setSession(req, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -206,10 +220,14 @@ export class ScopedSession {
|
|||||||
/**
|
/**
|
||||||
* Set the session to the supplied object.
|
* Set the session to the supplied object.
|
||||||
*/
|
*/
|
||||||
private async _setSession(session: SessionObj): Promise<void> {
|
private async _setSession(req: Request, session: SessionObj): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this._sessionStore.setAsync(this._sessionId, session);
|
await this._sessionStore.setAsync(this._sessionId, session);
|
||||||
if (!this._live) { this._sessionCache = session; }
|
if (!this._live) { this._sessionCache = session; }
|
||||||
|
const reqSession = (req as any).session;
|
||||||
|
if (reqSession?.reload) {
|
||||||
|
await fromCallback(cb => reqSession.reload(cb));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// (I've copied this from old code, not sure if continuing after a session save error is
|
// (I've copied this from old code, not sure if continuing after a session save error is
|
||||||
// something existing code depends on?)
|
// something existing code depends on?)
|
||||||
@ -224,7 +242,7 @@ export class ScopedSession {
|
|||||||
* @param prev: if supplied, this session object is used rather than querying the session again.
|
* @param prev: if supplied, this session object is used rather than querying the session again.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
private async _updateScopedSession(user: SessionUserObj, prev?: SessionObj): Promise<void> {
|
private async _updateScopedSession(req: Request, user: SessionUserObj, prev?: SessionObj): Promise<void> {
|
||||||
const profile = user.profile;
|
const profile = user.profile;
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new Error("No profile available");
|
throw new Error("No profile available");
|
||||||
@ -242,7 +260,7 @@ export class ScopedSession {
|
|||||||
if (index < 0) { index = session.users.length; }
|
if (index < 0) { index = session.users.length; }
|
||||||
session.orgToUser[this._org] = index;
|
session.orgToUser[this._org] = index;
|
||||||
session.users[index] = user;
|
session.users[index] = user;
|
||||||
await this._setSession(session);
|
await this._setSession(req, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
107
app/server/lib/DiscourseConnect.ts
Normal file
107
app/server/lib/DiscourseConnect.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Endpoint to support DiscourseConnect, to allow users to use their Grist logins for the
|
||||||
|
* Grist Community Forum.
|
||||||
|
*
|
||||||
|
* Adds one endpoint:
|
||||||
|
* - /discourse-connect: sends signed user info in a redirect to DISCOURSE_SITE.
|
||||||
|
*
|
||||||
|
* Expects environment variables:
|
||||||
|
* - DISCOURSE_SITE: URL of the Discourse site to which to redirect back.
|
||||||
|
* - DISCOURSE_CONNECT_SECRET: Secret for checking and adding signatures.
|
||||||
|
*
|
||||||
|
* This follows documentation at
|
||||||
|
* https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045
|
||||||
|
* The recommended Discourse configuration includes:
|
||||||
|
* - enable discourse connect: true
|
||||||
|
* - discourse connect url: GRIST_SITE/discourse-connect
|
||||||
|
* - discourse connect secret: DISCOURSE_CONNECT_SECRET
|
||||||
|
* - logout redirect (in Users): GRIST_SITE/logout?next=DISCOURSE_SITE
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {Express, NextFunction, Request, RequestHandler, Response} from 'express';
|
||||||
|
import type {RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
|
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
const DISCOURSE_CONNECT_SECRET = process.env.DISCOURSE_CONNECT_SECRET;
|
||||||
|
const DISCOURSE_SITE = process.env.DISCOURSE_SITE;
|
||||||
|
|
||||||
|
// A hook for dependency injection. Allows tests to override these variables on the fly.
|
||||||
|
export const Deps = {DISCOURSE_CONNECT_SECRET, DISCOURSE_SITE};
|
||||||
|
|
||||||
|
// Calculate payload signature using the given secret.
|
||||||
|
function calcSignature(payload: string, secret: string) {
|
||||||
|
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check configuration and signature of the Discourse nonce in the request.
|
||||||
|
function checkParams(req: Request, resp: Response, next: NextFunction) {
|
||||||
|
if (!Deps.DISCOURSE_SITE || !Deps.DISCOURSE_CONNECT_SECRET) {
|
||||||
|
throw new Error('DiscourseConnect not configured');
|
||||||
|
}
|
||||||
|
const payload = String(req.query.sso || '');
|
||||||
|
const signature = String(req.query.sig || '');
|
||||||
|
if (calcSignature(payload, Deps.DISCOURSE_CONNECT_SECRET) !== signature) {
|
||||||
|
throw new Error('Invalid signature for Discourse SSO request');
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(Buffer.from(payload, 'base64').toString('utf8'));
|
||||||
|
const nonce = params.get('nonce');
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error('Invalid request for Discourse SSO');
|
||||||
|
}
|
||||||
|
(req as any).discourseConnectNonce = nonce;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond to the DiscourseConnect request by redirecting back to discourse, including the user
|
||||||
|
// info and a signature into the URL parameters.
|
||||||
|
function discourseConnect(req: Request, resp: Response) {
|
||||||
|
const mreq = req as RequestWithLogin;
|
||||||
|
const nonce: string|undefined = (req as any).discourseConnectNonce;
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error('Invalid request for Discourse SSO');
|
||||||
|
}
|
||||||
|
if (!mreq.userIsAuthorized || !mreq.user?.loginEmail) {
|
||||||
|
throw new Error('User is not authenticated');
|
||||||
|
}
|
||||||
|
if (!req.query.user && mreq.users && mreq.users.length > 1) {
|
||||||
|
const origUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`);
|
||||||
|
const redirectUrl = new URL('/welcome/select-account', `${req.protocol}://${req.get('host')}`);
|
||||||
|
redirectUrl.searchParams.set('next', origUrl.toString());
|
||||||
|
return resp.redirect(redirectUrl.toString());
|
||||||
|
}
|
||||||
|
const responseObj: {[key: string]: string} = {
|
||||||
|
nonce,
|
||||||
|
email: mreq.user.loginEmail,
|
||||||
|
// We don't treat user IDs as secret, so use the same ID with Discourse directly.
|
||||||
|
external_id: String(mreq.user.id),
|
||||||
|
// We could specify the username (used for @ mentions), but let Discourse create one for us
|
||||||
|
// (it bases it on name or email). The user can change it within Discourse.
|
||||||
|
// username,
|
||||||
|
name: mreq.user.name,
|
||||||
|
...(mreq.user.picture ? {avatar_url: mreq.user.picture} : {}),
|
||||||
|
suppress_welcome_message: 'true',
|
||||||
|
};
|
||||||
|
const responseString = new URLSearchParams(responseObj).toString();
|
||||||
|
const responsePayload = Buffer.from(responseString, 'utf8').toString('base64');
|
||||||
|
const responseSignature = calcSignature(responsePayload, Deps.DISCOURSE_CONNECT_SECRET!);
|
||||||
|
const redirectUrl = new URL('/session/sso_login', Deps.DISCOURSE_SITE);
|
||||||
|
redirectUrl.search = new URLSearchParams({sso: responsePayload, sig: responseSignature}).toString();
|
||||||
|
return resp.redirect(redirectUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the endpoint for /discourse-connect, as documented at
|
||||||
|
* https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045
|
||||||
|
*/
|
||||||
|
export function addDiscourseConnectEndpoints(app: Express, options: {
|
||||||
|
userIdMiddleware: RequestHandler,
|
||||||
|
redirectToLogin: RequestHandler,
|
||||||
|
}) {
|
||||||
|
app.get('/discourse-connect',
|
||||||
|
expressWrap(checkParams), // Check early, to fail early if Discourse is misconfigured.
|
||||||
|
options.userIdMiddleware,
|
||||||
|
options.redirectToLogin,
|
||||||
|
expressWrap(discourseConnect)
|
||||||
|
);
|
||||||
|
}
|
@ -20,9 +20,10 @@ import {Usage} from 'app/gen-server/lib/Usage';
|
|||||||
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
||||||
import {addRequestUser, getUser, getUserId, isSingleUserMode,
|
import {addRequestUser, getUser, getUserId, isSingleUserMode,
|
||||||
redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
|
redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
|
||||||
import {redirectToLogin, RequestWithLogin} from 'app/server/lib/Authorizer';
|
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer';
|
||||||
import * as Comm from 'app/server/lib/Comm';
|
import * as Comm from 'app/server/lib/Comm';
|
||||||
import {create} from 'app/server/lib/create';
|
import {create} from 'app/server/lib/create';
|
||||||
|
import {addDiscourseConnectEndpoints} from 'app/server/lib/DiscourseConnect';
|
||||||
import {addDocApiRoutes} from 'app/server/lib/DocApi';
|
import {addDocApiRoutes} from 'app/server/lib/DocApi';
|
||||||
import {DocManager} from 'app/server/lib/DocManager';
|
import {DocManager} from 'app/server/lib/DocManager';
|
||||||
import {DocStorageManager} from 'app/server/lib/DocStorageManager';
|
import {DocStorageManager} from 'app/server/lib/DocStorageManager';
|
||||||
@ -30,6 +31,7 @@ import {DocWorker} from 'app/server/lib/DocWorker';
|
|||||||
import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
|
import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
|
||||||
import {expressWrap, jsonErrorHandler} from 'app/server/lib/expressWrap';
|
import {expressWrap, jsonErrorHandler} from 'app/server/lib/expressWrap';
|
||||||
import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg';
|
import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg';
|
||||||
|
import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth";
|
||||||
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer';
|
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer';
|
||||||
import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions';
|
import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions';
|
||||||
import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
|
import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
|
||||||
@ -63,7 +65,6 @@ import {AddressInfo} from 'net';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as serveStatic from "serve-static";
|
import * as serveStatic from "serve-static";
|
||||||
import { addGoogleAuthEndpoint } from "app/server/lib/GoogleAuth";
|
|
||||||
|
|
||||||
// Health checks are a little noisy in the logs, so we don't show them all.
|
// Health checks are a little noisy in the logs, so we don't show them all.
|
||||||
// We show the first N health checks:
|
// We show the first N health checks:
|
||||||
@ -588,6 +589,7 @@ export class FlexServer implements GristServer {
|
|||||||
// Create the sessionStore and related objects.
|
// Create the sessionStore and related objects.
|
||||||
const {sessions, sessionMiddleware, sessionStore} = initGristSessions(this.instanceRoot, this);
|
const {sessions, sessionMiddleware, sessionStore} = initGristSessions(this.instanceRoot, this);
|
||||||
this.app.use(sessionMiddleware);
|
this.app.use(sessionMiddleware);
|
||||||
|
this.app.use(signInStatusMiddleware);
|
||||||
|
|
||||||
// Create an endpoint for making cookies during testing.
|
// Create an endpoint for making cookies during testing.
|
||||||
this.app.get('/test/session', async (req, res) => {
|
this.app.get('/test/session', async (req, res) => {
|
||||||
@ -833,7 +835,7 @@ export class FlexServer implements GristServer {
|
|||||||
email: optStringParam(req.query.email) || 'chimpy@getgrist.com',
|
email: optStringParam(req.query.email) || 'chimpy@getgrist.com',
|
||||||
name: optStringParam(req.query.name) || 'Chimpy McBanana',
|
name: optStringParam(req.query.name) || 'Chimpy McBanana',
|
||||||
};
|
};
|
||||||
await scopedSession.updateUserProfile(profile);
|
await scopedSession.updateUserProfile(req, profile);
|
||||||
res.send(`<!doctype html>
|
res.send(`<!doctype html>
|
||||||
<html><body>
|
<html><body>
|
||||||
<p>Logged in as ${JSON.stringify(profile)}.<p>
|
<p>Logged in as ${JSON.stringify(profile)}.<p>
|
||||||
@ -859,7 +861,7 @@ export class FlexServer implements GristServer {
|
|||||||
// Express-session will save these changes.
|
// Express-session will save these changes.
|
||||||
const expressSession = (req as any).session;
|
const expressSession = (req as any).session;
|
||||||
if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }
|
if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }
|
||||||
await scopedSession.clearScopedSession();
|
await scopedSession.clearScopedSession(req);
|
||||||
resp.redirect(redirectUrl);
|
resp.redirect(redirectUrl);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -875,6 +877,11 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
const comment = await this._loginMiddleware.addEndpoints(this.app);
|
const comment = await this._loginMiddleware.addEndpoints(this.app);
|
||||||
this.info.push(['loginMiddlewareComment', comment]);
|
this.info.push(['loginMiddlewareComment', comment]);
|
||||||
|
|
||||||
|
addDiscourseConnectEndpoints(this.app, {
|
||||||
|
userIdMiddleware: this._userIdMiddleware,
|
||||||
|
redirectToLogin: this._redirectToLoginWithoutExceptionsMiddleware,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addTestingHooks(workerServers?: FlexServer[]) {
|
public async addTestingHooks(workerServers?: FlexServer[]) {
|
||||||
|
@ -18,6 +18,7 @@ export const ITestingHooks = t.iface([], {
|
|||||||
"flushAuthorizerCache": t.func("void"),
|
"flushAuthorizerCache": t.func("void"),
|
||||||
"getDocClientCounts": t.func(t.array(t.tuple("string", "number"))),
|
"getDocClientCounts": t.func(t.array(t.tuple("string", "number"))),
|
||||||
"setActiveDocTimeout": t.func("number", t.param("seconds", "number")),
|
"setActiveDocTimeout": t.func("number", t.param("seconds", "number")),
|
||||||
|
"setDiscourseConnectVar": t.func(t.union("string", "null"), t.param("varName", "string"), t.param("value", t.union("string", "null"))),
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportedTypeSuite: t.ITypeSuite = {
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
|
@ -14,4 +14,5 @@ export interface ITestingHooks {
|
|||||||
flushAuthorizerCache(): Promise<void>;
|
flushAuthorizerCache(): Promise<void>;
|
||||||
getDocClientCounts(): Promise<Array<[string, number]>>;
|
getDocClientCounts(): Promise<Array<[string, number]>>;
|
||||||
setActiveDocTimeout(seconds: number): Promise<number>;
|
setActiveDocTimeout(seconds: number): Promise<number>;
|
||||||
|
setDiscourseConnectVar(varName: string, value: string|null): Promise<string|null>;
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,7 @@ export async function getMinimalLoginSystem(): Promise<GristLoginSystem> {
|
|||||||
*/
|
*/
|
||||||
async function setSingleUser(req: Request, gristServer: GristServer) {
|
async function setSingleUser(req: Request, gristServer: GristServer) {
|
||||||
const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req);
|
const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req);
|
||||||
await scopedSession.operateOnScopedSession(async (user) => Object.assign(user, {
|
await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, {
|
||||||
profile: getDefaultProfile()
|
profile: getDefaultProfile()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -194,7 +194,7 @@ export class SamlConfig {
|
|||||||
log.info(`SamlConfig: got SAML response for ${profile.email} (${profile.name}) redirecting to ${redirectUrl}`);
|
log.info(`SamlConfig: got SAML response for ${profile.email} (${profile.name}) redirecting to ${redirectUrl}`);
|
||||||
|
|
||||||
const scopedSession = sessions.getOrCreateSessionFromRequest(req, state.sessionId);
|
const scopedSession = sessions.getOrCreateSessionFromRequest(req, state.sessionId);
|
||||||
await scopedSession.operateOnScopedSession(async (user) => Object.assign(user, {
|
await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, {
|
||||||
profile,
|
profile,
|
||||||
samlSessionIndex,
|
samlSessionIndex,
|
||||||
samlNameId,
|
samlNameId,
|
||||||
|
@ -2,9 +2,11 @@ import * as net from 'net';
|
|||||||
|
|
||||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
import {Deps as ActiveDocDeps} from 'app/server/lib/ActiveDoc';
|
import {Deps as ActiveDocDeps} from 'app/server/lib/ActiveDoc';
|
||||||
|
import {Deps as DiscourseConnectDeps} from 'app/server/lib/DiscourseConnect';
|
||||||
import * as Comm from 'app/server/lib/Comm';
|
import * as Comm from 'app/server/lib/Comm';
|
||||||
import * as log from 'app/server/lib/log';
|
import * as log from 'app/server/lib/log';
|
||||||
import {IMessage, Rpc} from 'grain-rpc';
|
import {IMessage, Rpc} from 'grain-rpc';
|
||||||
|
import {Request} from 'express';
|
||||||
import * as t from 'ts-interface-checker';
|
import * as t from 'ts-interface-checker';
|
||||||
import {FlexServer} from './FlexServer';
|
import {FlexServer} from './FlexServer';
|
||||||
import {ITestingHooks} from './ITestingHooks';
|
import {ITestingHooks} from './ITestingHooks';
|
||||||
@ -74,7 +76,8 @@ export class TestingHooks implements ITestingHooks {
|
|||||||
log.info("TestingHooks.setLoginSessionProfile called with", gristSidCookie, profile, org);
|
log.info("TestingHooks.setLoginSessionProfile called with", gristSidCookie, profile, org);
|
||||||
const sessionId = this._comm.getSessionIdFromCookie(gristSidCookie);
|
const sessionId = this._comm.getSessionIdFromCookie(gristSidCookie);
|
||||||
const scopedSession = this._comm.getOrCreateSession(sessionId, {org});
|
const scopedSession = this._comm.getOrCreateSession(sessionId, {org});
|
||||||
return await scopedSession.updateUserProfile(profile);
|
const req = {} as Request;
|
||||||
|
return await scopedSession.updateUserProfile(req, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setServerVersion(version: string|null): Promise<void> {
|
public async setServerVersion(version: string|null): Promise<void> {
|
||||||
@ -180,4 +183,15 @@ export class TestingHooks implements ITestingHooks {
|
|||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sets env vars for the DiscourseConnect module, and returns the previous value.
|
||||||
|
public async setDiscourseConnectVar(varName: string, value: string|null): Promise<string|null> {
|
||||||
|
const key = varName as keyof typeof DiscourseConnectDeps;
|
||||||
|
const prev = DiscourseConnectDeps[key] || null;
|
||||||
|
if (value == null) {
|
||||||
|
delete DiscourseConnectDeps[key];
|
||||||
|
} else {
|
||||||
|
DiscourseConnectDeps[key] = value;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,22 +100,6 @@ export function initGristSessions(instanceRoot: string, server: GristServer) {
|
|||||||
const sessionStoreCreator = createSessionStoreFactory(sessionsDB);
|
const sessionStoreCreator = createSessionStoreFactory(sessionsDB);
|
||||||
const sessionStore = sessionStoreCreator();
|
const sessionStore = sessionStoreCreator();
|
||||||
|
|
||||||
const adaptDomain = process.env.GRIST_ADAPT_DOMAIN === 'true';
|
|
||||||
const fixedDomain = process.env.GRIST_SESSION_DOMAIN || process.env.GRIST_DOMAIN;
|
|
||||||
|
|
||||||
const getCookieDomain = (req: express.Request) => {
|
|
||||||
const mreq = req as RequestWithOrg;
|
|
||||||
if (mreq.isCustomHost) {
|
|
||||||
// For custom hosts, omit the domain to make it a "host-only" cookie, to avoid it being
|
|
||||||
// included into subdomain requests (since we would not control all the subdomains).
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (adaptDomain) {
|
|
||||||
const reqDomain = parseSubdomain(req.get('host'));
|
|
||||||
if (reqDomain.base) { return reqDomain.base.split(':')[0]; }
|
|
||||||
}
|
|
||||||
return fixedDomain;
|
|
||||||
};
|
|
||||||
// Use a separate session IDs for custom domains than for native ones. Because a custom domain
|
// Use a separate session IDs for custom domains than for native ones. Because a custom domain
|
||||||
// cookie could be stolen (with some effort) by the custom domain's owner, we limit the damage
|
// cookie could be stolen (with some effort) by the custom domain's owner, we limit the damage
|
||||||
// by only honoring custom-domain cookies for requests to that domain.
|
// by only honoring custom-domain cookies for requests to that domain.
|
||||||
@ -148,5 +132,23 @@ export function initGristSessions(instanceRoot: string, server: GristServer) {
|
|||||||
|
|
||||||
const sessions = new Sessions(sessionSecret, sessionStore);
|
const sessions = new Sessions(sessionSecret, sessionStore);
|
||||||
|
|
||||||
return {sessions, sessionSecret, sessionStore, sessionMiddleware, sessionStoreCreator};
|
return {sessions, sessionSecret, sessionStore, sessionMiddleware};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCookieDomain(req: express.Request) {
|
||||||
|
const mreq = req as RequestWithOrg;
|
||||||
|
if (mreq.isCustomHost) {
|
||||||
|
// For custom hosts, omit the domain to make it a "host-only" cookie, to avoid it being
|
||||||
|
// included into subdomain requests (since we would not control all the subdomains).
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adaptDomain = process.env.GRIST_ADAPT_DOMAIN === 'true';
|
||||||
|
const fixedDomain = process.env.GRIST_SESSION_DOMAIN || process.env.GRIST_DOMAIN;
|
||||||
|
|
||||||
|
if (adaptDomain) {
|
||||||
|
const reqDomain = parseSubdomain(req.get('host'));
|
||||||
|
if (reqDomain.base) { return reqDomain.base.split(':')[0]; }
|
||||||
|
}
|
||||||
|
return fixedDomain;
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,8 @@ export class TestServerMerged implements IMochaServer {
|
|||||||
// Set low limits for uploads, for testing.
|
// Set low limits for uploads, for testing.
|
||||||
GRIST_MAX_UPLOAD_IMPORT_MB: '1',
|
GRIST_MAX_UPLOAD_IMPORT_MB: '1',
|
||||||
GRIST_MAX_UPLOAD_ATTACHMENT_MB: '2',
|
GRIST_MAX_UPLOAD_ATTACHMENT_MB: '2',
|
||||||
|
// The following line only matters for testing with non-localhost URLs, which some tests do.
|
||||||
|
GRIST_SERVE_SAME_ORIGIN: 'true',
|
||||||
APP_UNTRUSTED_URL : "http://localhost:18096",
|
APP_UNTRUSTED_URL : "http://localhost:18096",
|
||||||
// Run with HOME_PORT, STATIC_PORT, DOC_PORT, DOC_WORKER_COUNT in the environment to override.
|
// Run with HOME_PORT, STATIC_PORT, DOC_PORT, DOC_WORKER_COUNT in the environment to override.
|
||||||
...(isCore ? {
|
...(isCore ? {
|
||||||
|
Loading…
Reference in New Issue
Block a user