mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
parent
94a7b750a8
commit
ae76b25311
176
app/client/lib/MultiUserManager.ts
Normal file
176
app/client/lib/MultiUserManager.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import {computed, Computed, dom, DomElementArg, IDisposableOwner, Observable, styled} from "grainjs";
|
||||||
|
import {cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal, cssAnimatedModal} from 'app/client/ui2018/modals';
|
||||||
|
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
|
import {UserManagerModel, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {textarea} from "app/client/ui/inputs";
|
||||||
|
import {BasicRole, VIEWER, NonGuestRole, isBasicRole} from "app/common/roles";
|
||||||
|
import {menu, menuItem} from 'app/client/ui2018/menus';
|
||||||
|
|
||||||
|
function parseEmailList(emailListRaw: string): Array<string> {
|
||||||
|
return emailListRaw
|
||||||
|
.split('\n')
|
||||||
|
.map(email => email.trim().toLowerCase())
|
||||||
|
.filter(email => email !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEmail(email: string): boolean {
|
||||||
|
const mailformat = /\S+@\S+\.\S+/;
|
||||||
|
return mailformat.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMultiUserManagerModal(
|
||||||
|
owner: IDisposableOwner,
|
||||||
|
model: UserManagerModel,
|
||||||
|
onAdd: (email: string, role: NonGuestRole) => void,
|
||||||
|
) {
|
||||||
|
const emailListObs = Observable.create(owner, "");
|
||||||
|
const rolesObs = Observable.create<BasicRole>(owner, VIEWER);
|
||||||
|
const isValidObs = Observable.create(owner, true);
|
||||||
|
|
||||||
|
const enableAdd: Computed<boolean> = computed((use) => Boolean(use(emailListObs) && use(rolesObs) && use(isValidObs)));
|
||||||
|
|
||||||
|
const save = (ctl: IModalControl) => {
|
||||||
|
const emailList = parseEmailList(emailListObs.get());
|
||||||
|
const role = rolesObs.get();
|
||||||
|
if (emailList.some(email => !validateEmail(email))) {
|
||||||
|
isValidObs.set(false);
|
||||||
|
} else {
|
||||||
|
emailList.forEach(email => onAdd(email, role));
|
||||||
|
ctl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modal(ctl => [
|
||||||
|
{ style: 'padding: 0;' },
|
||||||
|
dom.cls(cssAnimatedModal.className),
|
||||||
|
cssTitle(
|
||||||
|
'Invite Users',
|
||||||
|
testId('um-header'),
|
||||||
|
),
|
||||||
|
cssModalBody(
|
||||||
|
cssUserManagerBody(
|
||||||
|
buildEmailsTextarea(emailListObs, isValidObs),
|
||||||
|
dom.maybe(use => !use(isValidObs), () => cssErrorMessage('At least one email is invalid')),
|
||||||
|
cssInheritRoles(
|
||||||
|
dom('span', 'Access: '),
|
||||||
|
buildRolesSelect(rolesObs, model)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssModalButtons(
|
||||||
|
{ style: 'margin: 32px 64px; display: flex;' },
|
||||||
|
bigPrimaryButton('Confirm',
|
||||||
|
dom.boolAttr('disabled', (use) => !use(enableAdd)),
|
||||||
|
dom.on('click', () => {save(ctl)}),
|
||||||
|
testId('um-confirm')
|
||||||
|
),
|
||||||
|
bigBasicButton(
|
||||||
|
'Cancel',
|
||||||
|
dom.on('click', () => ctl.close()),
|
||||||
|
testId('um-cancel')
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRolesSelect(
|
||||||
|
roleSelectedObs: Observable<BasicRole>,
|
||||||
|
model: UserManagerModel,
|
||||||
|
) {
|
||||||
|
const allRoles = (model.isOrg ? model.orgUserSelectOptions : model.userSelectOptions)
|
||||||
|
.filter((x): x is {value: BasicRole, label: string} => isBasicRole(x.value));
|
||||||
|
return cssOptionBtn(
|
||||||
|
menu(() => [
|
||||||
|
dom.forEach(allRoles, (_role) =>
|
||||||
|
menuItem(() => roleSelectedObs.set(_role.value), _role.label,
|
||||||
|
testId(`um-role-option`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
dom.text((use) => {
|
||||||
|
// Get the label of the active role.
|
||||||
|
const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(roleSelectedObs) === _role.value);
|
||||||
|
return activeRole ? activeRole.label : "";
|
||||||
|
}),
|
||||||
|
cssCollapseIcon('Collapse'),
|
||||||
|
testId('um-role-select')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function buildEmailsTextarea(
|
||||||
|
emailListObs: Observable<string>,
|
||||||
|
isValidObs: Observable<boolean>,
|
||||||
|
...args: DomElementArg[]
|
||||||
|
) {
|
||||||
|
return cssTextarea(emailListObs,
|
||||||
|
{onInput: true, isValid: isValidObs},
|
||||||
|
{placeholder: "Enter one email address per line"},
|
||||||
|
dom.on('change', (_ev) => isValidObs.set(true)),
|
||||||
|
...args,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const cssTitle = styled(cssModalTitle, `
|
||||||
|
margin: 40px 64px 0 64px;
|
||||||
|
|
||||||
|
@media ${mediaXSmall} {
|
||||||
|
& {
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssInheritRoles = styled('span', `
|
||||||
|
margin: 13px 63px 42px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssErrorMessage = styled('span', `
|
||||||
|
margin: 0 63px;
|
||||||
|
color: ${theme.errorText};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssOptionBtn = styled('span', `
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
color: ${theme.controlFg};
|
||||||
|
cursor: pointer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCollapseIcon = styled(icon, `
|
||||||
|
margin-top: 1px;
|
||||||
|
background-color: ${theme.controlFg};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssAccessDetailsBody = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 600px;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssUserManagerBody = styled(cssAccessDetailsBody, `
|
||||||
|
height: 374px;
|
||||||
|
border-bottom: 1px solid ${theme.modalBorderDark};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTextarea = styled(textarea, `
|
||||||
|
margin: 16px 63px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
resize: none;
|
||||||
|
border: 1px solid ${theme.inputBorder};
|
||||||
|
color: ${theme.inputFg};
|
||||||
|
background-color: ${theme.inputBg};
|
||||||
|
flex: 1 1 0;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
font-family: ${vars.fontFamily};
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: ${theme.inputPlaceholderFg};
|
||||||
|
}
|
||||||
|
`);
|
@ -10,12 +10,13 @@ import {capitalizeFirstWord, isLongerThan} from 'app/common/gutil';
|
|||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI';
|
import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI';
|
||||||
import {Computed, Disposable, dom, DomElementArg, keyframes, Observable, observable, styled} from 'grainjs';
|
import {Computed, Disposable, dom, DomElementArg, Observable, observable, styled} from 'grainjs';
|
||||||
import pick = require('lodash/pick');
|
import pick = require('lodash/pick');
|
||||||
|
|
||||||
import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex';
|
import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex';
|
||||||
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
||||||
import {setTestState} from 'app/client/lib/testState';
|
import {setTestState} from 'app/client/lib/testState';
|
||||||
|
import {buildMultiUserManagerModal} from 'app/client/lib/MultiUserManager';
|
||||||
import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager';
|
import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager';
|
||||||
import {AppModel} from 'app/client/models/AppModel';
|
import {AppModel} from 'app/client/models/AppModel';
|
||||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
@ -39,7 +40,7 @@ import {cssLink} from 'app/client/ui2018/links';
|
|||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||||
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl,
|
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl,
|
||||||
modal} from 'app/client/ui2018/modals';
|
modal, cssAnimatedModal} from 'app/client/ui2018/modals';
|
||||||
|
|
||||||
export interface IUserManagerOptions {
|
export interface IUserManagerOptions {
|
||||||
permissionData: Promise<PermissionData>;
|
permissionData: Promise<PermissionData>;
|
||||||
@ -148,7 +149,8 @@ function buildUserManagerModal(
|
|||||||
cssModalBody(
|
cssModalBody(
|
||||||
cssBody(
|
cssBody(
|
||||||
new UserManager(
|
new UserManager(
|
||||||
model, pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt', 'resource')
|
model,
|
||||||
|
pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt', 'resource')
|
||||||
).buildDom()
|
).buildDom()
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -198,7 +200,10 @@ function buildUserManagerModal(
|
|||||||
*/
|
*/
|
||||||
export class UserManager extends Disposable {
|
export class UserManager extends Disposable {
|
||||||
private _dom: HTMLDivElement;
|
private _dom: HTMLDivElement;
|
||||||
constructor(private _model: UserManagerModel, private _options: {
|
|
||||||
|
constructor(
|
||||||
|
private _model: UserManagerModel,
|
||||||
|
private _options: {
|
||||||
linkToCopy?: string,
|
linkToCopy?: string,
|
||||||
docPageModel?: DocPageModel,
|
docPageModel?: DocPageModel,
|
||||||
appModel?: AppModel,
|
appModel?: AppModel,
|
||||||
@ -209,11 +214,6 @@ export class UserManager extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
const acMemberEmail = this.autoDispose(new ACMemberEmail(
|
|
||||||
this._onAdd.bind(this),
|
|
||||||
this._model.membersEdited.get(),
|
|
||||||
this._options.prompt,
|
|
||||||
));
|
|
||||||
if (this._model.isPublicMember) {
|
if (this._model.isPublicMember) {
|
||||||
return this._buildSelfPublicAccessDom();
|
return this._buildSelfPublicAccessDom();
|
||||||
}
|
}
|
||||||
@ -222,6 +222,12 @@ export class UserManager extends Disposable {
|
|||||||
return this._buildSelfAccessDom();
|
return this._buildSelfAccessDom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const acMemberEmail = this.autoDispose(new ACMemberEmail(
|
||||||
|
this._onAdd.bind(this),
|
||||||
|
this._model.membersEdited.get(),
|
||||||
|
this._options.prompt,
|
||||||
|
));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
acMemberEmail.buildDom(),
|
acMemberEmail.buildDom(),
|
||||||
this._buildOptionsDom(),
|
this._buildOptionsDom(),
|
||||||
@ -233,6 +239,16 @@ export class UserManager extends Disposable {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _onAddOrEdit(email: string, role: roles.NonGuestRole) {
|
||||||
|
const members = this._model.membersEdited.get();
|
||||||
|
const maybeMember = members.find(m => m.email === email);
|
||||||
|
if (maybeMember) {
|
||||||
|
maybeMember.access.set(role);
|
||||||
|
} else {
|
||||||
|
this._onAdd(email, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _onAdd(email: string, role: roles.NonGuestRole) {
|
private _onAdd(email: string, role: roles.NonGuestRole) {
|
||||||
this._model.add(email, role);
|
this._model.add(email, role);
|
||||||
// Make sure the entry we have just added is actually visible - confusing if not.
|
// Make sure the entry we have just added is actually visible - confusing if not.
|
||||||
@ -244,7 +260,19 @@ export class UserManager extends Disposable {
|
|||||||
private _buildOptionsDom(): Element {
|
private _buildOptionsDom(): Element {
|
||||||
const publicMember = this._model.publicMember;
|
const publicMember = this._model.publicMember;
|
||||||
let tooltipControl: ITooltipControl | undefined;
|
let tooltipControl: ITooltipControl | undefined;
|
||||||
return cssOptionRow(
|
return dom('div',
|
||||||
|
cssOptionRowMultiple(
|
||||||
|
icon('AddUser'),
|
||||||
|
cssLabel('Invite multiple'),
|
||||||
|
dom.on('click', (_ev) => buildMultiUserManagerModal(
|
||||||
|
this,
|
||||||
|
this._model,
|
||||||
|
(email, role) => {
|
||||||
|
this._onAddOrEdit(email, role);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
),
|
||||||
|
cssOptionRow(
|
||||||
// TODO: Consider adding a tooltip explaining inheritance. A brief text caption may
|
// TODO: Consider adding a tooltip explaining inheritance. A brief text caption may
|
||||||
// be used to fill whitespace in org UserManager.
|
// be used to fill whitespace in org UserManager.
|
||||||
this._model.isOrg ? null : dom('span', { style: `float: left;` },
|
this._model.isOrg ? null : dom('span', { style: `float: left;` },
|
||||||
@ -278,7 +306,8 @@ export class UserManager extends Disposable {
|
|||||||
tooltipControl = ctl;
|
tooltipControl = ctl;
|
||||||
return 'Allow anyone with the link to open.';
|
return 'Allow anyone with the link to open.';
|
||||||
}),
|
}),
|
||||||
) : null
|
) : null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -674,6 +703,24 @@ const cssOptionRow = styled('div', `
|
|||||||
margin: 0 63px 23px 63px;
|
margin: 0 63px 23px 63px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssOptionRowMultiple = styled('div', `
|
||||||
|
margin: 0 63px 12px 63px;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
color: ${theme.controlFg};
|
||||||
|
--icon-color: ${theme.controlFg};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${theme.controlHoverFg};
|
||||||
|
--icon-color: ${theme.controlHoverFg};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssLabel = styled('span', `
|
||||||
|
margin-left: 4px;
|
||||||
|
`);
|
||||||
|
|
||||||
const cssOptionBtn = styled('span', `
|
const cssOptionBtn = styled('span', `
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: ${vars.mediumFontSize};
|
font-size: ${vars.mediumFontSize};
|
||||||
@ -735,17 +782,6 @@ const cssOrgDomain = styled('span', `
|
|||||||
color: ${theme.accentText};
|
color: ${theme.accentText};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssFadeInFromTop = keyframes(`
|
|
||||||
from {top: -250px; opacity: 0}
|
|
||||||
to {top: 0; opacity: 1}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssAnimatedModal = styled('div', `
|
|
||||||
animation-name: ${cssFadeInFromTop};
|
|
||||||
animation-duration: 0.4s;
|
|
||||||
position: relative;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssTitle = styled(cssModalTitle, `
|
const cssTitle = styled(cssModalTitle, `
|
||||||
margin: 40px 64px 0 64px;
|
margin: 40px 64px 0 64px;
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {dom, DomElementArg, Observable, styled} from 'grainjs';
|
import {dom, IDomArgs, DomElementArg, IInputOptions, Observable, styled, subscribe} from 'grainjs';
|
||||||
|
|
||||||
export const cssInput = styled('input', `
|
export const cssInput = styled('input', `
|
||||||
font-size: ${vars.mediumFontSize};
|
font-size: ${vars.mediumFontSize};
|
||||||
@ -46,3 +46,25 @@ export function textInput(obs: Observable<string>, ...args: DomElementArg[]): HT
|
|||||||
...args,
|
...args,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function textarea(
|
||||||
|
obs: Observable<string>, options: IInputOptions, ...args: IDomArgs<HTMLTextAreaElement>
|
||||||
|
): HTMLTextAreaElement {
|
||||||
|
|
||||||
|
const isValid = options.isValid;
|
||||||
|
|
||||||
|
function setValue(elem: HTMLTextAreaElement) {
|
||||||
|
obs.set(elem.value);
|
||||||
|
if (isValid) { isValid.set(elem.validity.valid); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return dom('textarea', ...args,
|
||||||
|
dom.prop('value', obs),
|
||||||
|
(isValid ?
|
||||||
|
(elem) => dom.autoDisposeElem(elem,
|
||||||
|
subscribe(obs, (use) => isValid.set(elem.checkValidity()))) :
|
||||||
|
null),
|
||||||
|
options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null,
|
||||||
|
dom.on('change', (e, elem) => setValue(elem)),
|
||||||
|
);
|
||||||
|
}
|
@ -602,3 +602,14 @@ const cssModalSpinner = styled('div', `
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssFadeInFromTop = keyframes(`
|
||||||
|
from {top: -250px; opacity: 0}
|
||||||
|
to {top: 0; opacity: 1}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssAnimatedModal = styled('div', `
|
||||||
|
animation-name: ${cssFadeInFromTop};
|
||||||
|
animation-duration: 0.4s;
|
||||||
|
position: relative;
|
||||||
|
`);
|
||||||
|
Loading…
Reference in New Issue
Block a user