2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* This module exports a UserManager component, consisting of a list of emails, each with an
|
|
|
|
* associated role (See app/common/roles), and a way to change roles, and add or remove new users.
|
|
|
|
* The component is instantiated as a modal with a confirm button to pass changes to the server.
|
|
|
|
*
|
|
|
|
* It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
|
|
|
|
*/
|
2021-10-25 16:54:04 +00:00
|
|
|
import {commonUrls} from 'app/common/gristUrls';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
|
|
|
import * as roles from 'app/common/roles';
|
|
|
|
import {PermissionData, UserAPI} from 'app/common/UserAPI';
|
|
|
|
import {computed, Computed, Disposable, observable, Observable} from 'grainjs';
|
2021-10-01 14:24:23 +00:00
|
|
|
import {dom, DomElementArg, styled} from 'grainjs';
|
2021-10-25 16:54:04 +00:00
|
|
|
import pick = require('lodash/pick');
|
2020-10-02 15:10:00 +00:00
|
|
|
import {cssMenuItem} from 'popweasel';
|
|
|
|
|
|
|
|
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
|
|
|
import {setTestState} from 'app/client/lib/testState';
|
2021-10-25 16:54:04 +00:00
|
|
|
import {AppModel} from 'app/client/models/AppModel';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
|
|
|
import {reportError} from 'app/client/models/errors';
|
2020-11-26 02:29:13 +00:00
|
|
|
import {urlState} from 'app/client/models/gristUrlState';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel';
|
|
|
|
import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel';
|
|
|
|
import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel';
|
|
|
|
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
|
|
|
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
|
|
|
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
|
2021-10-01 14:24:23 +00:00
|
|
|
import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMemberImage, cssMemberListItem,
|
2021-10-25 16:54:04 +00:00
|
|
|
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem,
|
|
|
|
cssRemoveIcon} from 'app/client/ui/UserItem';
|
2020-10-02 15:10:00 +00:00
|
|
|
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';
|
2020-12-04 23:29:29 +00:00
|
|
|
import {cssLink} from 'app/client/ui2018/links';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
2020-12-04 23:29:29 +00:00
|
|
|
import {cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
export interface IUserManagerOptions {
|
|
|
|
permissionData: Promise<PermissionData>;
|
|
|
|
activeEmail: string|null;
|
|
|
|
resourceType: ResourceType;
|
|
|
|
resourceId: string|number;
|
|
|
|
docPageModel?: DocPageModel;
|
2021-10-25 16:54:04 +00:00
|
|
|
appModel?: AppModel; // If present, we offer access to a nested team-level dialog.
|
2020-11-26 02:29:13 +00:00
|
|
|
linkToCopy?: string;
|
2021-10-25 16:54:04 +00:00
|
|
|
reload?: () => Promise<PermissionData>;
|
2020-10-02 15:10:00 +00:00
|
|
|
onSave?: () => Promise<unknown>;
|
2021-10-25 16:54:04 +00:00
|
|
|
prompt?: { // If set, user manager should open with this email filled in and ready to go.
|
|
|
|
email: string;
|
|
|
|
};
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Returns an instance of UserManagerModel given IUserManagerOptions. Makes the async call for the
|
|
|
|
// required properties of the options.
|
|
|
|
async function getModel(options: IUserManagerOptions): Promise<UserManagerModelImpl> {
|
|
|
|
const permissionData = await options.permissionData;
|
2021-10-25 16:54:04 +00:00
|
|
|
return new UserManagerModelImpl(permissionData, options.resourceType,
|
|
|
|
pick(options, ['activeEmail', 'reload', 'appModel', 'docPageModel']));
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Public interface for creating the UserManager in the app. Creates a modal that includes
|
|
|
|
* the UserManager menu with save and cancel buttons.
|
|
|
|
*/
|
|
|
|
export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOptions) {
|
|
|
|
const modelObs: Observable<UserManagerModel|null> = observable(null);
|
|
|
|
|
2020-12-04 23:29:29 +00:00
|
|
|
async function onConfirm(ctl: IModalControl) {
|
|
|
|
const model = modelObs.get();
|
|
|
|
if (model) {
|
|
|
|
// Save changes to the server, reporting any errors to the app.
|
|
|
|
try {
|
|
|
|
if (model.isAnythingChanged.get()) {
|
|
|
|
await model.save(userApi, options.resourceId);
|
|
|
|
}
|
|
|
|
await options.onSave?.();
|
|
|
|
ctl.close();
|
|
|
|
} catch (err) {
|
|
|
|
reportError(err);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
ctl.close();
|
|
|
|
}
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// Get the model and assign it to the observable. Report errors to the app.
|
|
|
|
getModel(options)
|
|
|
|
.then(model => modelObs.set(model))
|
|
|
|
.catch(reportError);
|
|
|
|
modal(ctl => [
|
|
|
|
// We set the padding to 0 since the body scroll shadows extend to the edge of the modal.
|
|
|
|
{ style: 'padding: 0;' },
|
|
|
|
|
|
|
|
cssModalTitle(
|
|
|
|
{ style: 'margin: 40px 64px 0 64px;' },
|
2020-12-04 23:29:29 +00:00
|
|
|
`Invite people to ${renderType(options.resourceType)}`,
|
|
|
|
(options.resourceType === 'document' ? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
|
2020-10-02 15:10:00 +00:00
|
|
|
testId('um-header')
|
|
|
|
),
|
|
|
|
|
|
|
|
cssModalBody(
|
|
|
|
cssUserManagerBody(
|
|
|
|
// TODO: Show a loading indicator before the model is loaded.
|
2021-10-25 16:54:04 +00:00
|
|
|
dom.maybe(modelObs, model => new UserManager(
|
|
|
|
model, pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt')
|
|
|
|
).buildDom()),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
|
|
|
),
|
|
|
|
cssModalButtons(
|
|
|
|
{ style: 'margin: 32px 64px; display: flex;' },
|
|
|
|
bigPrimaryButton('Confirm',
|
2020-12-04 23:29:29 +00:00
|
|
|
dom.boolAttr('disabled', (use) => !use(modelObs) || !use(use(modelObs)!.isAnythingChanged)),
|
|
|
|
dom.on('click', () => onConfirm(ctl)),
|
2020-10-02 15:10:00 +00:00
|
|
|
testId('um-confirm')
|
|
|
|
),
|
|
|
|
bigBasicButton('Cancel',
|
|
|
|
dom.on('click', () => ctl.close()),
|
|
|
|
testId('um-cancel')
|
|
|
|
),
|
2021-03-25 19:17:25 +00:00
|
|
|
cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})},
|
|
|
|
dom.text(use => (use(modelObs) && use(use(modelObs)!.isAnythingChanged)) ? 'Save & ' : ''),
|
|
|
|
'Open Access Rules',
|
|
|
|
dom.on('click', (ev) => {
|
|
|
|
ev.preventDefault();
|
|
|
|
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
|
|
|
|
}),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
|
|
|
testId('um-buttons'),
|
|
|
|
)
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* See module documentation for overview.
|
|
|
|
*
|
|
|
|
* Usage:
|
|
|
|
* const um = new UserManager(model);
|
|
|
|
* um.buildDom();
|
|
|
|
*/
|
|
|
|
export class UserManager extends Disposable {
|
2021-10-25 16:54:04 +00:00
|
|
|
private _dom: HTMLDivElement;
|
|
|
|
constructor(private _model: UserManagerModel, private _options: {
|
|
|
|
linkToCopy?: string,
|
|
|
|
docPageModel?: DocPageModel,
|
|
|
|
appModel?: AppModel,
|
|
|
|
prompt?: {email: string}
|
|
|
|
}) {
|
2020-10-02 15:10:00 +00:00
|
|
|
super();
|
|
|
|
}
|
|
|
|
|
|
|
|
public buildDom() {
|
2021-10-25 16:54:04 +00:00
|
|
|
const memberEmail = this.autoDispose(new MemberEmail(this._onAdd.bind(this),
|
|
|
|
this._options.prompt));
|
2020-10-02 15:10:00 +00:00
|
|
|
return [
|
|
|
|
memberEmail.buildDom(),
|
|
|
|
this._buildOptionsDom(),
|
2021-10-25 16:54:04 +00:00
|
|
|
this._dom = shadowScroll(
|
2020-10-02 15:10:00 +00:00
|
|
|
testId('um-members'),
|
|
|
|
this._buildPublicAccessMember(),
|
|
|
|
dom.forEach(this._model.membersEdited, (member) => this._buildMemberDom(member)),
|
|
|
|
),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2021-10-25 16:54:04 +00:00
|
|
|
private _onAdd(email: string, role: roles.NonGuestRole) {
|
|
|
|
this._model.add(email, role);
|
|
|
|
// Make sure the entry we have just added is actually visible - confusing if not.
|
|
|
|
Array.from(this._dom.querySelectorAll('.member-email'))
|
|
|
|
.find(el => el.textContent === email)
|
|
|
|
?.scrollIntoView();
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
private _buildOptionsDom(): Element {
|
|
|
|
const publicMember = this._model.publicMember;
|
|
|
|
return cssOptionRow(
|
|
|
|
// TODO: Consider adding a tooltip explaining inheritance. A brief text caption may
|
|
|
|
// be used to fill whitespace in org UserManager.
|
|
|
|
this._model.isOrg ? null : dom('span', { style: `float: left;` },
|
|
|
|
dom('span', 'Inherit access: '),
|
|
|
|
this._inheritRoleSelector()
|
|
|
|
),
|
|
|
|
publicMember ? dom('span', { style: `float: right;` },
|
|
|
|
dom('span', 'Public access: '),
|
|
|
|
cssOptionBtn(
|
|
|
|
menu(() => [
|
|
|
|
menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)),
|
|
|
|
menuItem(() => publicMember.access.set(null), 'Off',
|
|
|
|
// Disable null access if anonymous access is inherited.
|
|
|
|
dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null),
|
|
|
|
testId(`um-public-option`)
|
|
|
|
),
|
|
|
|
// If the 'Off' setting is disabled, show an explanation.
|
|
|
|
dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText(
|
|
|
|
`Public access inherited from ${getResourceParent(this._model.resourceType)}. ` +
|
|
|
|
`To remove, set 'Inherit access' option to 'None'.`))
|
|
|
|
]),
|
|
|
|
dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'),
|
|
|
|
cssCollapseIcon('Collapse'),
|
|
|
|
testId('um-public-access')
|
|
|
|
)
|
|
|
|
) : null
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Build a single member row.
|
|
|
|
private _buildMemberDom(member: IEditableMember) {
|
|
|
|
const disableRemove = Computed.create(null, (use) =>
|
|
|
|
Boolean(this._model.isActiveUser(member) || use(member.inheritedAccess)));
|
|
|
|
return dom('div',
|
|
|
|
dom.autoDispose(disableRemove),
|
|
|
|
dom.maybe((use) => use(member.effectiveAccess) && use(member.effectiveAccess) !== roles.GUEST, () =>
|
|
|
|
cssMemberListItem(
|
2021-10-25 16:54:04 +00:00
|
|
|
cssMemberListItem.cls('-removed', member.isRemoved),
|
2020-10-02 15:10:00 +00:00
|
|
|
cssMemberImage(
|
|
|
|
createUserImage(getFullUser(member), 'large')
|
|
|
|
),
|
|
|
|
cssMemberText(
|
2021-10-25 16:54:04 +00:00
|
|
|
cssMemberPrimary(member.name || dom('span', member.email, dom.cls('member-email'), testId('um-email'))),
|
|
|
|
member.name ? cssMemberSecondary(member.email, dom.cls('member-email'), testId('um-email')) : null,
|
|
|
|
this._buildAnnotationDom(member),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
|
|
|
member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
|
|
|
|
member.inheritedAccess, this._model.isActiveUser(member)),
|
|
|
|
// Only show delete buttons when editing the org users or when a user is being newly
|
|
|
|
// added to any resource. In workspace/doc UserManager instances we want to see all the
|
|
|
|
// users in the org, whether or not they have access to the resource of interest. They may
|
|
|
|
// be denied access via the role dropdown.
|
|
|
|
// Show the undo icon when an item has been removed but its removal has not been saved to
|
|
|
|
// the server.
|
|
|
|
cssMemberBtn(
|
|
|
|
// Button icon.
|
|
|
|
member.isRemoved ? cssUndoIcon('Undo', testId('um-member-undo')) :
|
|
|
|
cssRemoveIcon('Remove', testId('um-member-delete')),
|
|
|
|
cssMemberBtn.cls('-disabled', disableRemove),
|
|
|
|
// Click handler.
|
|
|
|
dom.on('click', () => disableRemove.get() ||
|
|
|
|
(member.isRemoved ? this._model.add(member.email, member.access.get()) :
|
|
|
|
this._model.remove(member)))
|
|
|
|
),
|
|
|
|
testId('um-member')
|
|
|
|
)
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-10-25 16:54:04 +00:00
|
|
|
// Build an annotation for a single member.
|
|
|
|
private _buildAnnotationDom(member: IEditableMember) {
|
|
|
|
return dom.domComputed(this._model.annotations, (annotations) => {
|
|
|
|
const annotation = annotations.users.get(member.email);
|
|
|
|
if (!annotation) { return null; }
|
|
|
|
if (annotation.isSupport) {
|
|
|
|
return cssMemberType('Grist support');
|
|
|
|
}
|
|
|
|
if (annotation.isMember && annotations.hasTeam) {
|
|
|
|
return cssMemberType('Team member');
|
|
|
|
}
|
|
|
|
const collaborator = annotations.hasTeam ? 'outside collaborator' : 'collaborator';
|
|
|
|
const limit = annotation.collaboratorLimit;
|
|
|
|
if (!limit || !limit.top) { return null; }
|
|
|
|
const elements: HTMLSpanElement[] = [];
|
|
|
|
if (limit.at <= limit.top) {
|
|
|
|
elements.push(cssMemberType(`${limit.at} of ${limit.top} free ${collaborator}s`));
|
|
|
|
} else {
|
|
|
|
elements.push(cssMemberTypeProblem(`Free ${collaborator} limit exceeded`));
|
|
|
|
}
|
|
|
|
if (annotations.hasTeam) {
|
|
|
|
// Add a link for adding a member. For a doc, streamline this so user can make
|
|
|
|
// the change and continue seamlessly.
|
|
|
|
// TODO: streamline for workspaces.
|
|
|
|
elements.push(cssLink(
|
|
|
|
{href: urlState().makeUrl({manageUsers: true})},
|
|
|
|
dom.on('click', (e) => {
|
|
|
|
if (this._options.appModel) {
|
|
|
|
e.preventDefault();
|
|
|
|
manageTeam(this._options.appModel,
|
|
|
|
() => this._model.reloadAnnotations(),
|
|
|
|
{ email: member.email }).catch(reportError);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
`Add ${member.name || 'member'} to your team`));
|
|
|
|
} else if (limit.at >= limit.top) {
|
|
|
|
elements.push(cssLink({href: commonUrls.plans, target: '_blank'},
|
|
|
|
'Create a team to share with more people'));
|
|
|
|
}
|
|
|
|
return elements;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
private _buildPublicAccessMember() {
|
|
|
|
const publicMember = this._model.publicMember;
|
|
|
|
if (!publicMember) { return null; }
|
|
|
|
return dom('div',
|
|
|
|
dom.maybe((use) => Boolean(use(publicMember.effectiveAccess)), () =>
|
|
|
|
cssMemberListItem(
|
|
|
|
cssPublicMemberIcon('PublicFilled'),
|
|
|
|
cssMemberText(
|
|
|
|
cssMemberPrimary('Public Access'),
|
2021-10-25 16:54:04 +00:00
|
|
|
cssMemberSecondary('Anyone with link ', makeCopyBtn(this._options.linkToCopy)),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
|
|
|
this._memberRoleSelector(publicMember.effectiveAccess, publicMember.inheritedAccess, false,
|
|
|
|
// Only show the Editor and Viewer options for the role of the "Public Access" member.
|
|
|
|
this._model.userSelectOptions.filter(opt => [roles.EDITOR, roles.VIEWER].includes(opt.value!))
|
|
|
|
),
|
|
|
|
cssMemberBtn(
|
|
|
|
cssRemoveIcon('Remove', testId('um-member-delete')),
|
|
|
|
dom.on('click', () => publicMember.access.set(null)),
|
|
|
|
),
|
|
|
|
testId('um-public-member')
|
|
|
|
)
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns a div containing a button that opens a menu to choose between roles.
|
|
|
|
private _memberRoleSelector(
|
|
|
|
role: Observable<string|null>,
|
|
|
|
inherited: Observable<roles.Role|null>,
|
|
|
|
isActiveUser: boolean,
|
|
|
|
allRolesOverride?: IOrgMemberSelectOption[],
|
|
|
|
) {
|
|
|
|
const allRoles = allRolesOverride ||
|
|
|
|
(this._model.isOrg ? this._model.orgUserSelectOptions : this._model.userSelectOptions);
|
|
|
|
return cssRoleBtn(
|
|
|
|
menu(() => [
|
|
|
|
dom.forEach(allRoles, _role =>
|
|
|
|
// The active user should be prevented from changing their own role.
|
|
|
|
menuItem(() => isActiveUser || role.set(_role.value), _role.label,
|
|
|
|
// Indicate which option is inherited, if any.
|
|
|
|
dom.text((use) => use(inherited) && (use(inherited) === _role.value)
|
|
|
|
&& !isActiveUser ? ' (inherited)' : ''),
|
|
|
|
// Disable everything providing less access than the inherited access
|
|
|
|
dom.cls('disabled', (use) =>
|
|
|
|
roles.getStrongestRole(_role.value, use(inherited)) !== _role.value),
|
|
|
|
testId(`um-role-option`)
|
|
|
|
)
|
|
|
|
),
|
|
|
|
// If the user's access is inherited, give an explanation on how to change it.
|
|
|
|
isActiveUser ? menuText(`User may not modify their own access.`) : null,
|
|
|
|
// If the user's access is inherited, give an explanation on how to change it.
|
|
|
|
dom.maybe((use) => use(inherited) && !isActiveUser, () => menuText(
|
|
|
|
`User inherits permissions from ${getResourceParent(this._model.resourceType)}. To remove, ` +
|
|
|
|
`set 'Inherit access' option to 'None'.`)),
|
|
|
|
// If the user is a guest, give a description of the guest permission.
|
|
|
|
dom.maybe((use) => !this._model.isOrg && use(role) === roles.GUEST, () => menuText(
|
|
|
|
`User has view access to ${this._model.resourceType} resulting from manually-set access ` +
|
|
|
|
`to resources inside. If removed here, this user will lose access to resources inside.`)),
|
|
|
|
this._model.isOrg ? menuText(`No default access allows access to be ` +
|
|
|
|
`granted to individual documents or workspaces, rather than the full team site.`) : null
|
|
|
|
]),
|
|
|
|
dom.text((use) => {
|
|
|
|
// Get the label of the active role. Note that the 'Guest' role is assigned when the role
|
|
|
|
// is not found because it is not included as a selection.
|
|
|
|
const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(role) === _role.value);
|
|
|
|
return activeRole ? activeRole.label : "Guest";
|
|
|
|
}),
|
|
|
|
cssCollapseIcon('Collapse'),
|
|
|
|
testId('um-member-role')
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Builds the max inherited role selection button and menu.
|
|
|
|
private _inheritRoleSelector() {
|
|
|
|
const role = this._model.maxInheritedRole;
|
|
|
|
const allRoles = this._model.inheritSelectOptions;
|
|
|
|
return cssOptionBtn(
|
|
|
|
menu(() => [
|
|
|
|
dom.forEach(allRoles, _role =>
|
|
|
|
menuItem(() => role.set(_role.value), _role.label,
|
|
|
|
testId(`um-role-option`)
|
|
|
|
)
|
|
|
|
)
|
|
|
|
]),
|
|
|
|
dom.text((use) => {
|
|
|
|
// Get the label of the active role.
|
|
|
|
const activeRole = allRoles.find((_role: IMemberSelectOption) => use(role) === _role.value);
|
|
|
|
return activeRole ? activeRole.label : "";
|
|
|
|
}),
|
|
|
|
cssCollapseIcon('Collapse'),
|
|
|
|
testId('um-max-inherited-role')
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Represents the widget that allows typing in an email and adding it.
|
|
|
|
* The border of the input turns green when the email is considered valid.
|
|
|
|
*/
|
|
|
|
export class MemberEmail extends Disposable {
|
|
|
|
public email = this.autoDispose(observable<string>(""));
|
|
|
|
public isEmpty = this.autoDispose(computed<boolean>((use) => !use(this.email)));
|
|
|
|
|
|
|
|
private _isValid = this.autoDispose(observable<boolean>(false));
|
|
|
|
private _emailElem: HTMLInputElement;
|
|
|
|
|
|
|
|
constructor(
|
2021-10-25 16:54:04 +00:00
|
|
|
private _onAdd: (email: string, role: roles.NonGuestRole) => void,
|
|
|
|
private _prompt?: {email: string},
|
2020-10-02 15:10:00 +00:00
|
|
|
) {
|
|
|
|
super();
|
2021-10-25 16:54:04 +00:00
|
|
|
if (_prompt) {
|
|
|
|
this.email.set(_prompt.email);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
// Reset custom validity that we sometimes set.
|
|
|
|
this.email.addListener(() => this._emailElem.setCustomValidity(""));
|
|
|
|
}
|
|
|
|
|
|
|
|
public buildDom(): Element {
|
|
|
|
const enableAdd: Computed<boolean> = computed((use) => Boolean(use(this.email) && use(this._isValid)));
|
2021-10-25 16:54:04 +00:00
|
|
|
const result = cssEmailInputContainer(
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.autoDispose(enableAdd),
|
|
|
|
cssMailIcon('Mail'),
|
|
|
|
this._emailElem = cssEmailInput(this.email, {onInput: true, isValid: this._isValid},
|
|
|
|
{type: "email", placeholder: "Enter email address"},
|
|
|
|
dom.onKeyPress({Enter: () => this._commit()}),
|
|
|
|
inputMenu(() => [
|
|
|
|
cssInputMenuItem(() => this._commit(),
|
|
|
|
cssUserImagePlus('+',
|
|
|
|
cssUserImage.cls('-large'),
|
|
|
|
cssUserImagePlus.cls('-invalid', (use) => !use(enableAdd))
|
|
|
|
),
|
|
|
|
cssMemberText(
|
|
|
|
cssMemberPrimary('Invite new member'),
|
|
|
|
cssMemberSecondary(
|
|
|
|
dom.text((use) => `We'll email an invite to ${use(this.email)}`)
|
|
|
|
)
|
|
|
|
),
|
|
|
|
testId('um-add-email')
|
|
|
|
)
|
|
|
|
], {
|
|
|
|
// NOTE: An offset of -40px is used to center the input button across the
|
|
|
|
// input container (including an envelope icon) rather than the input inside.
|
|
|
|
modifiers: {
|
|
|
|
offset: { enabled: true, offset: -40 }
|
|
|
|
},
|
|
|
|
stretchToSelector: `.${cssEmailInputContainer.className}`
|
|
|
|
})
|
|
|
|
),
|
|
|
|
cssEmailInputContainer.cls('-green', enableAdd),
|
|
|
|
testId('um-member-new')
|
|
|
|
);
|
2021-10-25 16:54:04 +00:00
|
|
|
if (this._prompt) {
|
|
|
|
this._emailElem.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
}
|
|
|
|
return result;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Add the currently entered email if valid, or trigger a validation message if not.
|
|
|
|
private _commit() {
|
|
|
|
this._emailElem.setCustomValidity("");
|
|
|
|
this._isValid.set(this._emailElem.checkValidity());
|
|
|
|
if (this.email.get() && this._isValid.get()) {
|
|
|
|
try {
|
|
|
|
this._onAdd(this.email.get(), roles.VIEWER);
|
|
|
|
this._reset();
|
|
|
|
} catch (e) {
|
|
|
|
this._emailElem.setCustomValidity(e.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this._emailElem.reportValidity();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Reset the widget.
|
|
|
|
private _reset() {
|
|
|
|
this.email.set("");
|
|
|
|
this._emailElem.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns a new FullUser object from an IEditableMember.
|
|
|
|
function getFullUser(member: IEditableMember): FullUser {
|
|
|
|
return {
|
|
|
|
id: member.id,
|
|
|
|
name: member.name,
|
|
|
|
email: member.email,
|
|
|
|
picture: member.picture
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create a "Copy Link" button.
|
2020-11-26 02:29:13 +00:00
|
|
|
function makeCopyBtn(linkToCopy: string|undefined, ...domArgs: DomElementArg[]) {
|
|
|
|
return linkToCopy && cssCopyBtn(cssCopyIcon('Copy'), 'Copy Link',
|
|
|
|
dom.on('click', (ev, elem) => copyLink(elem, linkToCopy)),
|
2020-10-02 15:10:00 +00:00
|
|
|
testId('um-copy-link'),
|
|
|
|
...domArgs,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Copy the current document link to clipboard, and notify the user with a transient popup near
|
|
|
|
// the given element.
|
2020-11-26 02:29:13 +00:00
|
|
|
async function copyLink(elem: HTMLElement, link: string) {
|
2020-10-02 15:10:00 +00:00
|
|
|
await copyToClipboard(link);
|
|
|
|
setTestState({clipboard: link});
|
|
|
|
showTransientTooltip(elem, 'Link copied to clipboard', {key: 'copy-doc-link'});
|
|
|
|
}
|
|
|
|
|
2021-10-25 16:54:04 +00:00
|
|
|
async function manageTeam(appModel: AppModel,
|
|
|
|
onSave?: () => Promise<void>,
|
|
|
|
prompt?: { email: string }) {
|
|
|
|
await urlState().pushUrl({manageUsers: false});
|
|
|
|
const user = appModel.currentValidUser;
|
|
|
|
const currentOrg = appModel.currentOrg;
|
|
|
|
if (currentOrg) {
|
|
|
|
const api = appModel.api;
|
|
|
|
showUserManagerModal(api, {
|
|
|
|
permissionData: api.getOrgAccess(currentOrg.id),
|
|
|
|
activeEmail: user ? user.email : null,
|
|
|
|
resourceType: 'organization',
|
|
|
|
resourceId: currentOrg.id,
|
|
|
|
onSave,
|
|
|
|
prompt,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const cssUserManagerBody = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
width: 600px;
|
|
|
|
height: 374px;
|
|
|
|
border-bottom: 1px solid ${colors.darkGrey};
|
|
|
|
font-size: ${vars.mediumFontSize};
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssCopyBtn = styled(basicButton, `
|
|
|
|
border: none;
|
|
|
|
font-weight: normal;
|
|
|
|
padding: 0 8px;
|
|
|
|
&-header {
|
|
|
|
float: right;
|
|
|
|
margin-top: 8px;
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssCopyIcon = styled(icon, `
|
|
|
|
margin-right: 4px;
|
|
|
|
margin-top: -2px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssOptionRow = styled('div', `
|
|
|
|
font-size: ${vars.mediumFontSize};
|
|
|
|
margin: 0 63px 23px 63px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssOptionBtn = styled('span', `
|
|
|
|
display: inline-flex;
|
|
|
|
font-size: ${vars.mediumFontSize};
|
|
|
|
color: ${colors.lightGreen};
|
|
|
|
cursor: pointer;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssPublicMemberIcon = styled(icon, `
|
|
|
|
width: 32px;
|
|
|
|
height: 32px;
|
|
|
|
margin: 4px 8px;
|
|
|
|
--icon-color: ${colors.lightGreen};
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssUndoIcon = styled(icon, `
|
|
|
|
margin: 12px 0;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssRoleBtn = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
justify-content: flex-end;
|
|
|
|
font-size: ${vars.mediumFontSize};
|
|
|
|
color: ${colors.lightGreen};
|
|
|
|
margin: 12px 24px;
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
&.disabled {
|
|
|
|
cursor: default;
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssCollapseIcon = styled(icon, `
|
|
|
|
margin-top: 1px;
|
|
|
|
background-color: var(--grist-color-light-green);
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssInputMenuItem = styled(menuItem, `
|
|
|
|
height: 64px;
|
|
|
|
padding: 8px 15px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssUserImagePlus = styled(cssUserImage, `
|
|
|
|
background-color: ${colors.lightGreen};
|
|
|
|
margin: auto 0;
|
|
|
|
|
|
|
|
&-invalid {
|
|
|
|
background-color: ${colors.mediumGrey};
|
|
|
|
}
|
|
|
|
|
|
|
|
.${cssMenuItem.className}-sel & {
|
|
|
|
background-color: white;
|
|
|
|
color: ${colors.lightGreen};
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
2020-12-04 23:29:29 +00:00
|
|
|
const cssAccessLink = styled(cssLink, `
|
|
|
|
align-self: center;
|
|
|
|
margin-left: auto;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
|
|
|
|
// Render the name "organization" as "team site" in UI
|
|
|
|
function renderType(resourceType: ResourceType): string {
|
|
|
|
return resourceType === 'organization' ? 'team site' : resourceType;
|
|
|
|
}
|